Vue×Piniaで簡単なTODOアプリを作ってみる

JavaScript

こんにちは、かつコーチです。

前回は、Vueプロジェクトのディレクトリ構成のベストプラクティスについて解説しました。

今回はVue.js編の最終回として、これまで学んできたComposition API・Pinia・型定義・設計の考え方を総動員し、シンプルなTODOアプリを実際に作っていきます。

「知識としては分かるけど、実際に手を動かして1つのアプリを組み立てた経験がない」という人ほど得るものが多い回になるはずです。

サンプルというにはやや実装量が多くなりますが、最後まで一緒に手を動かしてみましょう。

作るものと全体設計

今回作るTODOアプリの仕様

今回作るのは、次の機能を持つシンプルなTODOアプリです。

  • TODOの追加・完了切り替え・削除ができる
  • 「すべて/未完了/完了」でフィルタリングできる
  • 状態管理にPiniaを使う
  • 前回解説した機能ベースのディレクトリ構成を採用する

ディレクトリ構成

前回の記事で紹介した「機能ベース構成」を、実際にTODOアプリへ適用してみます。

src/
├── features/
│   └── todo/
│       ├── components/
│       │   ├── TodoInput.vue
│       │   ├── TodoItem.vue
│       │   ├── TodoList.vue
│       │   └── TodoFilter.vue
│       ├── stores/
│       │   └── todoStore.ts
│       └── types.ts
├── App.vue
└── main.ts

小規模なアプリなのでcomponentscomposablesのような共通フォルダは省略し、features/todoにすべて集約しています。

「規模に合った構成を選ぶ」という前回の考え方を、さっそくここで実践しています。

型定義とストアの実装

TODOの型を定義する

まずはtypes.tsで、TODOのデータ構造を定義します。

// src/features/todo/types.ts
export interface Todo {
  id: number
  title: string
  completed: boolean
}

export type TodoFilterType = 'all' | 'active' | 'completed'

型を最初に決めておくことで、この後のストア・コンポーネントの実装がすべて型の恩恵を受けられるようになります。

Piniaストアの実装

続いて、TODOの状態と操作をまとめたストアを作ります。

// src/features/todo/stores/todoStore.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Todo, TodoFilterType } from '../types'

export const useTodoStore = defineStore('todo', () => {
  const todos = ref<Todo[]>([])
  const filter = ref<TodoFilterType>('all')
  let nextId = 1

  const addTodo = (title: string): void => {
    const trimmed = title.trim()
    if (!trimmed) return

    todos.value.push({
      id: nextId++,
      title: trimmed,
      completed: false,
    })
  }

  const toggleTodo = (id: number): void => {
    const target = todos.value.find((todo) => todo.id === id)
    if (target) {
      target.completed = !target.completed
    }
  }

  const removeTodo = (id: number): void => {
    todos.value = todos.value.filter((todo) => todo.id !== id)
  }

  const filteredTodos = computed<Todo[]>(() => {
    switch (filter.value) {
      case 'active':
        return todos.value.filter((todo) => !todo.completed)
      case 'completed':
        return todos.value.filter((todo) => todo.completed)
      default:
        return todos.value
    }
  })

  const remainingCount = computed<number>(() => {
    return todos.value.filter((todo) => !todo.completed).length
  })

  return {
    todos,
    filter,
    addTodo,
    toggleTodo,
    removeTodo,
    filteredTodos,
    remainingCount,
  }
})

Composition API形式(setupストア)でPiniaのストアを書くと、通常のComposition APIと同じ感覚でrefcomputedが使えるので、これまでの記事で学んだ知識がそのまま活かせます。

filteredTodosのように「元データから導出される値」は、必ずcomputedにするのがこれまでの回でも繰り返し出てきたポイントです。

コンポーネントの実装

入力フォーム:TodoInput.vue

TODOを追加するための入力フォームです。

<script setup lang="ts">
import { ref } from 'vue'
import { useTodoStore } from '../stores/todoStore'

const store = useTodoStore()
const inputText = ref<string>('')

const handleSubmit = (): void => {
  store.addTodo(inputText.value)
  inputText.value = ''
}
</script>

<template>
  <form class="todo-input" @submit.prevent="handleSubmit">
    <input
      v-model="inputText"
      type="text"
      placeholder="やることを入力してEnter"
    />
    <button type="submit">追加</button>
  </form>
</template>

@submit.preventでページのリロードを防ぎつつ、フォームの送信をそのままストアのaddTodo呼び出しにつなげています。

一覧表示:TodoList.vue / TodoItem.vue

TODOの一覧は、親(TodoList)と子(TodoItem)に分けて実装します。

<script setup lang="ts">
import { useTodoStore } from '../stores/todoStore'
import TodoItem from './TodoItem.vue'

const store = useTodoStore()
</script>

<template>
  <ul class="todo-list">
    <TodoItem
      v-for="todo in store.filteredTodos"
      :key="todo.id"
      :todo="todo"
      @toggle="store.toggleTodo(todo.id)"
      @remove="store.removeTodo(todo.id)"
    />
  </ul>
  <p v-if="store.filteredTodos.length === 0" class="empty">
    表示できるTODOがありません
  </p>
</template>
<script setup lang="ts">
import type { Todo } from '../types'

defineProps<{
  todo: Todo
}>()

defineEmits<{
  toggle: []
  remove: []
}>()
</script>

<template>
  <li class="todo-item" :class="{ completed: todo.completed }">
    <input
      type="checkbox"
      :checked="todo.completed"
      @change="$emit('toggle')"
    />
    <span>{{ todo.title }}</span>
    <button @click="$emit('remove')">削除</button>
  </li>
</template>

TodoItemはストアを直接参照せず、propsemitだけでやり取りする設計にしています。

こうしておくと、TodoItem単体で別の画面でも再利用しやすくなり、テストも書きやすくなります。

フィルター:TodoFilter.vue

「すべて/未完了/完了」を切り替えるボタン群です。

<script setup lang="ts">
import { useTodoStore } from '../stores/todoStore'
import type { TodoFilterType } from '../types'

const store = useTodoStore()

const filters: { label: string; value: TodoFilterType }[] = [
  { label: 'すべて', value: 'all' },
  { label: '未完了', value: 'active' },
  { label: '完了', value: 'completed' },
]
</script>

<template>
  <div class="todo-filter">
    <button
      v-for="item in filters"
      :key="item.value"
      :class="{ active: store.filter === item.value }"
      @click="store.filter = item.value"
    >
      {{ item.label }}
    </button>
    <span class="remaining">残り{{ store.remainingCount }}件</span>
  </div>
</template>

配列に選択肢をまとめてv-forで描画することで、フィルターの種類が増えてもテンプレート側を書き換えずに済みます。

つまずきやすいポイント:propsを直接書き換えようとしてしまう

かつコーチが実際につまずいた話

このアプリを最初に組んだとき、TodoItemのチェックボックスの状態を、うっかりpropsであるtodo.completedに直接v-modelでバインドしようとしてしまったことがあります。

❌ Before:propsのプロパティに直接v-modelでバインドしてしまう

<script setup lang="ts">
import type { Todo } from '../types'

const props = defineProps<{
  todo: Todo
}>()
</script>

<template>
  <li>
    <!-- propsはコンポーネントの外から渡された「読み取り専用」の値 -->
    <input type="checkbox" v-model="props.todo.completed" />
    <span>{{ props.todo.title }}</span>
  </li>
</template>

このコードは一見動いているように見えるのですが、Vueが開発モードで「propsを直接ミューテートしないでください」という警告を出します。

propsは親コンポーネントから渡される「読み取り専用のデータ」という約束があり、子コンポーネント側で勝手に書き換えると、親の状態と子の見た目がズレる原因になります。

私はこの警告メッセージを最初「エラーではなく警告だから」と放置してしまい、後になって親のストアの状態と画面表示が一致しないバグを引き起こしてしまいました。

✅ After:emitで親(ストア)に変更を依頼する

<script setup lang="ts">
import type { Todo } from '../types'

defineProps<{
  todo: Todo
}>()

defineEmits<{
  toggle: []
}>()
</script>

<template>
  <li>
    <!-- 自分では書き換えず、emitで「変更してほしい」と伝えるだけにする -->
    <input
      type="checkbox"
      :checked="todo.completed"
      @change="$emit('toggle')"
    />
    <span>{{ todo.title }}</span>
  </li>
</template>

子コンポーネントは「表示すること」と「変更してほしいと伝えること」だけに徹し、実際の状態変更はストア側のtoggleTodoに一任します。

この「データの流れを一方向に保つ」という原則は、規模が大きくなるアプリほど効いてくる設計原則だと、今回のアプリ作りを通じて改めて実感しました。

App.vueで組み立てる

すべてのコンポーネントを配置する

最後に、App.vueで今まで作ったコンポーネントを組み合わせます。

<script setup lang="ts">
import TodoInput from './features/todo/components/TodoInput.vue'
import TodoFilter from './features/todo/components/TodoFilter.vue'
import TodoList from './features/todo/components/TodoList.vue'
</script>

<template>
  <main class="app">
    <h1>TODOアプリ</h1>
    <TodoInput />
    <TodoFilter />
    <TodoList />
  </main>
</template>

各コンポーネントがuseTodoStore()を通じて同じストアを参照しているため、App.vue側ではpropsのバケツリレーが一切発生していません。

これも、これまでの記事で扱ってきた「Piniaによるグローバルな状態管理」の恩恵です。

まとめ

これでVue.js編、全35記事が完了しました。

基礎文法から始まり、Composition API、コンポーネント設計、Pinia、テスト、デプロイ、そして今回のディレクトリ設計と実践プロジェクトまで、一通りの流れを扱ってきました。

今回のTODOアプリは、型定義・ストア・コンポーネント分割・propsとemitの原則など、これまでの記事で1つずつ解説してきた要素の集大成になっています。

「個別の知識はなんとなく分かるけど、1つのアプリとしてどう組み立てるか分からない」という悩みが、この記事で少しでも解消されていれば嬉しいです。

この記事のポイント

  • 機能ベースのディレクトリ構成に沿って、TODOアプリをfeatures/todo配下にまとめた
  • Piniaのsetupストアで、状態(ref)と導出値(computed)、操作(関数)を1つのストアに整理した
  • コンポーネントはpropsemitによるデータの一方向フローを守り、再利用しやすい形にした
  • propsを直接書き換えてはいけないというVueの原則を、実際のバグ経験を通じて確認した
  • フィルターのような選択肢は配列化し、v-forで描画することで拡張しやすくした

次のステップへ

Vue.js編35記事、お疲れさまでした。

ここまで一緒に学んできてくれて、本当にありがとうございます。

次はNext.js編に進みます。

Reactの知識をベースに、ルーティングやSSR/SSGといった、実務で欠かせないテーマを扱っていく予定です。

公開までもう少しお待ちください。楽しみにしていてくださいね。

タイトルとURLをコピーしました