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 `watch`er keeps every change saved to `localStorage` so the list survives a page refresh.
- A `tasks` ref holding an array in `App`, updated directly — Vue's reactivity tracks in-place mutations, unlike React.
- A `TodoItem` component that receives a task as a prop and emits `toggle`/`delete` events instead of changing state itself.
- A form using `v-model` to 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 `FilterBar` component for switching between All, Active, and Completed views.
- Automatic saving to and loading from `localStorage` with a `watch`er, so tasks persist across reloads.
Prerequisites
- Vue template syntax basics — `v-for`, `v-if`, `:` (attribute binding), and `@` (event binding).
- Single File Components (`.vue` files) — 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`, and `watch` are 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 `TodoItem` so double-clicking a task turns its text into an `<input>`, saved on blur or Enter.
- Add a "Clear Completed" button in `App` that filters every completed task out of `tasks.value` in 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 `useLocalStorage` composable 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 `watch`er 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.