Build a To-Do App
A complete small app: add, edit, complete, filter and persist items.
The to-do list is the "hello world" of front-end frameworks because it uses nearly everything you need day to day: rendering a list, a form, events, derived state, editing in place and saving data. This lesson builds one in six small steps. Each step is a Try it yourself editor you can change and re-run, and the finished app is at the bottom.
setup(), and Vue's ref, computed, watch and so on are in scope.
Step 1: state and a list
Start with the data. A to-do is an object with an id, title and done flag, and the list is a ref holding an array. v-for renders it, with :key set to the id so Vue can track each row.
Step 2: adding items
A form with v-model on the input and @submit.prevent on the form. .prevent stops the browser reloading the page. Trim the input and ignore empty titles.
Step 3: completing and removing
Bind a checkbox straight to todo.done with v-model: Vue's reactivity tracks changes to objects inside the array. Removing is a filter that keeps every item except the one clicked.
Step 4: filters and counts with computed
"All / Active / Completed" filters and the "2 items left" counter are derived state: they're calculated from todos, so they belong in computed(), not in extra refs you'd have to keep in sync.
Step 5: editing in place
Double-click a title to edit it. Track which item is being edited (editingId), show an input for that row only, and save on Enter or blur. Escape cancels.
Step 6: saving to localStorage
A deep watch on the list writes it to localStorage whenever anything changes, and the initial value is read back when the component starts. Wrap storage access in try/catch: it can throw in private windows or when storage is full.
Add a few items, then press Run (which re-creates the app from scratch): your list comes back.
The finished app
Everything above, put together as two components: a TodoApp that owns the state, and a TodoItem that receives a todo as a prop and emits toggle, remove and rename events (see Props & Emits). Your items are saved in this browser.
The component split looks like this in a real project:
<!-- TodoItem.vue -->
<script setup>
import { ref, nextTick, useTemplateRef } from 'vue' // useTemplateRef is Vue 3.5+
const props = defineProps({ todo: { type: Object, required: true } })
const emit = defineEmits(['toggle', 'remove', 'rename'])
const editing = ref(false)
const draft = ref('')
const input = useTemplateRef('input')
async function startEdit() {
draft.value = props.todo.title
editing.value = true
await nextTick() // wait for the <input> to render
input.value.focus()
}
function finish() {
if (!editing.value) return
editing.value = false
if (draft.value.trim() && draft.value.trim() !== props.todo.title) {
emit('rename', props.todo.id, draft.value.trim())
}
}
</script>
<template>
<li :class="{ done: todo.done }">
<input type="checkbox" :checked="todo.done" @change="emit('toggle', todo.id)">
<input v-if="editing" ref="input" v-model="draft" @keyup.enter="finish" @blur="finish">
<span v-else @dblclick="startEdit">{{ todo.title }}</span>
<button @click="emit('remove', todo.id)" aria-label="Remove">×</button>
</li>
</template>
props.todo itself. It emits rename and the parent updates its own array, so the list has one owner.
Where to go next
- Swap
localStoragefor the Laravel API: Fetching Data from an API. - Move the state into a Pinia store when a second component needs it: State Management with Pinia.
- Extract the storage logic into a
useLocalStorage()composable: Composables.
Key takeaways
- A to-do app exercises the core of Vue:
refstate,v-forwith:key,v-model,@submit.preventandcomputed. - Filtered lists and counters are derived state, so compute them instead of storing them.
- Edit in place by tracking which item is being edited, not by adding an
editingflag to every item. - Persist with a deep
watchandlocalStorage, guarded bytry/catch. - Split into parent (owns state) and child (props in, events out) once the template grows.