Overview
A todo app is the classic first Vue project because it exercises the three ideas everything else in Vue builds on: reactive state created with ref, a template that reads and writes that state directly through v-model and v-for, and a computed property that derives new data from state without ever duplicating it. Nothing about it needs routing, a store, or a server, so it stays focused on those fundamentals.
By the end of this tutorial you will have a task manager where a single tasks ref in the top-level App component is the one source of truth. A TodoItem component renders each row from a task prop and emits events back up when it is clicked, a FilterBar component switches between All/Active/Completed views, and a watcher keeps every change saved to localStorage so the list survives a page refresh.
- A
tasksref holding an array inApp, updated directly — Vue's reactivity tracks in-place mutations, unlike React. - A
TodoItemcomponent that receives a task as a prop and emitstoggle/deleteevents instead of changing state itself. - A form using
v-modelto add a new task without reloading the page. - Click-to-toggle-complete and a delete button, both implemented as emitted events handled in
App. - A
FilterBarcomponent for switching between All, Active, and Completed views. - Automatic saving to and loading from
localStoragewith awatcher, so tasks persist across reloads.
Prerequisites
- Vue template syntax basics —
v-for,v-if,:(attribute binding), and@(event binding). - Single File Components (
.vuefiles) — a component made of a<script>block, a<template>block, and (optionally) a<style>block. - Props and custom events (
emit) — passing data into a component and having it notify its parent of changes. - Solid core JavaScript — array methods (
map,filter,find), spreading objects with..., and arrow functions. - This is your first Vue project, so
ref,computed, andwatchare introduced here from scratch — you do not need to know them already.
Project Structure
The app is split into three components, each with one clear job. App is the only component that holds state — it owns the tasks ref, the current filter, and every function that changes them. TodoItem and FilterBar hold no state of their own; they are purely props-and-emits-driven, meaning everything they display comes in as a prop and everything they trigger goes back out as an emitted event. Keeping state in exactly one place is what guarantees the list, the filter buttons, and localStorage can never disagree about what the current tasks actually are.
src/ App.vue // Owns tasks and filter state; the only component that changes them TodoItem.vue // One task row; purely props-in / events-out, no state of its own FilterBar.vue // The All / Active / Completed buttons; purely props-in / events-out style.css // Shared dark-theme styling for the whole app main.js // Mounts <App /> into the page with createApp()Here is the shared stylesheet used by every component below. It does not change again after this — every remaining step only edits component files.
* { box-sizing: border-box; margin: 0; padding: 0;}
body { font-family: Arial, Helvetica, sans-serif; background: #1a1d23; color: #e8e8e8; display: flex; justify-content: center; padding: 40px 16px;}
.todo-app { width: 100%; max-width: 420px; background: #23262e; border-radius: 10px; padding: 24px;}
.todo-app h1 { margin-bottom: 16px; font-size: 1.5rem;}
.todo-form { display: flex; gap: 8px; margin-bottom: 16px;}
.todo-form input { flex: 1; /* input grows to fill the space; the button keeps its natural width */ padding: 10px 12px; border-radius: 6px; border: 1px solid #3a3f4b; background: #1a1d23; color: #e8e8e8; font-size: 1rem;}
.todo-form button { padding: 10px 16px; border: none; border-radius: 6px; background: #42b883; color: white; cursor: pointer; font-weight: bold;}
.filter-bar { display: flex; gap: 8px; margin-bottom: 16px;}
.filter-btn { flex: 1; padding: 8px 0; border: 1px solid #3a3f4b; border-radius: 6px; background: transparent; color: #9aa4b2; cursor: pointer;}
.filter-btn.active { background: #42b883; color: white; border-color: #42b883;}
.todo-list { list-style: none;}
.todo-item { display: flex; align-items: center; gap: 10px; padding: 10px 4px; border-bottom: 1px solid #3a3f4b;}
.todo-item.completed .todo-text { text-decoration: line-through; /* visual cue that a task is finished */ opacity: 0.6;}
.todo-text { flex: 1; cursor: pointer; /* clicking the text (not just a checkbox) toggles completion */}
.delete-btn { background: transparent; border: none; color: #ff6b6b; cursor: pointer; font-size: 1.1rem; line-height: 1;}
.empty-message { text-align: center; color: #888; padding: 12px 0;}Step 1: Set Up Reactive State with ref
ref wraps a value in a reactive container — reading or writing .value is what Vue tracks to know when to re-render. App needs three independent pieces of state: the tasks themselves, the text currently being typed into the "add task" input, and which filter is active — each gets its own ref rather than being crammed into one object, since they change independently of each other.
One quick note before the code: real Vue projects scaffolded with create-vue or Vite write components using <script setup> — a compile-time shorthand where every top-level binding is automatically exposed to the <template> block, no explicit return needed. That is what every code block in this tutorial's Steps and Complete Code section uses. The live, runnable demo at the bottom of this page is different: browsers can't compile <script setup> on the fly, so it uses Vue's CDN "global build" instead, which compiles templates at runtime but requires the older setup() { ... return {...} } function form. Both forms are genuine Vue — <script setup> is just syntax sugar that a build step (Vite) expands into the function form under the hood.
<!-- App.vue --><script setup>import { ref } from 'vue';
// tasks is the single source of truth for every task in the list — every// other piece of UI (the rendered rows, the filter counts) is derived FROM// this array whenever it's read, never stored as a separate copyconst tasks = ref([]);
// What the user is currently typing in the "add task" input. Kept separate// from tasks because draft text isn't a task yet — it only becomes one// when the form is submittedconst newTaskText = ref('');
// 'all' | 'active' | 'completed' — a plain string ref is enough here, no// need for a whole object just to track which filter button is selectedconst filter = ref('all');</script>
<template> <div class="todo-app"> <h1>My Tasks</h1> <!-- Form, list, and filter bar are filled in over the next steps --> </div></template>Every task will be a plain object shaped like { id, text, completed } — the same shape a plain-JavaScript version of this project would use, since Vue state is still just JavaScript data wrapped in a reactive proxy. Vue's job is only to keep the screen in sync with it.
Step 2: Render Tasks with a TodoItem Component
TodoItem is a separate component so App's template stays a simple v-for instead of a long block of row-building markup. It receives a task object as a prop and declares two events it can emit — it never touches tasks directly, because App is the only component allowed to change that state.
<!-- TodoItem.vue --><script setup>// defineProps/defineEmits are compiler macros — available automatically// inside <script setup>, no import needed. This component owns no state of// its own — everything it needs to display or trigger arrives as a prop or// leaves as an emitted event, which is what keeps App's tasks ref as the// one and only source of truthdefineProps({ task: Object });const emit = defineEmits(['toggle', 'delete']);</script>
<template> <li :class="['todo-item', { completed: task.completed }]"> <!-- Clicking the text emits an event for App to handle instead of mutating task.completed directly — this component has no access to App's tasks ref and shouldn't need it --> <span class="todo-text" @click="emit('toggle', task.id)"> {{ task.text }} </span> <button type="button" class="delete-btn" @click="emit('delete', task.id)"> × </button> </li></template>Back in App, v-for turns the array into one <TodoItem> per task. The :key binding is required on every item in a list — it is how Vue matches each <TodoItem> to the same underlying task across re-renders instead of throwing every row away and rebuilding it from scratch.
<!-- App.vue --><script setup>import { ref } from 'vue';import TodoItem from './TodoItem.vue';
const tasks = ref([]);const newTaskText = ref('');const filter = ref('all');</script>
<template> <div class="todo-app"> <h1>My Tasks</h1>
<p v-if="tasks.length === 0" class="empty-message">No tasks yet. Add one below to get started!</p>
<ul class="todo-list"> <!-- :key must be stable and unique per item, not the array index — an index would break if tasks are ever reordered or deleted --> <TodoItem v-for="task in tasks" :key="task.id" :task="task" @toggle="() => {}" @delete="() => {}" /> </ul> </div></template>Step 3: Add a New Task with v-model
v-model is Vue's shorthand for a "controlled" input — it binds the input's value to a ref AND listens for input events to update that ref, in one directive, so the ref and the visible input can never drift apart. Submitting the form builds a new task object and pushes it onto tasks.
<script setup>function handleSubmit() { const trimmed = newTaskText.value.trim(); // trim() so a string of only spaces doesn't create a blank task if (trimmed === '') { return; // silently ignore empty submissions }
const newTask = { id: Date.now(), // a timestamp is unique enough for a client-only list; a real backend would assign this instead text: trimmed, completed: false, };
// Unlike React state, tasks.value can be mutated directly — Vue's // reactivity system wraps the array in a Proxy, so push() is detected and // triggers a re-render on its own, no new array needs to be built tasks.value.push(newTask); newTaskText.value = ''; // clear the input now that the task was added}</script>
<template> <!-- @submit.prevent stops the browser's default full-page reload on submit --> <form class="todo-form" @submit.prevent="handleSubmit"> <input v-model="newTaskText" type="text" placeholder="What do you need to do?" autocomplete="off" /> <button type="submit">Add</button> </form></template>Click Run to see what this code prints.
Step 4: Toggle and Delete Tasks
Both toggleTask and deleteTask live in App, right next to tasks, and get passed down to every TodoItem as @toggle/@delete handlers. Because tasks is a ref around a reactive array, both functions can find the task in place and mutate it directly — no spreading into a brand-new array required, unlike the equivalent React version of this project.
<script setup>function toggleTask(id) { // find() returns a reference INTO the reactive array — flipping a // property on it is picked up by Vue's Proxy-based reactivity immediately const task = tasks.value.find((t) => t.id === id); if (task) { task.completed = !task.completed; }}
function deleteTask(id) { const index = tasks.value.findIndex((t) => t.id === id); if (index !== -1) { tasks.value.splice(index, 1); // splice() is also a tracked mutation, same as push() }}</script>
<template> <!-- Now the real handlers replace the empty placeholders from Step 2 --> <TodoItem v-for="task in tasks" :key="task.id" :task="task" @toggle="toggleTask" @delete="deleteTask" /></template>Step 5: Filter Tasks with a Computed Property
computed creates a value that recalculates automatically whenever the reactive data it reads changes, and — unlike a plain function call — caches its result until one of those dependencies actually changes. filteredTasks is never stored in its own ref; deriving it instead of duplicating it means the filtered view can never fall out of sync with the real task list.
<!-- FilterBar.vue --><script setup>// One small, purely props-in/events-out component for the filter buttonsdefineProps({ filter: String });const emit = defineEmits(['update:filter']);
const filters = ['all', 'active', 'completed'];</script>
<template> <div class="filter-bar"> <button v-for="f in filters" :key="f" type="button" :class="['filter-btn', { active: filter === f }]" @click="emit('update:filter', f)" > {{ f.charAt(0).toUpperCase() + f.slice(1) }} </button> </div></template><!-- App.vue --><script setup>import { ref, computed } from 'vue';
// ...tasks, newTaskText, filter, handleSubmit, toggleTask, deleteTask from before...
// Computed once, re-evaluated only when tasks or filter.value actually// change — derived data, not its own piece of stateconst filteredTasks = computed(() => { if (filter.value === 'active') return tasks.value.filter((t) => !t.completed); if (filter.value === 'completed') return tasks.value.filter((t) => t.completed); return tasks.value; // filter.value === 'all'});</script>
<template> <!-- update:filter is a naming convention Vue recognizes for a custom v-model-like prop — filter.value = $event assigns straight back --> <FilterBar :filter="filter" @update:filter="filter = $event" />
<p v-if="filteredTasks.length === 0" class="empty-message">No tasks to show.</p>
<ul class="todo-list"> <!-- now loops over filteredTasks instead of the raw tasks list --> <TodoItem v-for="task in filteredTasks" :key="task.id" :task="task" @toggle="toggleTask" @delete="deleteTask" /> </ul></template>Step 6: Persist Tasks with a Watcher
Loading and saving use two different Vue lifecycle tools. Loading happens in onMounted, which runs once after the component's first render — reading localStorage there (rather than leaving tasks empty until some later event) avoids a flash of an empty list. Saving uses watch(tasks, ...) with { deep: true }, so it re-runs whenever anything inside the tasks array changes — deep: true is required here because push/splice mutate the array in place rather than replacing tasks.value with a new reference, and a plain (non-deep) watcher only fires on that kind of reassignment.
<script setup>import { ref, computed, watch, onMounted } from 'vue';
const STORAGE_KEY = 'vue-todo-app-tasks';
const tasks = ref([]);const newTaskText = ref('');const filter = ref('all');
// Runs once, right after the component mounts — reading localStorage here// means tasks starts correct almost immediately, with only a brief instant// of an empty list rather than never loading saved data at allonMounted(() => { const saved = localStorage.getItem(STORAGE_KEY); if (saved) { tasks.value = JSON.parse(saved); }});
// { deep: true } is what makes this watcher fire on push()/splice() mutations// to the array, not just on tasks.value being replaced outrightwatch( tasks, (newTasks) => { localStorage.setItem(STORAGE_KEY, JSON.stringify(newTasks)); }, { deep: true });
// ...rest of the component from the previous steps</script>Vue also offers watchEffect, which automatically tracks whatever reactive values its callback reads instead of naming them explicitly — it is not used here because watch(tasks, ...) is more explicit about exactly which piece of state triggers the save, which matters once a component tracks more than one independent ref.
Complete Code
Here is the complete project — the stylesheet and every component file, fully assembled from the steps above, written the way a real Vite-scaffolded Vue project would structure it.
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: Arial, Helvetica, sans-serif; background: #1a1d23; color: #e8e8e8; display: flex; justify-content: center; padding: 40px 16px;}
.todo-app { width: 100%; max-width: 420px; background: #23262e; border-radius: 10px; padding: 24px; }.todo-app h1 { margin-bottom: 16px; font-size: 1.5rem; }.todo-form { display: flex; gap: 8px; margin-bottom: 16px; }.todo-form input { flex: 1; padding: 10px 12px; border-radius: 6px; border: 1px solid #3a3f4b; background: #1a1d23; color: #e8e8e8; font-size: 1rem; }.todo-form button { padding: 10px 16px; border: none; border-radius: 6px; background: #42b883; color: white; cursor: pointer; font-weight: bold; }.filter-bar { display: flex; gap: 8px; margin-bottom: 16px; }.filter-btn { flex: 1; padding: 8px 0; border: 1px solid #3a3f4b; border-radius: 6px; background: transparent; color: #9aa4b2; cursor: pointer; }.filter-btn.active { background: #42b883; color: white; border-color: #42b883; }.todo-list { list-style: none; }.todo-item { display: flex; align-items: center; gap: 10px; padding: 10px 4px; border-bottom: 1px solid #3a3f4b; }.todo-item.completed .todo-text { text-decoration: line-through; opacity: 0.6; }.todo-text { flex: 1; cursor: pointer; }.delete-btn { background: transparent; border: none; color: #ff6b6b; cursor: pointer; font-size: 1.1rem; line-height: 1; }.empty-message { text-align: center; color: #888; padding: 12px 0; }<!-- TodoItem.vue --><script setup>defineProps({ task: Object });const emit = defineEmits(['toggle', 'delete']);</script>
<template> <li :class="['todo-item', { completed: task.completed }]"> <span class="todo-text" @click="emit('toggle', task.id)">{{ task.text }}</span> <button type="button" class="delete-btn" @click="emit('delete', task.id)">×</button> </li></template><!-- FilterBar.vue --><script setup>defineProps({ filter: String });const emit = defineEmits(['update:filter']);const filters = ['all', 'active', 'completed'];</script>
<template> <div class="filter-bar"> <button v-for="f in filters" :key="f" type="button" :class="['filter-btn', { active: filter === f }]" @click="emit('update:filter', f)" > {{ f.charAt(0).toUpperCase() + f.slice(1) }} </button> </div></template><!-- App.vue --><script setup>import { ref, computed, watch, onMounted } from 'vue';import TodoItem from './TodoItem.vue';import FilterBar from './FilterBar.vue';
const STORAGE_KEY = 'vue-todo-app-tasks';
const tasks = ref([]);const newTaskText = ref('');const filter = ref('all');
onMounted(() => { const saved = localStorage.getItem(STORAGE_KEY); if (saved) tasks.value = JSON.parse(saved);});
watch(tasks, (newTasks) => { localStorage.setItem(STORAGE_KEY, JSON.stringify(newTasks));}, { deep: true });
function handleSubmit() { const trimmed = newTaskText.value.trim(); if (trimmed === '') return; tasks.value.push({ id: Date.now(), text: trimmed, completed: false }); newTaskText.value = '';}
function toggleTask(id) { const task = tasks.value.find((t) => t.id === id); if (task) task.completed = !task.completed;}
function deleteTask(id) { const index = tasks.value.findIndex((t) => t.id === id); if (index !== -1) tasks.value.splice(index, 1);}
const filteredTasks = computed(() => { if (filter.value === 'active') return tasks.value.filter((t) => !t.completed); if (filter.value === 'completed') return tasks.value.filter((t) => t.completed); return tasks.value;});</script>
<template> <div class="todo-app"> <h1>My Tasks</h1>
<form class="todo-form" @submit.prevent="handleSubmit"> <input v-model="newTaskText" type="text" placeholder="What do you need to do?" autocomplete="off" /> <button type="submit">Add</button> </form>
<FilterBar :filter="filter" @update:filter="filter = $event" />
<p v-if="filteredTasks.length === 0" class="empty-message">No tasks to show.</p>
<ul class="todo-list"> <TodoItem v-for="task in filteredTasks" :key="task.id" :task="task" @toggle="toggleTask" @delete="deleteTask" /> </ul> </div></template>Sample Run
Click Run to see what this code prints.
Extend This Project
- Add an "editing" ref to
TodoItemso double-clicking a task turns its text into an<input>, saved on blur or Enter. - Add a "Clear Completed" button in
Appthat filters every completed task out oftasks.valuein one click. - Add a due date with
<input type="date">in the form, stored as another field on each task object. - Show a live count of remaining active tasks next to the filter bar, computed the same derived way as
filteredTasks. - Extract the localStorage load/save logic into a reusable
useLocalStoragecomposable so other projects can share it.
Summary
You built a complete, persistent Todo App using ref for state, props and emitted events to pass data and behavior between components, and a watcher to synchronize that state with localStorage. The core pattern — state lives in one place, gets passed down as props, and changes flow back up through emitted events — is the foundation for every Vue project you build next, including the other three in this course.