
Building a Modern Todo App with Vue 3: A Complete Developer Guide
Vue 3 has revolutionized the way we build interactive web applications, and creating a todo app is the perfect way to explore its powerful features. This comprehensive guide will walk you through building a modern, feature-rich todo application using Vue 3's Composition API, TypeScript, and best practices.
1. Why Vue 3 for Todo Applications?
Vue 3 brings significant improvements over Vue 2, making it ideal for building todo applications. The Composition API provides better TypeScript support, improved performance, and more flexible code organization. For todo apps, this means easier state management, better reactivity, and more maintainable code.
1.1 Key Vue 3 Features for Todo Apps
Vue 3 offers several features that make todo development more efficient:
- Composition API: Better logic reuse and organization
- Improved TypeScript Support: Enhanced type safety and developer experience
- Better Performance: Faster rendering and smaller bundle sizes
- Teleport: Better modal and overlay management
- Fragments: Multiple root elements without wrapper divs
2. Project Setup and Structure
Setting up a Vue 3 todo project requires careful planning of the project structure. A well-organized project makes development more efficient and maintenance easier.
2.1 Recommended Project Structure
vue3-todo/
├── src/
│ ├── components/
│ │ ├── TodoList.vue
│ │ ├── TodoItem.vue
│ │ ├── TodoForm.vue
│ │ └── TodoFilter.vue
│ ├── composables/
│ │ ├── useTodos.ts
│ │ └── useLocalStorage.ts
│ ├── types/
│ │ └── todo.ts
│ ├── utils/
│ │ └── helpers.ts
│ ├── App.vue
│ └── main.ts
├── public/
├── package.json
└── README.md
3. Core Todo Types and Interfaces
Defining proper TypeScript interfaces is crucial for type safety and better development experience. Here's how to structure your todo types:
// types/todo.ts
export interface Todo {
id: string;
title: string;
completed: boolean;
createdAt: Date;
updatedAt: Date;
priority: 'low' | 'medium' | 'high';
category?: string;
dueDate?: Date;
}
export interface TodoFilters {
status: 'all' | 'active' | 'completed';
priority: 'all' | 'low' | 'medium' | 'high';
category: string;
search: string;
}
export interface TodoStats {
total: number;
completed: number;
active: number;
completionRate: number;
}
4. Building the Todo Composable
The heart of any Vue 3 application is its composables. For a todo app, we need a robust composable that handles all todo-related logic:
// composables/useTodos.ts
import { ref, computed } from 'vue';
import type { Todo, TodoFilters, TodoStats } from '@/types/todo';
import { useLocalStorage } from './useLocalStorage';
export function useTodos() {
const todos = ref([]);
const filters = ref({
status: 'all',
priority: 'all',
category: '',
search: ''
});
const { saveToStorage, loadFromStorage } = useLocalStorage();
// Load todos from localStorage on initialization
const initializeTodos = () => {
const savedTodos = loadFromStorage('todos');
if (savedTodos) {
todos.value = savedTodos.map((todo: any) => ({
...todo,
createdAt: new Date(todo.createdAt),
updatedAt: new Date(todo.updatedAt),
dueDate: todo.dueDate ? new Date(todo.dueDate) : undefined
}));
}
};
// Add new todo
const addTodo = (title: string, priority: Todo['priority'] = 'medium', category?: string, dueDate?: Date) => {
const newTodo: Todo = {
id: generateId(),
title: title.trim(),
completed: false,
createdAt: new Date(),
updatedAt: new Date(),
priority,
category,
dueDate
};
todos.value.unshift(newTodo);
saveToStorage('todos', todos.value);
};
// Toggle todo completion
const toggleTodo = (id: string) => {
const todo = todos.value.find(t => t.id === id);
if (todo) {
todo.completed = !todo.completed;
todo.updatedAt = new Date();
saveToStorage('todos', todos.value);
}
};
// Update todo
const updateTodo = (id: string, updates: Partial) => {
const todo = todos.value.find(t => t.id === id);
if (todo) {
Object.assign(todo, { ...updates, updatedAt: new Date() });
saveToStorage('todos', todos.value);
}
};
// Delete todo
const deleteTodo = (id: string) => {
todos.value = todos.value.filter(t => t.id !== id);
saveToStorage('todos', todos.value);
};
// Filtered todos
const filteredTodos = computed(() => {
return todos.value.filter(todo => {
const matchesStatus = filters.value.status === 'all' ||
(filters.value.status === 'active' && !todo.completed) ||
(filters.value.status === 'completed' && todo.completed);
const matchesPriority = filters.value.priority === 'all' ||
todo.priority === filters.value.priority;
const matchesCategory = !filters.value.category ||
todo.category === filters.value.category;
const matchesSearch = !filters.value.search ||
todo.title.toLowerCase().includes(filters.value.search.toLowerCase());
return matchesStatus && matchesPriority && matchesCategory && matchesSearch;
});
});
// Todo statistics
const stats = computed(() => {
const total = todos.value.length;
const completed = todos.value.filter(t => t.completed).length;
const active = total - completed;
const completionRate = total > 0 ? (completed / total) * 100 : 0;
return { total, completed, active, completionRate };
});
// Utility function
const generateId = () => {
return Date.now().toString(36) + Math.random().toString(36).substr(2);
};
return {
todos,
filters,
filteredTodos,
stats,
addTodo,
toggleTodo,
updateTodo,
deleteTodo,
initializeTodos
};
}
5. Local Storage Composable
Persistence is crucial for todo apps. Here's a reusable composable for handling localStorage:
// composables/useLocalStorage.ts
export function useLocalStorage() {
const saveToStorage = (key: string, data: any) => {
try {
localStorage.setItem(key, JSON.stringify(data));
} catch (error) {
console.error('Error saving to localStorage:', error);
}
};
const loadFromStorage = (key: string) => {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : null;
} catch (error) {
console.error('Error loading from localStorage:', error);
return null;
}
};
const removeFromStorage = (key: string) => {
try {
localStorage.removeItem(key);
} catch (error) {
console.error('Error removing from localStorage:', error);
}
};
return {
saveToStorage,
loadFromStorage,
removeFromStorage
};
}
6. Building the Todo Components
Now let's create the core components that make up our todo application:
6.1 TodoForm Component
<template>
<form @submit.prevent="handleSubmit" class="todo-form">
<div class="form-group">
<input
v-model="newTodo"
type="text"
placeholder="What needs to be done?"
class="todo-input"
:class="{ 'error': showError }"
@input="clearError"
/>
<span v-if="showError" class="error-message">{{ errorMessage }}</span>
</div>
<div class="form-options">
<select v-model="priority" class="priority-select">
<option value="low">Low Priority</option>
<option value="medium">Medium Priority</option>
<option value="high">High Priority</option>
</select>
<input
v-model="category"
type="text"
placeholder="Category (optional)"
class="category-input"
/>
<input
v-model="dueDate"
type="date"
class="due-date-input"
/>
</div>
<button type="submit" class="add-button">
Add Todo
</button>
</form>
</template>
<script setup lang="ts">
import { ref } from 'vue';
const emit = defineEmits<{
add: [title: string, priority: string, category?: string, dueDate?: Date]
}>();
const newTodo = ref('');
const priority = ref('medium');
const category = ref('');
const dueDate = ref('');
const showError = ref(false);
const errorMessage = ref('');
const handleSubmit = () => {
if (!newTodo.value.trim()) {
showError.value = true;
errorMessage.value = 'Please enter a todo title';
return;
}
emit('add', newTodo.value, priority.value, category.value || undefined, dueDate.value ? new Date(dueDate.value) : undefined);
// Reset form
newTodo.value = '';
priority.value = 'medium';
category.value = '';
dueDate.value = '';
showError.value = false;
};
const clearError = () => {
if (showError.value) {
showError.value = false;
errorMessage.value = '';
}
};
</script>
6.2 TodoItem Component
<template>
<div class="todo-item" :class="{ 'completed': todo.completed, 'priority-high': todo.priority === 'high' }">
<div class="todo-content">
<input
type="checkbox"
:checked="todo.completed"
@change="toggleTodo"
class="todo-checkbox"
/>
<div class="todo-details">
<h3 class="todo-title" :class="{ 'completed': todo.completed }">
{{ todo.title }}
</h3>
<div class="todo-meta">
<span class="priority-badge" :class="'priority-' + todo.priority">
{{ todo.priority }}
</span>
<span v-if="todo.category" class="category-badge">
{{ todo.category }}
</span>
<span v-if="todo.dueDate" class="due-date" :class="{ 'overdue': isOverdue }">
Due: {{ formatDate(todo.dueDate) }}
</span>
</div>
</div>
</div>
<div class="todo-actions">
<button @click="editTodo" class="edit-button">
Edit
</button>
<button @click="deleteTodo" class="delete-button">
Delete
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import type { Todo } from '@/types/todo';
const props = defineProps<{
todo: Todo
}>();
const emit = defineEmits<{
toggle: [id: string]
edit: [todo: Todo]
delete: [id: string]
}>();
const isOverdue = computed(() => {
if (!props.todo.dueDate) return false;
return new Date() > props.todo.dueDate && !props.todo.completed;
});
const toggleTodo = () => {
emit('toggle', props.todo.id);
};
const editTodo = () => {
emit('edit', props.todo);
};
const deleteTodo = () => {
emit('delete', props.todo.id);
};
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
}).format(date);
};
</script>
7. Advanced Features and Enhancements
To make your todo app stand out, consider implementing these advanced features:
7.1 Drag and Drop Reordering
Implement drag and drop functionality to allow users to reorder their todos:
// Install: npm install vuedraggable
import draggable from 'vuedraggable';
// In your TodoList component
<draggable
v-model="todos"
@end="onDragEnd"
item-key="id"
class="todo-list"
>
<template #item="{ element }">
<TodoItem
:todo="element"
@toggle="toggleTodo"
@edit="editTodo"
@delete="deleteTodo"
/>
</template>
</draggable>
7.2 Keyboard Shortcuts
Add keyboard shortcuts for better user experience:
// composables/useKeyboardShortcuts.ts
import { onMounted, onUnmounted } from 'vue';
export function useKeyboardShortcuts(shortcuts: Record void>) {
const handleKeydown = (event: KeyboardEvent) => {
const key = event.key.toLowerCase();
const ctrl = event.ctrlKey || event.metaKey;
if (ctrl && shortcuts[`ctrl+${key}`]) {
event.preventDefault();
shortcuts[`ctrl+${key}`]();
} else if (shortcuts[key]) {
event.preventDefault();
shortcuts[key]();
}
};
onMounted(() => {
document.addEventListener('keydown', handleKeydown);
});
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown);
});
}
7.3 Dark Mode Support
Implement dark mode using CSS custom properties and Vue's reactivity:
// composables/useDarkMode.ts
import { ref, watch } from 'vue';
export function useDarkMode() {
const isDark = ref(localStorage.getItem('darkMode') === 'true');
const toggleDarkMode = () => {
isDark.value = !isDark.value;
localStorage.setItem('darkMode', isDark.value.toString());
};
watch(isDark, (newValue) => {
document.documentElement.classList.toggle('dark', newValue);
}, { immediate: true });
return {
isDark,
toggleDarkMode
};
}
8. Testing Your Todo App
Testing is crucial for maintaining code quality. Here's how to test your Vue 3 todo app:
8.1 Unit Testing with Vitest
// tests/useTodos.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { useTodos } from '@/composables/useTodos';
describe('useTodos', () => {
let todos;
beforeEach(() => {
todos = useTodos();
});
it('should add a new todo', () => {
todos.addTodo('Test todo');
expect(todos.todos.value).toHaveLength(1);
expect(todos.todos.value[0].title).toBe('Test todo');
});
it('should toggle todo completion', () => {
todos.addTodo('Test todo');
const todoId = todos.todos.value[0].id;
todos.toggleTodo(todoId);
expect(todos.todos.value[0].completed).toBe(true);
todos.toggleTodo(todoId);
expect(todos.todos.value[0].completed).toBe(false);
});
});
8.2 Component Testing
// tests/TodoItem.test.ts
import { mount } from '@vue/test-utils';
import { describe, it, expect } from 'vitest';
import TodoItem from '@/components/TodoItem.vue';
describe('TodoItem', () => {
const mockTodo = {
id: '1',
title: 'Test todo',
completed: false,
createdAt: new Date(),
updatedAt: new Date(),
priority: 'medium' as const
};
it('should render todo title', () => {
const wrapper = mount(TodoItem, {
props: { todo: mockTodo }
});
expect(wrapper.text()).toContain('Test todo');
});
it('should emit toggle event when checkbox is clicked', async () => {
const wrapper = mount(TodoItem, {
props: { todo: mockTodo }
});
await wrapper.find('.todo-checkbox').trigger('change');
expect(wrapper.emitted('toggle')).toBeTruthy();
expect(wrapper.emitted('toggle')[0]).toEqual(['1']);
});
});
9. Deployment and Performance
Once your todo app is complete, consider these deployment and performance optimizations:
9.1 Build Optimization
- Code Splitting: Use dynamic imports for route-based code splitting
- Tree Shaking: Ensure unused code is eliminated from the bundle
- Compression: Enable gzip/brotli compression for smaller file sizes
- CDN: Use a CDN for faster global delivery
9.2 Progressive Web App (PWA)
Convert your todo app into a PWA for better user experience:
// vite.config.ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
plugins: [
vue(),
VitePWA({
registerType: 'autoUpdate',
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg}']
},
manifest: {
name: 'Vue 3 Todo App',
short_name: 'Todo',
description: 'A modern todo application built with Vue 3',
theme_color: '#ffffff',
icons: [
{
src: 'pwa-192x192.png',
sizes: '192x192',
type: 'image/png'
}
]
}
})
]
});
10. Best Practices and Tips
Follow these best practices to ensure your Vue 3 todo app is maintainable and scalable:
- Use TypeScript: Leverage TypeScript for better type safety and developer experience
- Composition API: Prefer Composition API over Options API for better logic reuse
- Reactive Data: Use ref() and reactive() appropriately for different data types
- Computed Properties: Use computed() for derived state to avoid unnecessary recalculations
- Event Handling: Use proper event handling patterns and avoid inline functions
- Error Boundaries: Implement error boundaries to handle component errors gracefully
- Accessibility: Ensure your app is accessible with proper ARIA labels and keyboard navigation
11. Conclusion
Building a todo app with Vue 3 is an excellent way to learn the framework's modern features and best practices. The Composition API, TypeScript support, and improved performance make Vue 3 an ideal choice for building interactive applications.
By following this guide, you'll have created a robust, feature-rich todo application that demonstrates Vue 3's capabilities while providing a solid foundation for more complex applications. Remember to focus on user experience, performance, and maintainability as you continue to enhance your todo app.
The skills you've learned building this todo app—state management, component composition, TypeScript integration, and modern Vue 3 patterns—will serve you well in building larger, more complex applications.
Clone the repository from here