This project ties the course together: a task manager with a persistent Pinia store, a validated form, animated list updates, a detail page reached through Vue Router, and tests. After this lesson you will have a complete, structured Vue application to extend and deploy in the final chapter.
npm create vue@latest task-manager
# select: Router, Pinia, Vitest; TypeScript optionalOrganize src/ by responsibility: stores/tasks.js for state and actions; components/ for TaskForm, TaskItem, TaskList and FilterBar; views/ for HomeView and TaskDetailView; router/index.js for the routes.
A setup store holds the tasks, the current filter, a derived filtered list and the actions. A deep watcher persists every change to localStorage, so the list survives reloads:
// src/stores/tasks.js
import { defineStore } from 'pinia'
import { ref, computed, watch } from 'vue'
export const useTasksStore = defineStore('tasks', () => {
const tasks = ref(JSON.parse(localStorage.getItem('tasks') ?? '[]'))
const filter = ref('all')
const filtered = computed(() =>
filter.value === 'all'
? tasks.value
: tasks.value.filter(t => t.done === (filter.value === 'done'))
)
const remaining = computed(() => tasks.value.filter(t => !t.done).length)
const byId = (id) => tasks.value.find(t => t.id === id)
function add(title) {
tasks.value.push({ id: crypto.randomUUID(), title, done: false })
}
function toggle(id) {
const task = byId(id)
if (task) task.done = !task.done
}
function remove(id) {
tasks.value = tasks.value.filter(t => t.id !== id)
}
watch(tasks, (v) => localStorage.setItem('tasks', JSON.stringify(v)), { deep: true })
return { tasks, filter, filtered, remaining, byId, add, toggle, remove }
})TaskForm owns a title ref and an error ref. Its @submit.prevent handler trims the value, sets error when it is shorter than 3 characters, otherwise calls store.add(value), clears the input and refocuses it through a template ref; the error uses role="alert" as in the forms lesson. TaskList reads the store and delegates each row to TaskItem, a presentational component driven only by props and events:
<!-- src/components/TaskList.vue -->
<script setup>
import { storeToRefs } from 'pinia'
import { useTasksStore } from '@/stores/tasks'
import TaskItem from './TaskItem.vue'
const store = useTasksStore()
const { filtered } = storeToRefs(store)
</script>
<template>
<TransitionGroup name="list" tag="ul">
<TaskItem v-for="task in filtered" :key="task.id" :task="task"
@toggle="store.toggle(task.id)" @remove="store.remove(task.id)" />
</TransitionGroup>
</template>TaskItem renders a checkbox that emits toggle, a RouterLink to the detail route around the title, and a remove button; FilterBar binds three radios to store.filter and shows store.remaining. storeToRefs is required when destructuring state or getters; actions can be destructured directly.
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '@/views/HomeView.vue'
export default createRouter({
history: createWebHistory(),
routes: [
{ path: '/', name: 'home', component: HomeView },
{ path: '/tasks/:id', name: 'task', component: () => import('@/views/TaskDetailView.vue'), props: true },
{ path: '/:pathMatch(.*)*', component: () => import('@/views/NotFoundView.vue') }
]
})TaskDetailView receives id as a prop, computes const task = computed(() => store.byId(props.id)), renders an editable title with v-model="task.title", and shows a "Task not found" link home when the computed is undefined. Since the store is the single source of truth, edits show on the list immediately.
// src/stores/tasks.test.js
import { setActivePinia, createPinia } from 'pinia'
import { beforeEach, it, expect } from 'vitest'
import { useTasksStore } from './tasks'
beforeEach(() => { setActivePinia(createPinia()); localStorage.clear() })
it('adds, toggles and filters tasks', () => {
const store = useTasksStore()
store.add('Write tests')
store.toggle(store.tasks[0].id)
store.filter = 'done'
expect(store.filtered).toHaveLength(1)
expect(store.remaining).toBe(0)
})A component test can mount TaskForm with createTestingPinia, submit an empty value and assert the error, then a valid one and assert store.add was called.
Natural next steps: replace localStorage with an API module and loading states, add a login route protected by a requiresAuth guard, and add due dates with an "overdue" computed and a sort option.
Why is `storeToRefs(store)` used in `TaskList` instead of `const { filtered } = store`?
storeToRefs keeps destructured state reactive; TransitionGroup animates list changes.props: true feed the detail view from the same store.Next lesson: Building and Deploying a Vue App — produce an optimized production build and publish the task manager to a hosting platform.