• Vue 3
  • Composition API
  • JavaScript
  • localStorage
  • CodePen

Building a TODO app with the Vue 3 Composition API and localStorage

A walkthrough of a Vue 3 TODO app built with ref, computed and onMounted: reactive task state, To-Do and Done tabs, UUID task IDs, confirmation flows and localStorage persistence.

Cristopher Paniagua By 5 min read
The Vue 3 TODO app showing To-Do and Done tabs with a list of tasks

What does the app do?

It's a small task manager built with Vue 3 and the Composition API. You can:

  • Create tasks.
  • Complete a task, which moves it from To-Do to Done.
  • Restore a completed task back to To-Do.
  • Delete a task.
  • Switch between To-Do and Done with a reactive tab interface.

Tasks are stored in the browser's localStorage, so they are still there after a reload. Destructive or status-changing actions show a confirmation first, so a misclick doesn't lose your work.

The live app on CodePen. Switch to the JS tab to read the full source.

How is the state modeled?

All the state hangs off one reactive array. Each task is a plain object with a unique id, a title and a done flag. The tab views are derived from that array with computed instead of being stored separately:

import { ref, computed } from 'vue'

const tasks = ref([])
const activeTab = ref('todo')

const todoTasks = computed(() => tasks.value.filter(task => !task.done))
const doneTasks = computed(() => tasks.value.filter(task => task.done))

const visibleTasks = computed(() =>
  activeTab.value === 'todo' ? todoTasks.value : doneTasks.value
)

Simplified for this article. See the CodePen for the exact implementation.

Keeping one source of truth and computing the views from it means the lists can't drift out of sync. Completing a task only flips one flag, and both tabs update automatically.

Why give each task a UUID?

Using the array index as an identifier breaks as soon as items are deleted or reordered. Giving each task a UUID when it is created provides:

  • a stable :key for v-for, so Vue updates the DOM correctly,
  • a reliable way to find the task to complete, restore or delete.
const addTask = (title) => {
  tasks.value.push({ id: uuid(), title, done: false })
}

const toggleTask = (id) => {
  const task = tasks.value.find(task => task.id === id)
  if (task) task.done = !task.done
}

const removeTask = (id) => {
  tasks.value = tasks.value.filter(task => task.id !== id)
}

How does persistence with localStorage work?

localStorage only stores strings, so the task list is serialized with JSON.stringify when it is saved and parsed back with JSON.parse when it is loaded. Loading happens in onMounted, once the app is running in the browser:

import { onMounted } from 'vue'

const STORAGE_KEY = 'tasks'

const saveTasks = () => {
  localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks.value))
}

onMounted(() => {
  const saved = localStorage.getItem(STORAGE_KEY)
  if (saved) tasks.value = JSON.parse(saved)
})

Saving after every change keeps localStorage and the UI in sync, with no backend at all.

Why confirm destructive actions?

Deleting a task or changing its status are the two actions a user is most likely to regret. Asking for confirmation before running them is cheap and saves frustration, and it's a habit worth keeping in larger apps.

What frontend concepts does it demonstrate?

Small as it is, the project covers concepts that come up in any production Vue app:

  • Reactive state with ref.
  • Derived state with computed, using one source of truth.
  • Lifecycle hooks (onMounted) for browser-only work.
  • Client-side persistence with the Web Storage API.
  • Stable identity for list items using UUIDs.
  • Event-driven UI with confirmation flows.

Frequently asked questions

Which Vue 3 APIs does the TODO app use?

It uses ref for the reactive task list, computed for the derived To-Do and Done lists, and onMounted to load saved tasks from localStorage when the app starts.

How does the app keep tasks after a page reload?

Every change to the task list is serialized with JSON.stringify and written to localStorage. On mount, the app reads that key back with JSON.parse and restores the tasks.

Why use UUIDs for the tasks?

A UUID gives each task a stable, unique identifier that does not depend on its position in the array. That makes completing, restoring and deleting tasks reliable, and it gives Vue a correct key for v-for.

Can I try the app?

Yes. The full source code and a live, editable version are on CodePen, embedded in this post.

Cristopher Paniagua

Written by

Cristopher Paniagua

Senior Software Engineer specializing in frontend development with Vue, Nuxt, React and TypeScript. Author of open source packages like vue3-autocounter and speaker at Vue.js meetups.

Reading this with an AI tool? Use the markdown version of this article or the site's llms.txt.

logo © 2026 Cristopher PS