LearnAI ToolsCareerPractice BuildsPlayContact
Vue.jsBeginner~1.5 hours

Todo App

Build a task manager with filtering and reactive state using the Composition API.

ref/reactiveComputed Propertiesv-model

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.

What You'll Build
  • 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 copy
const 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 submitted
const 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 selected
const 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 truth
defineProps({ 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)">
&times;
</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>
What Happens in the tasks Ref

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 buttons
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 } 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 state
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; // 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 all
onMounted(() => {
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 outright
watch(
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)">&times;</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>
Live Preview

Sample Run

Sample Interaction

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.