Vue — Pinia 画面間共有
テクノロジ系 / L05 state-management — 状態管理
Vue — Pinia 画面間共有
state-management / ch03
このチャプターの到達ゴール
| 翌日 |
|---|
| 画面間で同じデータを使う必要が出たとき、 props 伝達ではなく store を選べる |
storeToRefs を忘れたときに何が起きるかを 1 文で言える |
| 「3 つ目の画面を生やすコスト」 を 説明できる |
> 答えを暗記するのではなく、 痛み → 解 の順で身につける。
なぜ store か (= 動機の言語化)
1 画面のタスクアプリに「タスク追加」 画面を増やそうとすると こうなる:
TaskAddView (追加した) → 親 App.vue に emit → TaskListView に props で渡す
これが props バケツリレー。 画面が増えるほど App.vue が肥大化する。
> 解決: tasks を store という外部の置き場所に出す。 全画面から 直接 store を見にいける。
> これが Pinia が解いてくれる痛み。
最小 store を 1 ファイルで書く
src/stores/tasks.js:
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useTasksStore = defineStore('tasks', () => {
// state (= 持っているデータ)
const tasks = ref([])
// action (= state を変える関数)
async function load() {
const res = await fetch('http://localhost:8000/tasks')
tasks.value = await res.json()
}
return { tasks, load }
})用語の整理
| 名前 | 役割 | このチャプターでの例 |
|---|---|---|
| state | 持っているデータ | tasks (= ref) |
| getter | state から派生する値 | (本 chapter では出さない / forms 章で computed として登場) |
| action | state を変える関数 | load(), add() |
> computed が getter / ref が state / 関数が action という対応で覚える。
TaskListView を store ベースに書き換える
<script setup>
import { onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useTasksStore } from '@/stores/tasks'
const store = useTasksStore()
const { tasks } = storeToRefs(store) // ⚠️ storeToRefs を忘れない
onMounted(() => store.load())
</script>
<template>
<h1>タスク一覧</h1>
<ul>
<li v-for="t in tasks" :key="t.id">{{ t.name }}</li>
</ul>
</template>storeToRefs の罠 (= 必ず踏む)
// ❌ NG: リアクティブが切れる (= store が変わっても画面が更新されない)
const { tasks } = store
// ✅ OK: ref のまま取り出す
const { tasks } = storeToRefs(store)
// ✅ OK: action は分割代入してよい (= ref ではないため)
const { load, add } = store> ルール: ref/computed (= state/getter) は storeToRefs、 関数 (= action) は普通の分割代入。
なぜ storeToRefs を忘れると壊れるのか
const { tasks } = store は JavaScript の分割代入 で 「現時点の tasks の値」 を取り出す動き。
取り出した瞬間に store との つながりが切れる ので、 後で store の tasks が変わっても画面に伝わらない。
storeToRefs(store) は 「ref のまま (= リアクティブの管を保ったまま) 取り出す」 専用の関数。
管がつながっていれば、 store 側で tasks.value.push(...) した瞬間に画面が再描画される。
> 「呪文として暗記する」 ではなく、 分割代入が値を切り離す という JavaScript の素の仕様から来ている。
action を 1 つ追加 (= add)
export const useTasksStore = defineStore('tasks', () => {
const tasks = ref([])
async function load() { /* 既出 */ }
async function add(name) {
const res = await fetch('http://localhost:8000/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
})
const created = await res.json()
tasks.value.push(created)
}
return { tasks, load, add }
})TaskAddView を作る (= 2 画面目)
<script setup>
import { ref } from 'vue'
import { useTasksStore } from '@/stores/tasks'
import { useRouter } from 'vue-router'
const store = useTasksStore()
const router = useRouter()
const name = ref('')
async function onSubmit() {
await store.add(name.value)
router.push('/') // 一覧へ戻る
}
</script>
<template>
<h1>タスク追加</h1>
<input v-model="name" placeholder="タスク名" />
<button @click="onSubmit">追加</button>
</template>動作確認 (= 状態共有の体感)
- 「タスク追加」 で入力 → 戻ると 一覧に増えている
- DevTools の Pinia タブ (= 樽マーク) で
tasks配列が伸びている - TaskAddView と TaskListView の間に 直接の親子関係がない のに 状態が共有されている
> ここが Pinia の効能: ページが変わっても store は消えない (= router 遷移と独立)。
「props バケツリレーを撤廃した証拠」
| 観点 | Pinia なし | Pinia あり |
|---|---|---|
| App.vue に tasks を置く必要 | あり | なし |
| TaskAddView から TaskListView へ emit/props | 必要 | 不要 |
| 3 つ目の画面を生やすコスト | 親に props 追加で配線必要 | store を読むだけ |
> 「3 つ目の画面を生やすコスト」 が劇的に下がっているのが体感できる。
チャプター完了サイン
- [ ] 「タスク追加」 で入力 → 戻ると 一覧に増えている
- [ ] Pinia DevTools で
tasks配列が伸びている - [ ] 「なぜ store が要るのか」 を 自分の言葉で説明できる (= props バケツリレーの痛みへの解)
- [ ] 「なぜ storeToRefs を忘れると壊れるのか」 を 1 文で言える
次に学ぶ領域 (= 名前だけ)
| 領域 | 名前 |
|---|---|
| Pinia 別形式 | Options Store (= defineStore({state, getters, actions})) |
| state 操作 API | $patch / $reset / $subscribe |
| 永続化 | pinia-plugin-persistedstate |
| ストア間連携 | store A 内で useStoreB() を呼ぶ / circular dep 回避 |
| テスト | setActivePinia + Vitest で store 単体テスト |
| バリデーション | forms/ch01-reactive-validation |
| 非同期 UX | api/ch01-async-ux-3states |
> 全部覚える必要はない。 「今日の store のテストが書ける」 「永続化できる」 という地図だけ持っておく。
考えてみよう
既定では正解・不正解の判定はしません。自分のペースで「答えを見る」を。採点してほしい場合だけ「採点してほしい」を押してください。
ch03 — 演習
本編 manuscript と同じ題材 (タスク管理ミニアプリ + Mock API) で動かす想定。
API は port 8000 で動いている前提:
GET /tasks→[{ id, name }, ...]POST /tasks→{ id, name }
Lv1: store を最小で書く
stores/tasks.js を以下の仕様で書け。
// stores/tasks.js
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useTasksStore = defineStore('tasks', () => {
// TODO 1: tasks = ref([]) (= state)
// TODO 2: async function load() で GET /tasks を tasks に入れる
// TODO 3: tasks, load を return する
})検証
TaskListView.vue で storeToRefs で取り出した tasks を <ul><li v-for> で表示し、 起動直後にデータが見えれば OK。
Lv2: storeToRefs の罠を踏む / 解消する
下記 2 つのコードを順番に試して、 何が起きるかを観察せよ。
<!-- 試行1: NG パターン -->
<script setup>
import { useTasksStore } from '@/stores/tasks'
const store = useTasksStore()
const { tasks } = store // ← 分割代入
// 起動後に store.tasks.value.push(...) で追加してみる
</script>→ 画面が更新されない (= reactivity が切れている)。
<!-- 試行2: OK パターン -->
<script setup>
import { storeToRefs } from 'pinia'
import { useTasksStore } from '@/stores/tasks'
const store = useTasksStore()
const { tasks } = storeToRefs(store) // ← storeToRefs 経由
</script>→ 画面が更新される。
提出物
「なぜ storeToRefs を使うと更新されるのか」 を 1 文で書け。
Lv3: 2 画面で同じ store を読む
TaskListView.vue (= 一覧) と TaskAddView.vue (= 追加フォーム) の 両方 で
useTasksStore を呼び、 「追加した瞬間に一覧が更新される」 ことを確認せよ。
検証
| 操作 | 期待 |
|---|---|
/add で名前入力 → 追加ボタン押下 | router.push('/') で一覧へ遷移 |
| 一覧に追加した名前が見える | OK |
| ブラウザ DevTools の Pinia タブ | tasks 配列が伸びている |
| TaskAddView と TaskListView の間に親子関係なし | (template を見れば確認できる) |
提出物
「props バケツリレーが消えた」 ことを 自分の言葉で 1 文で書け。
ヒント
storeToRefsを import し忘れる のが 1 番多い。import { storeToRefs } from 'pinia'store.load()をonMountedで呼ばないと初期データが空のままstore.add(name)の引数を trim しないと 空白だけのタスクが追加される (= 別 chapter forms/ で対処)