• Fri, Mar 2026

Build a Todo App in Vue.js with Local Storage — Step-by-Step

Build a Todo App in Vue.js with Local Storage — Step-by-Step

This tutorial teaches you how to build a robust, production-minded Todo application using Vue 3 (Composition API) with persistence via localStorage.You’ll learn reactive state, computed properties, watchers, component architecture, and how to persist, import/export, and synchronize todos with localStorage.

Why build a Todo app — what you'll learn

The Todo app is a classic exercise because it touches common UI problems: lists, CRUD (create, read, update, delete), form handling, filtering, sorting, persistence, and UX patterns like inline-editing and optimistic updates. In this project you'll practice:

  • Vue 3 Composition API fundamentals: ref, reactive, computed, watch
  • Component design and props/events
  • Persisting state to localStorage and handling migrations
  • Accessibility considerations (keyboard, ARIA)
  • Performance and UX best practices
  • Export / import and data synchronization patterns

Prerequisites & project setup

What you need

  • Node.js + npm (or yarn/pnpm)
  • Basic Vue 3 knowledge (components, template syntax)
  • Text editor (VSCode recommended)

Create project using Vite (recommended)

# create a Vue 3 project with Vite
npm create vite@latest vue-todo-localstorage -- --template vue
cd vue-todo-localstorage
npm install
npm run dev

We’ll use plain Vue (no router or state library required). Keep it small and focused.

Designing the app — features & data model

Core features

  • Add new todo
  • Edit todo title inline
  • Toggle complete/incomplete
  • Delete todo
  • Filter: All / Active / Completed
  • Clear completed
  • Persist todos to localStorage
  • Export / import todos (JSON)
  • Optional: reorder todos (simple up/down)

Data model

Each todo will be an object with these properties:

{
  id: '1634567890123',    // unique id (use Date.now() or nanoid)
  title: 'Buy groceries', // string
  completed: false,       // boolean
  createdAt: 1634567890123, // timestamp
  updatedAt: 1634567890123  // timestamp
}

All todos will be stored in an array: todos: Todo[]. We'll persist this array to localStorage under a key like vue_todo_v1.

App architecture & component breakdown

We’ll follow a simple component structure for clarity and reuse:

  • App.vue — root that contains the app shell
  • components/TodoApp.vue — main container (stateful)
  • components/TodoInput.vue — add / edit input
  • components/TodoList.vue — renders list, uses TodoItem.vue
  • components/TodoItem.vue — single todo row (toggle, edit, delete)
  • components/TodoFooter.vue — filters, counts, clear completed, export/import

Reason: keep state in TodoApp.vue and pass props/events down — this is simple and testable.

Core utilities: ID & localStorage helpers

Create a small helpers file for id generation and localStorage interactions. This keeps the main code cleaner and enables future changes (like migrating from localStorage to IndexedDB) in one place.

// src/utils/storage.js
export const TODO_KEY = 'vue_todo_v1'
// safe JSON parse
export function safeParse(str, fallback = null) {
  try {
    return JSON.parse(str)
  } catch (e) {
    return fallback
  }
}
export function loadTodos() {
  const raw = localStorage.getItem(TODO_KEY)
  const parsed = safeParse(raw, [])
  // validate fallback if parsed isn't an array
  return Array.isArray(parsed) ? parsed : []
}
export function saveTodos(todos) {
  try {
    localStorage.setItem(TODO_KEY, JSON.stringify(todos))
  } catch (e) {
    console.error('Failed to save todos', e)
  }
}
// id generator (small and predictable for demo)
export function makeId() {
  return Date.now().toString(36) + Math.random().toString(36).slice(2,8)
}

Tip: For large production apps consider libraries (nanoid) or server-generated ids.

Building the main TodoApp (state + persistence)

Here’s the main component that loads, stores, and manages todos. It demonstrates Composition API patterns, watchers for persistence, CRUD logic, filtering, and export/import.

Full TodoApp.vue (copy-paste)

<!-- src/components/TodoApp.vue -->
<template>
 <section >
  <h1>Vue Todo (localStorage)</h1>
  <TodoInput @add="addTodo" />
  <TodoList
   :todos="filteredTodos"
   @toggle="toggleTodo"
   @delete="deleteTodo"
   @edit="editTodo"
  />
  <TodoFooter
   :total="todos.length"
   :active="activeCount"
   :filter="filter"
   @change-filter="filter = $event"
   @clear-completed="clearCompleted"
   @export="exportTodos"
   @import="importTodos"
  />
 </section>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import TodoInput from './TodoInput.vue'
import TodoList from './TodoList.vue'
import TodoFooter from './TodoFooter.vue'
import { loadTodos, saveTodos, makeId } from '../utils/storage'
// reactive state
const todos = ref([])
// filtering: 'all' | 'active' | 'completed'
const filter = ref('all')
// load initial todos from localStorage on mount
onMounted(() => {
 todos.value = loadTodos()
})
// persist todos whenever they change (debounce optional)
watch(todos, (newTodos) => {
 saveTodos(newTodos)
}, { deep: true })
// derived counts
const activeCount = computed(() => todos.value.filter(t => !t.completed).length)
// filtered view
const filteredTodos = computed(() => {
 if (filter.value === 'active') return todos.value.filter(t => !t.completed)
 if (filter.value === 'completed') return todos.value.filter(t => t.completed)
 return todos.value
})
// actions
function addTodo(title) {
 if (!title || !title.trim()) return
 const now = Date.now()
 todos.value.unshift({
  id: makeId(),
  title: title.trim(),
  completed: false,
  createdAt: now,
  updatedAt: now
 })
}
function toggleTodo(id) {
 const t = todos.value.find(x => x.id === id)
 if (t) {
  t.completed = !t.completed
  t.updatedAt = Date.now()
 }
}
function deleteTodo(id) {
 todos.value = todos.value.filter(x => x.id !== id)
}
function editTodo(id, newTitle) {
 const t = todos.value.find(x => x.id === id)
 if (t) {
  t.title = newTitle.trim()
  t.updatedAt = Date.now()
 }
}
function clearCompleted() {
 todos.value = todos.value.filter(t => !t.completed)
}
// export (download JSON)
function exportTodos() {
 const data = JSON.stringify(todos.value, null, 2)
 const blob = new Blob([data], { type: 'application/json' })
 const url = URL.createObjectURL(blob)
 const a = document.createElement('a')
 a.href = url
 a.download = 'todos.json'
 a.click()
 URL.revokeObjectURL(url)
}
// import (accept parsed array)
async function importTodos(file) {
 if (!file) return
 try {
  const text = await file.text()
  const parsed = JSON.parse(text)
  if (!Array.isArray(parsed)) throw new Error('Invalid format')
  // basic sanitization: map only expected fields and ensure id uniqueness
  const mapped = parsed.map(p => ({
   id: p.id || makeId(),
   title: String(p.title || '').trim(),
   completed: !!p.completed,
   createdAt: p.createdAt || Date.now(),
   updatedAt: p.updatedAt || Date.now()
  })).filter(x => x.title)
  // merge: keep existing and append imported (you might choose different merge strategies)
  todos.value = [...mapped, ...todos.value]
 } catch (err) {
  alert('Import failed: ' + (err.message || err))
 }
}
</script>
<style scoped>
.todo-app { max-width:680px; margin:24px auto; background:#fff; padding:20px; border-radius:10px; box-shadow: 0 6px 20px rgba(12,15,20,0.08); }
h1 { margin:0 0 12px; font-size:20px; }
</style>

This component orchestrates everything. Note the simple import strategy for JSON; in production you might validate the schema more strictly.

Input component: add new todos & inline edit

The input component supports adding new todos and can be reused for inline editing (we’ll build a separate inline editor in TodoItem.vue).

TodoInput.vue

<!-- src/components/TodoInput.vue -->
<template>
 <form @submit.prevent="submit" >
  <input
   v-model="text"
   placeholder="What needs to be done?"
   aria-label="Add todo"
   ref="inputEl"
  />
  <button type="submit">Add</button>
 </form>
</template>
<script setup>
import { ref } from 'vue'
const emit = defineEmits(['add'])
const text = ref('')
const inputEl = ref(null)
function submit() {
 if (!text.value.trim()) return
 emit('add', text.value)
 text.value = ''
 // keep focus for quick entry
 inputEl.value && inputEl.value.focus()
}
</script>
<style scoped>
.todo-input { display:flex; gap:8px; margin-bottom:12px; }
.todo-input input { flex:1; padding:10px; border:1px solid #e6e9ee; border-radius:6px; }
.todo-input button { padding:8px 12px; background:#4f46e5; color:white; border:none; border-radius:6px; }
</style>

UX notes: After adding, we keep focus in the input so users can rapidly enter multiple items (power-user friendly).

Todo list & item components (rendering & inline editing)

TodoList.vue

<!-- src/components/TodoList.vue -->
<template>
 <ul >
  <li v-if="todos.length === 0" >No todos yet — add one above.</li>
  <li v-for="todo in todos" :key="todo.id">
   <TodoItem
    :todo="todo"
    @toggle="$emit('toggle', $event)"
    @delete="$emit('delete', $event)"
    @edit="$emit('edit', $event.id, $event.title)"
   />
  </li>
 </ul>
</template>
<script setup>
import TodoItem from './TodoItem.vue'
const props = defineProps({ todos: { type: Array, default: () => [] } })
</script>
<style scoped>
.todo-list { list-style:none; margin:0; padding:0; }
.empty { color:#6b7280; padding:12px 0; }
.todo-list li { margin-bottom:8px; }
</style>

TodoItem.vue (inline editing, toggle, delete)

<!-- src/components/TodoItem.vue -->
<template>
 <div >
  <input type="checkbox" :checked="todo.completed" @change="onToggle" aria-label="Toggle todo" />
  <div v-if="!editing" @dblclick="startEdit" tabindex="0" @keydown.enter="startEdit" role="button">
   <span :>{{ todo.title }}</span>
  </div>
  <form v-else @submit.prevent="saveEdit" >
   <input v-model="editText" ref="editInput" @blur="saveEdit" />
  </form>
  <button @click="onDelete" aria-label="Delete todo">🗑️</button>
 </div>
</template>
<script setup>
import { ref, watch, nextTick } from 'vue'
const props = defineProps({
 todo: { type: Object, required: true }
})
const emit = defineEmits(['toggle', 'delete', 'edit'])
const editing = ref(false)
const editText = ref('')
const editInput = ref(null)
function onToggle() {
 emit('toggle', props.todo.id)
}
function onDelete() {
 if (confirm('Delete this todo?')) emit('delete', props.todo.id)
}
function startEdit() {
 editing.value = true
 editText.value = props.todo.title
 nextTick(() => editInput.value && editInput.value.focus())
}
function saveEdit() {
 if (!editing.value) return
 const val = (editText.value || '').trim()
 if (!val) {
  // if empty, treat as delete to avoid blank todos
  emit('delete', props.todo.id)
 } else {
  emit('edit', { id: props.todo.id, title: val })
 }
 editing.value = false
}
</script>
<style scoped>
.todo-item { display:flex; align-items:center; gap:10px; padding:8px; background:#fbfcfe; border-radius:6px; border:1px solid #eef2ff; }
.todo-item .title { flex:1; cursor:text; }
.todo-item .title:focus { outline:2px solid #c7d2fe; border-radius:4px; }
.completed { text-decoration: line-through; color:#9ca3af; }
.edit-form input { width:100%; padding:6px; border-radius:4px; border:1px solid #e6e9ee; }
button { background:none; border:none; cursor:pointer; }
</style>

Accessibility: The title supports keyboard activation via Enter and double-click for mouse users. Editable input saves on blur and Enter, and empty edits delete the item to avoid blank entries.

Footer: filters, counts, clear completed, export/import UI

The footer displays counts and filter controls; it also exposes export/import buttons so users can back up or restore todos.

TodoFooter.vue

<!-- src/components/TodoFooter.vue -->
<template>
  <footer class1="todo-footer">
    <div class1="counts" >
      <span>{{ active }} active</span>
      <span>|</span>
      <span>{{ total }} total</span>
    </div>>
    <div class1="filters">
      <button :class1="{ active: filterLocal === 'all' }" @click="setFilter('all')">All</button>
      <button :class1="{ active: filterLocal === 'active' }" @click="setFilter('active')">Active</button>
      <button :class1="{ active: filterLocal === 'completed' }" @click="setFilter('completed')">Completed</button>
    </div>
    <div class1="actions">
      <button @click="emit('clear-completed')">Clear completed</button>
      <button @click="emit('export')">Export</button>
      <label class1="import">
        Import<input type="file" accept="application/json" @change="onImport"  />
      </label>
    </div>
  </footer>
</template>
<script setup>
import { ref, watch } from 'vue'
const props = defineProps({
  total: { type: Number, default: 0 },
  active: { type: Number, default: 0 },
  filter: { type: String, default: 'all' }
})
const emit = defineEmits(['change-filter','clear-completed','export','import'])
const filterLocal = ref(props.filter)
watch(() => props.filter, (v) => filterLocal.value = v)
function setFilter(f) {
  filterLocal.value = f
  emit('change-filter', f)
}
function onImport(e) {
  const file = e.target.files &amp;&amp; e.target.files[0]
  if (!file) return
  emit('import', file)
  // reset so same file can be re-selected if needed
  e.target.value = null
}
</script>
<style scoped>
.todo-footer { display:flex; justify-content:space-between; align-items:center; gap:12px; padding-top:12px; margin-top:12px; border-top:1px solid #f1f5f9; }
.filters button { margin-right:8px; padding:6px 8px; border-radius:6px; border:1px solid transparent; }
.filters button.active { background:#eef2ff; border-color:#c7d2fe; }
.actions button, .actions label { padding:6px 8px; border-radius:6px; background:#fff; border:1px solid #e6e9ee; cursor:pointer; }
.import input { display:none; }
</style>

UX choices: Using a hidden file input inside a label provides a simple custom Import button. We reset the input value so uploading the same file twice is allowed.

Persisting & migration tips

We saved data using a TODO_KEY. If you ship updates that change the model, handle migration:

// example migration (loadTodos in utils/storage.js)
export function loadTodos() {
  const raw = localStorage.getItem(TODO_KEY)
  const parsed = safeParse(raw, [])
  let arr = Array.isArray(parsed) ? parsed : []
  // migration example: older entries had `text` instead of `title`
  arr = arr.map(item => {
    if (item.text && !item.title) {
      return { ...item, title: item.text, updatedAt: item.updatedAt || Date.now() }
    }
    return item
  })
  return arr
}

Always be conservative — never assume the shape of persisted data. Validate and sanitize on load.

Optional: Reordering (drag & drop) — simple up/down controls

If you want reorder without drag & drop libraries, provide Up/Down buttons in TodoItem.vue and emit move events. The App component updates array order and persistence handles saving. For full DnD experience use libraries like SortableJS or Vue Draggable.

// simple move handler in TodoApp.vue
function move(id, direction) {
  const idx = todos.value.findIndex(t => t.id === id)
  if (idx === -1) return
  const newIdx = idx + (direction === 'up' ? -1 : 1)
  if (newIdx < 0 || newIdx >= todos.value.length) return
  const copy = [...todos.value]
  const [item] = copy.splice(idx,1)
  copy.splice(newIdx,0,item)
  todos.value = copy
}

Edge cases, testing & debugging tips

  • localStorage full / quota: Wrap writes in try/catch — if quota exceeded inform user or prevent big exports.
  • Invalid import format: Validate JSON and show friendly errors.
  • Cross-tab sync: Use the storage event to sync todos across tabs:
// in TodoApp onMounted
function onStorage(e) {
  if (e.key === TODO_KEY) {
    // optionally prompt user before overwrite
    todos.value = safeParse(e.newValue, [])
  }
}
window.addEventListener('storage', onStorage)
onBeforeUnmount(() => window.removeEventListener('storage', onStorage))

This makes the app resilient when multiple browser tabs are used.

Accessibility checklist (important)

  • Input fields have aria-label or visible label.
  • Editable titles are keyboard-accessible (Enter to edit)
  • Buttons have descriptive labels and icons have text fallbacks when necessary.
  • Modal/dialogs (if used) include role="dialog", ARIA labelling, focus management and Escape to close.
  • Respect prefers-reduced-motion for users who request reduced animations.

Performance & UX best practices

  • Debounce heavy operations (e.g., autosave or expensive computations).
  • Keep writes to localStorage minimal — batch or debounce if writing frequently.
  • Use key-based rendering for lists to enable efficient DOM updates.
  • Provide optimistic UI: update UI immediately on user action, persist in background.

Packaging & distribution tips

When your app is ready:

  1. Build with npm run build via Vite and deploy to static hosting (Netlify, Vercel, GitHub Pages).
  2. Version your localStorage key (vue_todo_v1) so you can migrate safely later.
  3. Consider adding optional sync to cloud (authenticated users) to persist across devices.

Full minimal entry points (main.js & App.vue)

If you want a compact starting point, here are the two main files to wire components together.

// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import './assets/global.css' // optional
createApp(App).mount('#app')
// src/App.vue
<template>
  <div id="app">
    <TodoApp />
  </div>
</template>
<script setup>
import TodoApp from './components/TodoApp.vue'
</script>

Wrapping up — what you built and next steps

Congratulations — you now have a feature-rich Todo app in Vue that:

  • Adds, edits, toggles, and deletes todos
  • Filters (All/Active/Completed)
  • Persists to localStorage with safe load/save
  • Supports export & import and cross-tab synchronization
  • Has accessibility and UX considerations

Next enhancements you might try:

  1. Add unit tests (Vue Test Utils) for components and storage logic.
  2. Integrate Pinia for global state in larger apps.
  3. Sync to an API and add auth to persist across devices.
  4. Add drag & drop reorder using Vue Draggable.
This website uses cookies to enhance your browsing experience. By continuing to use this site, you consent to the use of cookies. Please review our Privacy Policy for more information on how we handle your data. Cookie Policy