Capstone Project: Task Manager with Vue Router and Pinia

Advanced
16 min

Capstone Project: Task Manager with Vue Router and Pinia

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.

Setup and Structure

bash
npm create vue@latest task-manager # select: Router, Pinia, Vitest; TypeScript optional

Organize 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.

The Store

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:

javascript
// 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 } })

Components

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:

vue
<!-- 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.

Routes and the Detail View

javascript
// 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.

Tests

javascript
// 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.

Extending the Project

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.

Quick Quiz
Question 1 of 2

Why is `storeToRefs(store)` used in `TaskList` instead of `const { filtered } = store`?

Key Takeaways

  • A Pinia setup store centralizes tasks, derived lists and actions; a deep watcher persists them.
  • Container components talk to the store; presentational components use props and events.
  • storeToRefs keeps destructured state reactive; TransitionGroup animates list changes.
  • Lazy-loaded routes with props: true feed the detail view from the same store.
  • Vitest store and component tests protect the behaviour as you extend the app.

Next lesson: Building and Deploying a Vue App — produce an optimized production build and publish the task manager to a hosting platform.

Capstone Project: Task Manager with Vue Router and Pinia - Vue.js | CodeYourCraft | CodeYourCraft