VuVue · Lesson 6 of 7

Mini Project: Todo App with Pinia

Build a fully featured todo app using Vue 3 + Pinia (Vue's official state management library). Pinia replaces Vuex — it's simpler, type-safe, and works great with the Composition API.

JavaScript
// stores/todos.js  (install pinia: npm install pinia)
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useTodosStore = defineStore('todos', () => {
  // State
  const todos  = ref([])
  const filter = ref('all')   // 'all' | 'active' | 'done'

  // Getters (computed)
  const filtered = computed(() => {
    if (filter.value === 'active') return todos.value.filter(t => !t.done)
    if (filter.value === 'done')   return todos.value.filter(t => t.done)
    return todos.value
  })

  const remaining = computed(() => todos.value.filter(t => !t.done).length)

  // Actions
  function add(text) {
    todos.value.push({ id: Date.now(), text, done: false })
  }

  function toggle(id) {
    const todo = todos.value.find(t => t.id === id)
    if (todo) todo.done = !todo.done
  }

  function remove(id) {
    todos.value = todos.value.filter(t => t.id !== id)
  }

  function clearDone() {
    todos.value = todos.value.filter(t => !t.done)
  }

  return { todos, filter, filtered, remaining, add, toggle, remove, clearDone }
})
HTML
<!-- App.vue -->
<template>
  <div class="app">
    <h1>Todos <span class="badge">{{ store.remaining }}</span></h1>

    <!-- Add new todo -->
    <form @submit.prevent="addTodo">
      <input v-model="newTodo" placeholder="What needs to be done?" />
      <button type="submit">Add</button>
    </form>

    <!-- Filter buttons -->
    <div class="filters">
      <button v-for="f in filters" :key="f"
              :class="{ active: store.filter === f }"
              @click="store.filter = f">
        {{ f }}
      </button>
    </div>

    <!-- Todo list -->
    <TransitionGroup name="list" tag="ul">
      <li v-for="todo in store.filtered" :key="todo.id" :class="{ done: todo.done }">
        <input type="checkbox" :checked="todo.done" @change="store.toggle(todo.id)" />
        <span>{{ todo.text }}</span>
        <button @click="store.remove(todo.id)">✕</button>
      </li>
    </TransitionGroup>

    <button v-if="store.todos.some(t => t.done)" @click="store.clearDone">
      Clear completed
    </button>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import { useTodosStore } from './stores/todos'

const store   = useTodosStore()
const newTodo = ref("")
const filters = ['all', 'active', 'done']

function addTodo() {
  if (!newTodo.value.trim()) return
  store.add(newTodo.value.trim())
  newTodo.value = ""
}
</script>

<style>
.list-enter-active, .list-leave-active { transition: all 0.3s ease; }
.list-enter-from, .list-leave-to       { opacity: 0; transform: translateX(-30px); }
</style>
◆ Note
Register Pinia in `main.js`: `import { createPinia } from 'pinia'; app.use(createPinia())`. Next steps: add local storage persistence with `pinia-plugin-persistedstate`, or try Vue Router for multi-page navigation.