Table of contents [Show]
- Why build a Todo app — what you'll learn
- Prerequisites & project setup
- Designing the app — features & data model
- App architecture & component breakdown
- Core utilities: ID & localStorage helpers
- Building the main TodoApp (state + persistence)
- Input component: add new todos & inline edit
- Todo list & item components (rendering & inline editing)
- Footer: filters, counts, clear completed, export/import UI
- Persisting & migration tips
- Optional: Reordering (drag & drop) — simple up/down controls
- Edge cases, testing & debugging tips
- Accessibility checklist (important)
- Performance & UX best practices
- Packaging & distribution tips
- Full minimal entry points (main.js & App.vue)
- Wrapping up — what you built and next steps
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
localStorageand 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 shellcomponents/TodoApp.vue— main container (stateful)components/TodoInput.vue— add / edit inputcomponents/TodoList.vue— renders list, usesTodoItem.vuecomponents/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.
The footer displays counts and filter controls; it also exposes export/import buttons so users can back up or restore todos.
<!-- 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 && 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
storageevent 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-labelor 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-motionfor 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:
- Build with
npm run buildvia Vite and deploy to static hosting (Netlify, Vercel, GitHub Pages). - Version your localStorage key (
vue_todo_v1) so you can migrate safely later. - 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>
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:
- Add unit tests (Vue Test Utils) for components and storage logic.
- Integrate Pinia for global state in larger apps.
- Sync to an API and add auth to persist across devices.
- Add drag & drop reorder using
Vue Draggable.






