State in Vue

Intro

Like most JS frameworks Vue supports state. And like the others Vue has component & global state.

Let's build a Todo list to learn how to CRUD using local & global state in Vue.

Local

Start by defining the TodosContainer.vue component.

Create

Start with defining your state vars.

The newTodo var will hold the new todo we're adding & todos our list of todos.

./components/Todos/TodosContainer.vue
vue
1
<script setup>
2
const todos = ref([])
3
const newTodo = ref('')
4
</script>
5
6
<template>
7
<div>
8
<h1>Todos</h1>
9
</div>
10
</template>

Next add an input to update newTodo .

Update newTodo when the input changes by binding it to the input using v-model

./components/Todos/TodosContainer.vue
vue
1
<script setup>
2
const todos = ref([])
3
const newTodo = ref('')
4
</script>
5
6
<template>
7
<div>
8
<h1>Todos</h1>
9
<input
10
autofocus
11
v-model="newTodo"
12
class="text-black px-1"
13
/>
14
</div>
15
</template>

Next define a handler, addTodo , that implements the logic of adding a todo to our todos list.

./components/Todos/TodosContainer.vue
vue
1
<script setup>
2
const todos = ref([])
3
const newTodo = ref('')
4
5
function addTodo() {
6
const todo = {
7
done: false,
8
name: newTodo.value,
9
id: todos.value.length + 1,
10
}
11
todos.value.push(todo)
12
newTodo.value = ''
13
}
14
</script>
15
16
<template>
17
<div>
18
<h1>Todos</h1>
19
<input
20
autofocus
21
v-model="newTodo"
22
class="text-black px-1"
23
/>
24
</div>
25
</template>

And lastly trigger addTodo when the user hits the enter key by binding addTodo to the enter key up event.

./components/Todos/TodosContainer.vue
vue
1
<script setup>
2
const todos = ref([])
3
const newTodo = ref('')
4
5
function addTodo() {
6
const todo = {
7
done: false,
8
name: newTodo.value,
9
id: todos.value.length + 1,
10
}
11
todos.value.push(todo)
12
newTodo.value = ''
13
}
14
</script>
15
16
<template>
17
<div>
18
<h1>Todos</h1>
19
<input
20
autofocus
21
v-model="newTodo"
22
class="text-black px-1"
23 +
@keyup.enter="addTodo"
24
/>
25
</div>
26
</template>

You'll now see todos update when you enter a new todo and press enter in your console.

Read

Next use a v-for to render the todos .

./components/Todos/TodosContainer.vue
vue
1
<script setup>
2
const todos = ref([])
3
const newTodo = ref('')
4
5
function addTodo() {
6
const todo = {
7
done: false,
8
name: newTodo.value,
9
id: todos.value.length + 1,
10
}
11
todos.value.push(todo)
12
newTodo.value = ''
13
}
14
</script>
15
16
<template>
17
<div>
18
<h1>Todos</h1>
19
<input
20
autofocus
21
v-model="newTodo"
22
class="text-black px-1"
23
@keyup.enter="addTodo"
24
/>
25
<ul>
26
<li
27
:key="todo.id"
28
v-for="todo of todos"
29
class="flex flex-row justify-between"
30
>
31
<span v-text="todo.name" />
32
</li>
33
</ul>
34
</div>
35
</template>

Next implement the ability to add a todo by defining the handler & a state var newTodo to hold the name of the todo

Update

Now let's add the ability to update a todo.

Define a function to toggle the status of a todo, toggleStatus .

./components/Todos/TodosContainer.vue
vue
1
<script setup>
2
const todos = ref([])
3
const newTodo = ref('')
4
5
function addTodo() {
6
const todo = {
7
done: false,
8
name: newTodo.value,
9
id: todos.value.length + 1,
10
}
11
todos.value.push(todo)
12
newTodo.value = ''
13
}
14
function toggleStatus(id) {
15
const idx = todos.value.findIndex((t) => t.id === id)
16
const todo = todos.value[idx]
17
todo.done = !todo.done
18
todos.value[idx] = todo
19
}
20
</script>
21
22
<template>
23
<div>
24
<h1>Todos</h1>
25
<input
26
autofocus
27
v-model="newTodo"
28
class="text-black px-1"
29
@keyup.enter="addTodo"
30
/>
31
<ul>
32
<li
33
:key="todo.id"
34
v-for="todo of todos"
35
class="flex flex-row justify-between"
36
>
37
<span v-text="todo.name" />
38
</li>
39
</ul>
40
</div>
41
</template>

Bind toggleStatus to the @click of each todo item.

Also make sure to pass the id of the todo to toggleStatus as well.

./components/Todos/TodosContainer.vue
vue
1
<script setup>
2
const todos = ref([])
3
const newTodo = ref('')
4
5
function addTodo() {
6
const todo = {
7
done: false,
8
name: newTodo.value,
9
id: todos.value.length + 1,
10
}
11
todos.value.push(todo)
12
newTodo.value = ''
13
}
14
function toggleStatus(id) {
15
const idx = todos.value.findIndex((t) => t.id === id)
16
const todo = todos.value[idx]
17
todo.done = !todo.done
18
todos.value[idx] = todo
19
}
20
</script>
21
22
<template>
23
<div>
24
<h1>Todos</h1>
25
<input
26
autofocus
27
v-model="newTodo"
28
class="text-black px-1"
29
@keyup.enter="addTodo"
30
/>
31
<ul>
32
<li
33
:key="todo.id"
34
v-for="todo of todos"
35 +
@click="toggleStatus(todo.id)"
36
class="flex flex-row justify-between"
37
>
38
<span v-text="todo.name" />
39
</li>
40
</ul>
41
</div>
42
</template>

Lastly programmatically add the class .done to each todo item.

Also define a class .done in the style tag which will give the todo a line-through if it's status is done.

./components/Todos/TodosContainer.vue
vue
1
<script setup>
2
const todos = ref([])
3
const newTodo = ref('')
4
5
function addTodo() {
6
const todo = {
7
done: false,
8
name: newTodo.value,
9
id: todos.value.length + 1,
10
}
11
todos.value.push(todo)
12
newTodo.value = ''
13
}
14
function toggleStatus(id) {
15
const idx = todos.value.findIndex((t) => t.id === id)
16
const todo = todos.value[idx]
17
todo.done = !todo.done
18
todos.value[idx] = todo
19
}
20
</script>
21
22
<template>
23
<div>
24
<h1>Todos</h1>
25
<input
26
autofocus
27
v-model="newTodo"
28
class="text-black px-1"
29
@keyup.enter="addTodo"
30
/>
31
<ul>
32
<li
33
:key="todo.id"
34
v-for="todo of todos"
35
:class="{ done: todo.done }"
36
@click="toggleStatus(todo.id)"
37
class="flex flex-row justify-between"
38
>
39
<span v-text="todo.name" />
40
</li>
41
</ul>
42
</div>
43
</template>
44
45
<style>
46
.done {
47
color: indianred;
48
text-decoration: line-through;
49
}
50
</style>

Delete

The last thing we need to do is add the ability to remove a todo item.

Define removeTodo which finds a todo in the list via id and removes it from the list.

./components/Todos/TodosContainer.vue
vue
1
<script setup>
2
const todos = ref([])
3
const newTodo = ref('')
4
5
function addTodo() {
6
const todo = {
7
done: false,
8
name: newTodo.value,
9
id: todos.value.length + 1,
10
}
11
todos.value.push(todo)
12
newTodo.value = ''
13
}
14
function toggleStatus(id) {
15
const idx = todos.value.findIndex((t) => t.id === id)
16
const todo = todos.value[idx]
17
todo.done = !todo.done
18
todos.value[idx] = todo
19
}
20
function removeTodo(id) {
21
const idx = todos.value.findIndex((t) => t.id === id)
22
todos.value.splice(idx, 1)
23
}
24
</script>
25
26
<template>
27
<div>
28
<h1>Todos</h1>
29
<input
30
autofocus
31
v-model="newTodo"
32
class="text-black px-1"
33
@keyup.enter="addTodo"
34
/>
35
<ul>
36
<li
37
:key="todo.id"
38
v-for="todo of todos"
39
:class="{ done: todo.done }"
40
@click="toggleStatus(todo.id)"
41
class="flex flex-row justify-between"
42
>
43
<span v-text="todo.name" />
44
</li>
45
</ul>
46
</div>
47
</template>
48
49
<style>
50
.done {
51
color: indianred;
52
text-decoration: line-through;
53
}
54
</style>

Bind removeTodo to an @click event of an HTML element of your choice.

I imported & used an icon here.

Once @click of the todo item is triggered you'll see the todo removed from the list.

./components/Todos/TodosContainer.vue
vue
1
<script setup>
2
import XIcon from '~/assets/images/icons/XIcon.vue'
3
const todos = ref([])
4
const newTodo = ref('')
5
6
function addTodo() {
7
const todo = {
8
done: false,
9
name: newTodo.value,
10
id: todos.value.length + 1,
11
}
12
todos.value.push(todo)
13
newTodo.value = ''
14
}
15
function toggleStatus(id) {
16
const idx = todos.value.findIndex((t) => t.id === id)
17
const todo = todos.value[idx]
18
todo.done = !todo.done
19
todos.value[idx] = todo
20
}
21
function removeTodo(id) {
22
const idx = todos.value.findIndex((t) => t.id === id)
23
todos.value.splice(idx, 1)
24
}
25
</script>
26
27
<template>
28
<div>
29
<h1>Todos</h1>
30
<input
31
autofocus
32
v-model="newTodo"
33
class="text-black px-1"
34
@keyup.enter="addTodo"
35
/>
36
<ul>
37
<li
38
:key="todo.id"
39
v-for="todo of todos"
40
:class="{ done: todo.done }"
41
@click="toggleStatus(todo.id)"
42
class="flex flex-row justify-between"
43
>
44
<span v-text="todo.name" />
45
<XIcon @click="removeTodo(todo.id)" />
46
</li>
47
</ul>
48
</div>
49
</template>
50
51
<style>
52
.done {
53
color: indianred;
54
text-decoration: line-through;
55
}
56
</style>

Global

Intro

It's often the case that state needs to be shared throughout the application.

To achieve this a parent component could define state and pass it to it's children using props.

This works but it creates a few problems:

  • The parent component bloats.
  • Components are now tightly coupled.
  • Components now have parent/child hierarchy.

Vue has a better solution:

useState()

The useState method is provided by Vue.

To refactor todos to a global state do the following.

Refactor the initialization of todos to the following:

./components/Todos/TodosContainer.vue
vue
1
<script setup>
2 -
const todos = ref([])
3 +
const todos = useState('todos', () => [])
4
5
// etc...
6
</script>
7
8
<template>
9
<!-- etc... -->
10
</template>
11
12
<style>
13
/* etc... */
14
</style>

Now you can use the same state in other components by calling useState() again.

This case for example, you grab the todos and count the done and undone to provide additional context to the user.

./components/Todos/TodosMeta.vue
vue
1
<script setup>
2
const todos = useState('todos', () => [])
3
const countDone = computed(() => todos.value.filter((t) => t.done).length)
4
const countUndone = computed(() => todos.value.filter((t) => !t.done).length)
5
</script>
6
<template>
7
<div>
8
<div>
9
<label>Done Count</label>
10
<div>
11
<span v-text="countDone" />
12
</div>
13
</div>
14
<div>
15
<label>Undone Count</label>
16
<div>
17
<span v-text="countUndone" />
18
</div>
19
</div>
20
</div>
21
</template>

Now you'll see that when you add a todo to your todos list then both components, TodosMeta & TodosContainer update.

Pinia