状態管理とデータ永続化

前の章でコンポーネント化が完了しました。現在、最後の実践的な課題に直面しています:ページをリフレッシュすると、データがすべて消えてしまう。

この問題を解決するために、Vue公式推奨の状態管理ライブラリを導入します。Piniaそして、Tailwind v4のスタイル能力を組み合わせて、データの永続化ストレージを実現します。。


Pinia 状態管理とデータ永続化

Pinia は Vue.js の軽量な状態管理ライブラリで、コンポーネント間で状態を共有・管理できるようにします。Pinia をグローバルなデータストアと考えると、すべてのコンポーネントがここからデータを取得したり更新したりできます。

Pinia 関連内容の参考:Pinia 入門チュートリアル。

なぜ Pinia を使うのか?

以前のコードでは、データはすべてApp.vueにあります。プロジェクトが大きくなると(たとえば統計ページやユーザーセンターを追加するなど)、コンポーネント間でのデータ受け渡しはマトリョーシカ式の悪夢(Props Hell)になります。

  • Piniaグローバルなデータストアを提供します。
  • どのコンポーネントもストアから直接データを取得したり、ストアのメソッドを呼び出したりできます。

インストールと初期化:

npm install pinia
npm install @vue/devtools-kit -D

src/main.js で登録します:

例

import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import './style.css'

const app = createApp(App)
app.use(createPinia()) // プラグイン登録
app.mount('#app')

Task Store(ストア)を作成

src ディレクトリに stores ディレクトリを作成し、その後 src/stores ディレクトリに taskStore.js を新規作成します。

元々 App.vue にあったロジックをすべてこちらに移します。

例

import { defineStore } from 'pinia'
import { ref, computed, watch } from 'vue'

export const useTaskStore = defineStore('task-store', () => {
  // --- 1. 状態(State)---
  // LocalStorage から初期値を読み取る
  const savedTasks = localStorage.getItem('my-tasks')
  const tasks = ref(savedTasks ? JSON.parse(savedTasks) : [])
  const filter = ref('all')

  // --- 2. 派生状態(Getters)---
  const filteredTasks = computed(() => {
    if (filter.value === 'active') return tasks.value.filter(t => !t.isCompleted)
    if (filter.value === 'completed') return tasks.value.filter(t => t.isCompleted)
    return tasks.value
  })

  // --- 3. アクション(Actions)---
  const addTask = (title) => {
    tasks.value.unshift({ id: crypto.randomUUID(), title, isCompleted: false })
  }

  const removeTask = (id) => {
    tasks.value = tasks.value.filter(t => t.id !== id)
  }

  const toggleTask = (id) => {
    const task = tasks.value.find(t => t.id === id)
    if (task) task.isCompleted = !task.isCompleted
  }

  // --- 4. 永続化(Persistence)---
  // tasks の変化を監視し、変化したら LocalStorage に書き込む
  watch(tasks, (newVal) => {
    localStorage.setItem('my-tasks', JSON.stringify(newVal))
  }, { deep: true }) // 配列内のオブジェクトの変化をディープ監視する

  return {
    tasks,
    filter,
    filteredTasks,
    addTask,
    removeTask,
    toggleTask
  }
})

App.vue をリファクタリング(ストアに接続)

これで App.vue は自分でデータを管理する必要がなくなり、Store を呼び出すだけで済みます。

例

<script setup>
import { storeToRefs } from 'pinia'
import { useTaskStore } from '@/stores/taskStore'
import TaskHeader from './components/TaskHeader.vue'
import TaskInput from './components/TaskInput.vue'
import TaskFilter from './components/TaskFilter.vue'
import TaskItem from './components/TaskItem.vue'

// ストアを初期化
const taskStore = useTaskStore()

// storeToRefs を使用してデータのリアクティブな分割代入を維持
// これによりテンプレート内で filter と filteredTasks を直接使える
const { filter, filteredTasks } = storeToRefs(taskStore)
const { addTask, removeTask, toggleTask } = taskStore
</script>

<template>
  <div class="min-h-screen py-12 px-4">
    <div class="max-w-md mx-auto bg-white rounded-3xl shadow-xl border border-slate-100 overflow-hidden">
      <TaskHeader />
      <main class="p-6">
        <TaskInput @add-task="addTask" />
        <TaskFilter v-model="filter" />

        <ul class="space-y-3">
          <TransitionGroup name="list">
            <TaskItem
             v-for="task in filteredTasks"
             :key="task.id"
             :task="task"
             @toggle="toggleTask"
             @remove="removeTask"
           />
          </TransitionGroup>
        </ul>
      </main>
    </div>
  </div>
</template>

知識ポイントの詳細解説

1. storeToRefsとは?

もし直接このように書くと:const { filter } = taskStore、リアクティビティが失われます。変更してもfilter、ページはリフレッシュされません。

  • 原因:Store はreactiveでラップされたオブジェクトなので、直接分割代入すると参照が壊れます。
  • 解決方法:使用するのは storeToRefsstoreToRefsです。これはストア内の状態をrefに変換し、分割代入後も「一箇所を動かせば全体が動く」ことを保証します。

2. watchの深い監視(deep: true)

永続化ロジックでは、監視しているのはtasks配列です。

  • もし{ deep: true }を付けない場合、配列が置き換えられたときだけ(例:tasks.value = [])が保存をトリガーします。
  • 付けると、配列内のオブジェクトのプロパティ変更(タスクが「未完了」から「完了」に変わるなど)も検出され、ディスクに保存されます。

3. 永続化の考え方

「初期化時に読み取り + 変更時に書き込み」という閉ループを採用しています:

  1. 読み取り:Store 作成時にlocalStorageからデータを取得し、アプリに「記憶」を持たせます。
  2. 書き込み:利用することでwatch完全自動同期を実現し、開発者は手動で書く必要はもうありません。setItem。

Tailwind v4 の最終仕上げ

Store のサポートにより、アプリにいくつかのグローバルフィードバックスタイル。たとえばTaskHeader.vueにタスクの進捗を表示します:

例

<script setup>
<script setup>
import { computed } from 'vue';
import { storeToRefs } from 'pinia';
import { useTaskStore } from '@/stores/taskStore'; // パスが正しいことを確認

// 1. store を初期化
const taskStore = useTaskStore();

// 2. storeToRefs を使用して tasks を分割代入し、リアクティブであることを確認
// 直接 const { tasks } = taskStore; とすると、進捗バーはチェックに応じて動かない
const { tasks } = storeToRefs(taskStore);

// 3. 進捗ロジックを記述
const progress = computed(() => {
  const total = tasks.value.length;
if (total === 0) return 0; // ゼロ除算を防ぐ

  const completedCount = tasks.value.filter(t => t.isCompleted).length;
// パーセンテージを計算して整数に丸める
  return Math.round((completedCount / total) * 100);
});
</script>

<template>
  <header class="bg-linear-to-br from-blue-600 to-indigo-700 p-8 text-white">
    <h1 class="text-3xl font-black tracking-tight">TaskHub</h1>
   
    <div class="mt-6">
      <div class="flex justify-between items-end mb-2">
        <p class="text-blue-100/80 text-xs font-bold uppercase tracking-wider">完了進捗</p>
        <span class="text-2xl font-mono font-black">{{ progress }}%</span>
      </div>
     
      <div class="h-2 w-full bg-white/20 rounded-full overflow-hidden backdrop-blur-sm">
        <div
         class="h-full bg-white shadow-[0_0_15px_rgba(255,255,255,0.5)] transition-all duration-500 ease-out"
         :style="{ width: `${progress}%` }"
       ></div>
      </div>
    </div>
  </header>
</template>

変更後、画面全体に進捗バーの機能が追加されます:

その他の拡張