Todo Store Example
This example demonstrates more advanced Pinia concepts through a feature-rich todo list application. It covers complex state management, nested objects, filtering, persistence, and real-world patterns.
Overview
The todo example showcases:
- Complex state structures with nested objects
- Advanced filtering and sorting
- Local storage persistence
- Optimistic updates
- Error handling
- Cross-store communication
- Performance optimizations
Features
- ✅ Create, read, update, delete todos
- ✅ Mark todos as complete/incomplete
- ✅ Filter by status (all, active, completed)
- ✅ Search functionality
- ✅ Categories and tags
- ✅ Due dates and priorities
- ✅ Bulk operations
- ✅ Local storage persistence
- ✅ Undo/redo functionality
- ✅ Statistics and analytics
Types Definition
ts
// types/todo.ts
export interface Todo {
id: string
title: string
description?: string
completed: boolean
createdAt: Date
updatedAt: Date
dueDate?: Date
priority: 'low' | 'medium' | 'high'
category: string
tags: string[]
}
export interface TodoFilter {
status: 'all' | 'active' | 'completed'
category?: string
priority?: Todo['priority']
search?: string
tags?: string[]
}
export interface TodoStats {
total: number
completed: number
active: number
overdue: number
byCategory: Record<string, number>
byPriority: Record<Todo['priority'], number>
}
Store Definition
ts
// stores/todos.ts
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import type { Todo, TodoFilter, TodoStats } from '../types/todo'
export const useTodosStore = defineStore('todos', () => {
// State
const todos = ref<Todo[]>([])
const filter = ref<TodoFilter>({ status: 'all' })
const loading = ref(false)
const error = ref<string | null>(null)
const categories = ref<string[]>(['Personal', 'Work', 'Shopping', 'Health'])
const history = ref<Todo[][]>([])
const historyIndex = ref(-1)
// Getters
const filteredTodos = computed(() => {
let result = todos.value
// Filter by status
if (filter.value.status === 'active') {
result = result.filter(todo => !todo.completed)
} else if (filter.value.status === 'completed') {
result = result.filter(todo => todo.completed)
}
// Filter by category
if (filter.value.category) {
result = result.filter(todo => todo.category === filter.value.category)
}
// Filter by priority
if (filter.value.priority) {
result = result.filter(todo => todo.priority === filter.value.priority)
}
// Filter by search
if (filter.value.search) {
const search = filter.value.search.toLowerCase()
result = result.filter(todo =>
todo.title.toLowerCase().includes(search) ||
todo.description?.toLowerCase().includes(search)
)
}
// Filter by tags
if (filter.value.tags?.length) {
result = result.filter(todo =>
filter.value.tags!.some(tag => todo.tags.includes(tag))
)
}
return result.sort((a, b) => {
// Sort by priority first
const priorityOrder = { high: 3, medium: 2, low: 1 }
const priorityDiff = priorityOrder[b.priority] - priorityOrder[a.priority]
if (priorityDiff !== 0) return priorityDiff
// Then by due date
if (a.dueDate && b.dueDate) {
return new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime()
}
if (a.dueDate) return -1
if (b.dueDate) return 1
// Finally by creation date
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
})
})
const activeTodos = computed(() => todos.value.filter(todo => !todo.completed))
const completedTodos = computed(() => todos.value.filter(todo => todo.completed))
const overdueTodos = computed(() => {
const now = new Date()
return todos.value.filter(todo =>
!todo.completed &&
todo.dueDate &&
new Date(todo.dueDate) < now
)
})
const stats = computed<TodoStats>(() => {
const byCategory: Record<string, number> = {}
const byPriority: Record<Todo['priority'], number> = {
low: 0,
medium: 0,
high: 0
}
todos.value.forEach(todo => {
byCategory[todo.category] = (byCategory[todo.category] || 0) + 1
byPriority[todo.priority]++
})
return {
total: todos.value.length,
completed: completedTodos.value.length,
active: activeTodos.value.length,
overdue: overdueTodos.value.length,
byCategory,
byPriority
}
})
const allTags = computed(() => {
const tags = new Set<string>()
todos.value.forEach(todo => {
todo.tags.forEach(tag => tags.add(tag))
})
return Array.from(tags).sort()
})
const canUndo = computed(() => historyIndex.value > 0)
const canRedo = computed(() => historyIndex.value < history.value.length - 1)
// Actions
const addTodo = (todoData: Omit<Todo, 'id' | 'createdAt' | 'updatedAt'>) => {
const todo: Todo = {
id: generateId(),
createdAt: new Date(),
updatedAt: new Date(),
...todoData
}
todos.value.push(todo)
saveToHistory()
persistTodos()
return todo
}
const updateTodo = (id: string, updates: Partial<Omit<Todo, 'id' | 'createdAt'>>) => {
const index = todos.value.findIndex(todo => todo.id === id)
if (index === -1) return false
todos.value[index] = {
...todos.value[index],
...updates,
updatedAt: new Date()
}
saveToHistory()
persistTodos()
return true
}
const deleteTodo = (id: string) => {
const index = todos.value.findIndex(todo => todo.id === id)
if (index === -1) return false
todos.value.splice(index, 1)
saveToHistory()
persistTodos()
return true
}
const toggleTodo = (id: string) => {
return updateTodo(id, {
completed: !todos.value.find(todo => todo.id === id)?.completed
})
}
const toggleAll = (completed: boolean) => {
todos.value.forEach(todo => {
todo.completed = completed
todo.updatedAt = new Date()
})
saveToHistory()
persistTodos()
}
const clearCompleted = () => {
todos.value = todos.value.filter(todo => !todo.completed)
saveToHistory()
persistTodos()
}
const bulkUpdate = (ids: string[], updates: Partial<Omit<Todo, 'id' | 'createdAt'>>) => {
ids.forEach(id => {
const index = todos.value.findIndex(todo => todo.id === id)
if (index !== -1) {
todos.value[index] = {
...todos.value[index],
...updates,
updatedAt: new Date()
}
}
})
saveToHistory()
persistTodos()
}
const setFilter = (newFilter: Partial<TodoFilter>) => {
filter.value = { ...filter.value, ...newFilter }
}
const clearFilter = () => {
filter.value = { status: 'all' }
}
const addCategory = (category: string) => {
if (!categories.value.includes(category)) {
categories.value.push(category)
persistCategories()
}
}
const removeCategory = (category: string) => {
const index = categories.value.indexOf(category)
if (index !== -1) {
categories.value.splice(index, 1)
// Update todos that use this category
todos.value.forEach(todo => {
if (todo.category === category) {
todo.category = 'Personal' // Default category
todo.updatedAt = new Date()
}
})
persistCategories()
persistTodos()
}
}
const saveToHistory = () => {
// Remove any future history if we're not at the end
if (historyIndex.value < history.value.length - 1) {
history.value = history.value.slice(0, historyIndex.value + 1)
}
// Add current state to history
history.value.push(JSON.parse(JSON.stringify(todos.value)))
historyIndex.value = history.value.length - 1
// Limit history size
if (history.value.length > 50) {
history.value.shift()
historyIndex.value--
}
}
const undo = () => {
if (canUndo.value) {
historyIndex.value--
todos.value = JSON.parse(JSON.stringify(history.value[historyIndex.value]))
persistTodos()
}
}
const redo = () => {
if (canRedo.value) {
historyIndex.value++
todos.value = JSON.parse(JSON.stringify(history.value[historyIndex.value]))
persistTodos()
}
}
const loadTodos = () => {
try {
const saved = localStorage.getItem('todos')
if (saved) {
const parsed = JSON.parse(saved)
todos.value = parsed.map((todo: any) => ({
...todo,
createdAt: new Date(todo.createdAt),
updatedAt: new Date(todo.updatedAt),
dueDate: todo.dueDate ? new Date(todo.dueDate) : undefined
}))
saveToHistory()
}
} catch (error) {
console.error('Failed to load todos:', error)
}
}
const loadCategories = () => {
try {
const saved = localStorage.getItem('todo-categories')
if (saved) {
categories.value = JSON.parse(saved)
}
} catch (error) {
console.error('Failed to load categories:', error)
}
}
const persistTodos = () => {
try {
localStorage.setItem('todos', JSON.stringify(todos.value))
} catch (error) {
console.error('Failed to persist todos:', error)
}
}
const persistCategories = () => {
try {
localStorage.setItem('todo-categories', JSON.stringify(categories.value))
} catch (error) {
console.error('Failed to persist categories:', error)
}
}
const generateId = () => {
return Date.now().toString(36) + Math.random().toString(36).substr(2)
}
// Initialize
loadTodos()
loadCategories()
return {
// State
todos: readonly(todos),
filter: readonly(filter),
loading: readonly(loading),
error: readonly(error),
categories: readonly(categories),
// Getters
filteredTodos,
activeTodos,
completedTodos,
overdueTodos,
stats,
allTags,
canUndo,
canRedo,
// Actions
addTodo,
updateTodo,
deleteTodo,
toggleTodo,
toggleAll,
clearCompleted,
bulkUpdate,
setFilter,
clearFilter,
addCategory,
removeCategory,
undo,
redo
}
})
Component Usage
Todo List Component
vue
<!-- components/TodoList.vue -->
<template>
<div class="todo-app">
<header class="todo-header">
<h1>Todo List</h1>
<div class="stats">
<span>{{ store.stats.active }} active</span>
<span>{{ store.stats.completed }} completed</span>
<span v-if="store.stats.overdue" class="overdue">
{{ store.stats.overdue }} overdue
</span>
</div>
</header>
<TodoForm @add="handleAdd" />
<TodoFilters
:filter="store.filter"
:categories="store.categories"
:tags="store.allTags"
@update="store.setFilter"
@clear="store.clearFilter"
/>
<div class="todo-actions">
<button
@click="store.toggleAll(true)"
:disabled="store.activeTodos.length === 0"
>
Mark All Complete
</button>
<button
@click="store.clearCompleted()"
:disabled="store.completedTodos.length === 0"
>
Clear Completed
</button>
<button @click="store.undo" :disabled="!store.canUndo">
Undo
</button>
<button @click="store.redo" :disabled="!store.canRedo">
Redo
</button>
</div>
<div class="todo-list">
<TodoItem
v-for="todo in store.filteredTodos"
:key="todo.id"
:todo="todo"
@update="handleUpdate"
@delete="store.deleteTodo"
@toggle="store.toggleTodo"
/>
<div v-if="store.filteredTodos.length === 0" class="empty-state">
<p v-if="store.todos.length === 0">
No todos yet. Add one above!
</p>
<p v-else>
No todos match the current filter.
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useTodosStore } from '../stores/todos'
import type { Todo } from '../types/todo'
import TodoForm from './TodoForm.vue'
import TodoFilters from './TodoFilters.vue'
import TodoItem from './TodoItem.vue'
const store = useTodosStore()
const handleAdd = (todoData: Omit<Todo, 'id' | 'createdAt' | 'updatedAt'>) => {
store.addTodo(todoData)
}
const handleUpdate = (id: string, updates: Partial<Todo>) => {
store.updateTodo(id, updates)
}
</script>
Todo Form Component
vue
<!-- components/TodoForm.vue -->
<template>
<form @submit.prevent="handleSubmit" class="todo-form">
<div class="form-row">
<input
v-model="form.title"
placeholder="What needs to be done?"
required
class="title-input"
>
<select v-model="form.priority" class="priority-select">
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
<div class="form-row">
<textarea
v-model="form.description"
placeholder="Description (optional)"
class="description-input"
></textarea>
</div>
<div class="form-row">
<select v-model="form.category" class="category-select">
<option v-for="category in store.categories" :key="category" :value="category">
{{ category }}
</option>
</select>
<input
v-model="form.dueDate"
type="datetime-local"
class="date-input"
>
</div>
<div class="form-row">
<input
v-model="tagInput"
@keydown.enter.prevent="addTag"
@keydown.comma.prevent="addTag"
placeholder="Add tags (press Enter or comma)"
class="tag-input"
>
<div class="tags">
<span
v-for="tag in form.tags"
:key="tag"
class="tag"
@click="removeTag(tag)"
>
{{ tag }} ×
</span>
</div>
</div>
<button type="submit" class="submit-btn">
Add Todo
</button>
</form>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useTodosStore } from '../stores/todos'
import type { Todo } from '../types/todo'
const emit = defineEmits<{
add: [todo: Omit<Todo, 'id' | 'createdAt' | 'updatedAt'>]
}>()
const store = useTodosStore()
const form = reactive({
title: '',
description: '',
priority: 'medium' as Todo['priority'],
category: store.categories[0],
dueDate: '',
tags: [] as string[]
})
const tagInput = ref('')
const handleSubmit = () => {
const todoData = {
title: form.title.trim(),
description: form.description.trim() || undefined,
completed: false,
priority: form.priority,
category: form.category,
dueDate: form.dueDate ? new Date(form.dueDate) : undefined,
tags: [...form.tags]
}
emit('add', todoData)
// Reset form
form.title = ''
form.description = ''
form.priority = 'medium'
form.category = store.categories[0]
form.dueDate = ''
form.tags = []
}
const addTag = () => {
const tag = tagInput.value.trim().replace(',', '')
if (tag && !form.tags.includes(tag)) {
form.tags.push(tag)
tagInput.value = ''
}
}
const removeTag = (tag: string) => {
const index = form.tags.indexOf(tag)
if (index !== -1) {
form.tags.splice(index, 1)
}
}
</script>
Testing
ts
// tests/todos.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useTodosStore } from '../stores/todos'
describe('Todos Store', () => {
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
})
it('adds a new todo', () => {
const store = useTodosStore()
const todo = store.addTodo({
title: 'Test todo',
completed: false,
priority: 'medium',
category: 'Personal',
tags: []
})
expect(store.todos).toHaveLength(1)
expect(store.todos[0]).toMatchObject({
title: 'Test todo',
completed: false,
priority: 'medium'
})
expect(todo.id).toBeDefined()
})
it('toggles todo completion', () => {
const store = useTodosStore()
const todo = store.addTodo({
title: 'Test todo',
completed: false,
priority: 'medium',
category: 'Personal',
tags: []
})
store.toggleTodo(todo.id)
expect(store.todos[0].completed).toBe(true)
store.toggleTodo(todo.id)
expect(store.todos[0].completed).toBe(false)
})
it('filters todos correctly', () => {
const store = useTodosStore()
store.addTodo({
title: 'Active todo',
completed: false,
priority: 'high',
category: 'Work',
tags: ['urgent']
})
store.addTodo({
title: 'Completed todo',
completed: true,
priority: 'low',
category: 'Personal',
tags: ['done']
})
// Test status filter
store.setFilter({ status: 'active' })
expect(store.filteredTodos).toHaveLength(1)
expect(store.filteredTodos[0].title).toBe('Active todo')
store.setFilter({ status: 'completed' })
expect(store.filteredTodos).toHaveLength(1)
expect(store.filteredTodos[0].title).toBe('Completed todo')
// Test category filter
store.setFilter({ status: 'all', category: 'Work' })
expect(store.filteredTodos).toHaveLength(1)
expect(store.filteredTodos[0].category).toBe('Work')
})
it('calculates stats correctly', () => {
const store = useTodosStore()
store.addTodo({
title: 'Todo 1',
completed: false,
priority: 'high',
category: 'Work',
tags: []
})
store.addTodo({
title: 'Todo 2',
completed: true,
priority: 'medium',
category: 'Personal',
tags: []
})
expect(store.stats.total).toBe(2)
expect(store.stats.active).toBe(1)
expect(store.stats.completed).toBe(1)
expect(store.stats.byCategory.Work).toBe(1)
expect(store.stats.byCategory.Personal).toBe(1)
expect(store.stats.byPriority.high).toBe(1)
expect(store.stats.byPriority.medium).toBe(1)
})
it('supports undo/redo', () => {
const store = useTodosStore()
// Add a todo
const todo = store.addTodo({
title: 'Test todo',
completed: false,
priority: 'medium',
category: 'Personal',
tags: []
})
expect(store.todos).toHaveLength(1)
expect(store.canUndo).toBe(true)
// Undo
store.undo()
expect(store.todos).toHaveLength(0)
expect(store.canRedo).toBe(true)
// Redo
store.redo()
expect(store.todos).toHaveLength(1)
})
})
Key Concepts
1. Complex State Management
Managing nested objects and arrays with proper reactivity and immutability.
2. Advanced Filtering
Implementing multiple filter criteria with computed properties for optimal performance.
3. Persistence
Saving and loading state from localStorage with proper error handling.
4. History Management
Implementing undo/redo functionality with state snapshots.
5. Performance Optimization
Using computed properties and readonly state to minimize unnecessary re-renders.
Best Practices
- Normalize complex state for easier manipulation
- Use computed properties for derived data
- Implement proper error handling for persistence operations
- Keep actions focused on single responsibilities
- Use TypeScript for better type safety
- Test complex logic thoroughly
- Optimize for performance with readonly state and computed properties