状態管理とデータ永続化
前の章でコンポーネント化が完了しました。現在、最後の実践的な課題に直面しています:ページをリフレッシュすると、データがすべて消えてしまう。
この問題を解決するために、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 { 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 { 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 を呼び出すだけで済みます。
例
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でラップされたオブジェクトなので、直接分割代入すると参照が壊れます。 - 解決方法:使用するのは storeToRefs
storeToRefsです。これはストア内の状態をrefに変換し、分割代入後も「一箇所を動かせば全体が動く」ことを保証します。
2. watchの深い監視(deep: true)
永続化ロジックでは、監視しているのはtasks配列です。
- もし
{ deep: true }を付けない場合、配列が置き換えられたときだけ(例:tasks.value = [])が保存をトリガーします。 - 付けると、配列内のオブジェクトのプロパティ変更(タスクが「未完了」から「完了」に変わるなど)も検出され、ディスクに保存されます。
3. 永続化の考え方
「初期化時に読み取り + 変更時に書き込み」という閉ループを採用しています:
- 読み取り:Store 作成時に
localStorageからデータを取得し、アプリに「記憶」を持たせます。 - 書き込み:利用することで
watch完全自動同期を実現し、開発者は手動で書く必要はもうありません。setItem。
Tailwind v4 の最終仕上げ
Store のサポートにより、アプリにいくつかのグローバルフィードバックスタイル。たとえばTaskHeader.vueにタスクの進捗を表示します:
例
<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>
変更後、画面全体に進捗バーの機能が追加されます:
