状態管理 Pinia
Aliceがカートに商品を追加した後, 別のページに移動するとカートが空になります。各ページには独自のコンポーネント状態があるからです。BobはSSRとクライアント側でユーザーのログイン状態に不整合があることに気づきました。Charlieはコンポーネントやページ間で共有状態を管理するためにPiniaが必要です。
1. 学ぶ内容
- @pinia/nuxtモジュールのインストールと自動設定
- Store定義:オプション型とSetup構文
- SSR状態の脱水と水和のメカニズム
- storeToRefsリアクティブ分割代入と$patch/$reset
- MegaShopショッピングカート:追加, 削除, 更新, 照会+合計金額計算
2. 消費者の実話
(1) ペインポイント:「消える」ショッピングカート状態
AliceはMegaShopのホームページで3つの商品をカートに追加しましたが, 商品詳細ページをクリックするとカートアイコンが0件を表示していました。状態が同期されていません。Bobも同様の問題に遭遇しました。ログインしてページをリフレッシュすると, ログイン状態が消えていました。
(2) Pinia状態管理による解決策
Piniaはショッピングカートとユーザー状態をコンポーネントのライフサイクルから切り離し, グローバルに共有されるリアクティブデータに変えます:
TYPESCRIPT
// composables/useCartStore.ts
export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([])
const total = computed(() => items.value.reduce((s, i) => s + i.price * i.quantity, 0))
return { items, total }
})
(3) 効果:クロスページ状態の永続化
Aliceのショッピングカートはすべてのページで一貫性を保ち, Bobのログイン状態はSSRとクライアント間で同期され, 状態消失の問題が解消されました。
3. Piniaのインストールと設定
(1) インストール手順
BASH
npm install @pinia/nuxt pinia
(1) ▶ サンプル:nuxt.config.ts設定
TYPESCRIPT
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@pinia/nuxt'],
// Piniaは自動設定, 追加のセットアップ不要
})
出力:
TEXT
// 実行成功
(2) Pinia, Vuex, useStateの比較
| 項目 | Pinia | Vuex 4 | Nuxt useState |
|---|---|---|---|
| Vue 3サポート | ✅ ネイティブ | ⚠️ 互換モード | ✅ Nuxt専用 |
| TypeScript | ✅ 完全な型推論 | ❌ 手動宣言が必要 | ✅ ジェネリクス対応 |
| SSR水和 | ✅ 自動 | ⚠️ 設定が必要 | ✅ 自動 |
| コード分割 | ✅ オンデマンド | ❌ グローバル | ❌ グローバル |
| DevTools | ✅ サポート | ✅ サポート | ❌ 非サポート |
| サイズ | ~1 KB | ~6 KB | 内蔵 |
| ユースケース | 複雑なシナリオ | レガシーマイグレーション | 簡易共有 |
4. Storeの定義方法
(1) オプション型Store
(1) ▶ サンプル:オプション型ショッピングカートStore
TYPESCRIPT
// stores/cart.ts
export const useCartStore = defineStore('cart', {
state: () => ({
items: [] as CartItem[],
couponCode: '' as string
}),
getters: {
totalItems: (state) => state.items.reduce((sum, item) => sum + item.quantity, 0),
totalPrice: (state) => {
return state.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
},
formattedTotal(): string {
return new Intl.NumberFormat('en-US', {
style: 'currency', currency: 'USD'
}).format(this.totalPrice)
}
},
actions: {
addItem(product: Product) {
const existing = this.items.find(i => i.id === product.id)
if (existing) {
existing.quantity++
} else {
this.items.push({ ...product, quantity: 1 })
}
},
removeItem(productId: number) {
this.items = this.items.filter(i => i.id !== productId)
},
updateQuantity(productId: number, quantity: number) {
const item = this.items.find(i => i.id === productId)
if (item) item.quantity = Math.max(0, quantity)
this.items = this.items.filter(i => i.quantity > 0)
},
clearCart() {
this.items = []
this.couponCode = ''
}
}
})
interface CartItem {
id: number; name: string; price: number; quantity: number; image: string
}
interface Product {
id: number; name: string; price: number; image: string
}
出力:
TEXT
// 実行成功
(2) Setup構文Store
(2) ▶ サンプル:Setup構文ユーザーStore
TYPESCRIPT
// stores/user.ts
export const useUserStore = defineStore('user', () => {
// State
const user = ref<User | null>(null)
const isAuthenticated = computed(() => !!user.value)
const fullName = computed(() => user.value ? `${user.value.firstName} ${user.value.lastName}` : '')
// Actions
async function login(email: string, password: string) {
const response = await $fetch('/api/auth/login', {
method: 'POST',
body: { email, password }
})
user.value = response.user
}
function logout() {
user.value = null
}
async function fetchProfile() {
const profile = await $fetch('/api/user/profile')
user.value = profile
}
return {
user, isAuthenticated, fullName,
login, logout, fetchProfile
}
})
interface User {
id: number; email: string; firstName: string; lastName: string; role: 'customer' | 'admin'
}
出力:
TEXT
// 実行成功
(3) 2つの方法の比較
| 項目 | オプション型 | Setup構文 |
|---|---|---|
| 構文 | state/getters/actions | ref/computed/function |
| TypeScript | ⚠️ インターフェース宣言が必要 | ✅ 自動推論 |
| 柔軟性 | ⚠️ 制限あり | ✅ 任意のコンポーザブルが利用可能 |
| SSR | ✅ $resetをサポート | ⚠️ $reset非サポート (手動実装が必要) |
| ユースケース | シンプルなStore | 複雑なStore/コンポーザブルが必要な場合 |
5. SSR脱水と水和
(1) Pinia SSR状態の流れ
flowchart LR
A[サーバー:Storeにデータを格納] --> B[状態をHTML payloadにシリアライズ]
B --> C[クライアント:payloadを読み取り]
C --> D[サーバー状態でStoreを水和]
D --> E[クライアント:Store準備完了, 再フェッチなし]
(1) ▶ サンプル:SSRでStoreを初期化
TYPESCRIPT
// plugins/init-pinia.server.ts
export default defineNuxtPlugin(() => {
const cartStore = useCartStore()
// サーバーでcookieからカートを初期化
const cartCookie = useCookie('cart-items')
if (cartCookie.value) {
cartStore.items = cartCookie.value
}
})
出力:
TEXT
// 実行成功
(2) ▶ サンプル:PiniaとuseStateのSSR比較
| 項目 | Pinia | useState |
|---|---|---|
| SSR水和 | ✅ 自動payload | ✅ 自動payload |
| マルチStore | ✅ 独立した名前空間 | ⚠️ 手動キー管理 |
| DevTools | ✅ 可視化 | ❌ なし |
| 永続化 | ✅ プラグインサポート | ⚠️ 手動cookieが必要 |
| 複雑ロジック | ✅ actions/getters | ❌ refのみ |
6. storeToRefsとバッチ操作
(1) ▶ サンプル:storeToRefsリアクティブ分割代入
VUE
<script setup lang="ts">
const cartStore = useCartStore()
// ✅ リアクティブ分割代入 - リアクティブ性を維持
const { items, totalPrice, totalItems } = storeToRefs(cartStore)
// ❌ 直接分割代入 - リアクティブ性を失う
// const { items, totalPrice } = cartStore
// アクションは直接分割代入可能 (リアクティブ性不要)
const { addItem, removeItem, clearCart } = cartStore
</script>
出力:
TEXT
// 実行成功
(2) ▶ サンプル:$patchバッチ更新
TYPESCRIPT
// $patchでバッチ更新 - 単一のリアクティブトリガー
const cartStore = useCartStore()
// オブジェクトスタイル
cartStore.$patch({
couponCode: 'SAVE20',
items: [...cartStore.items, newItem]
})
// 関数スタイル (配列変更に適している)
cartStore.$patch((state) => {
state.couponCode = 'SAVE20'
state.items.push(newItem)
state.items[0].quantity = 3
})
出力:
TEXT
// 実行成功
(3) ▶ サンプル:ショッピングカート永続化プラグイン
TYPESCRIPT
// plugins/pinia-persist.client.ts
export default defineNuxtPlugin(({ $pinia }) => {
$pinia.use(({ store }) => {
// クライアントでlocalStorageから読み込み
const saved = localStorage.getItem(`pinia-${store.$id}`)
if (saved) store.$patch(JSON.parse(saved))
// 変更時にlocalStorageに保存
store.$subscribe((mutation, state) => {
localStorage.setItem(`pinia-${store.$id}`, JSON.stringify(state))
})
})
})
出力:
TEXT
// 実行成功
7. 総合例:MegaShopショッピングカートシステム
VUE
<!-- pages/cart.vue -->
<template>
<div class="cart-page">
<h1>ショッピングカート</h1>
<div v-if="items.length === 0" class="empty-cart">
<p>カートは空です</p>
<NuxtLink to="/products">ショッピングを続ける</NuxtLink>
</div>
<div v-else>
<div class="cart-items">
<div v-for="item in items" :key="item.id" class="cart-item">
<img :src="item.image" :alt="item.name" />
<div class="details">
<h3>{{ item.name }}</h3>
<p>${{ item.price }} USD</p>
<div class="quantity">
<button @click="updateQuantity(item.id, item.quantity - 1)">-</button>
<span>{{ item.quantity }}</span>
<button @click="updateQuantity(item.id, item.quantity + 1)">+</button>
</div>
</div>
<button @click="removeItem(item.id)" class="remove">削除</button>
</div>
</div>
<div class="cart-summary">
<p>商品数: {{ totalItems }}</p>
<p class="total">合計: {{ formattedTotal }}</p>
<button @click="clearCart" class="clear">カートを空にする</button>
<NuxtLink to="/checkout" class="checkout">チェックアウトへ進む</NuxtLink>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const cartStore = useCartStore()
const { items, totalItems, formattedTotal } = storeToRefs(cartStore)
const { removeItem, updateQuantity, clearCart } = cartStore
</script>
❓ よくある質問
Q Pinia Storeとコンポーザブルのどちらを選ぶべきですか?
A コンポーネント間で状態を共有する必要がある場合はStoreを使い, コンポーネント内でロジックを再利用する場合はコンポーザブルを使います。ショッピングカートやユーザー状態にはStoreを, 価格フォーマットにはコンポーザブルを使用します。
Q Storeの分割代入でリアクティブ性が失われるのはなぜですか?
A Storeを直接分割代入すると値のコピーが返され, refではありません。
storeToRefsを使うとリアクティブ性を維持したまま分割代入できます。アクションは直接分割代入可能です (関数にはリアクティブ性は不要)。Q SSR時にクライアントとサーバー間でStoreデータに不整合がある場合はどうすればよいですか?
A
onMountedの前にStoreを変更しないようにしてください (SSRフェーズで変更された値は水和時に上書きされます)。PiniaのSSR水和は自動であり, 通常は手動処理は不要です。Q Piniaの
$resetはSetup構文で使用できませんか?A Setup構文には
$resetが含まれません。$resetアクションを手動定義するか, $patchですべてのrefをリセットすることで実装できます。Q 複数のページで同時にStoreを変更すると競合が発生しますか?
A クライアントはシングルスレッドなので競合は発生しません。SSR中も各リクエストが独立したPiniaインスタンスを持つため, 競合は発生しません。
Q ページリフレッシュ後もショッピングカートデータを保持するにはどうすればよいですか?
A 永続化プラグイン (localStorageに保存)を使用するか, cookieに同期してください。MegaShopではcookieでの同期を推奨しています。サーバー側レンダリング (SSR)でも読み取れるためです。
📖 まとめ
- PiniaはVue 3の公式状態管理ソリューション。@pinia/nuxtモジュールが自動統合を提供
- オプション型Storeは明確な構造 (state/getters/actions), Setup構文はより柔軟
- SSR水和は自動完了:サーバー側Storeデータ → payload → クライアント側Store
storeToRefsで分割代入のリアクティブ性を維持。$patchでバッチ更新でも1回のみレンダリングトリガー- MegaShopはPiniaでショッピングカートとユーザー状態を管理し, cookieで永続化
📝 練習問題
- 基本問題 (難易度:⭐):
useCartStoreを作成し,addItemとremoveItemを実装し, 2つの異なるページでショッピングカートを操作して状態が共有されることを検証してください。 - 応用問題 (難易度:⭐⭐):Setup構文で
useUserStoreを定義し,login,logout,fetchProfileを実装し, SSR水和が正しく動作することを検証してください。 - チャレンジ (難易度:⭐⭐⭐):ショッピングカートの永続化を実装し (ページリフレッシュ後もデータが保持される), localStorageプラグインとcookie同期の長所と短所を比較してください。
---|



