404 Not Found

404 Not Found


nginx

状態管理 Pinia

Aliceがカートに商品を追加した後, 別のページに移動するとカートが空になります。各ページには独自のコンポーネント状態があるからです。BobはSSRとクライアント側でユーザーのログイン状態に不整合があることに気づきました。Charlieはコンポーネントやページ間で共有状態を管理するためにPiniaが必要です。

1. 学ぶ内容


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状態の流れ

100%
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)でも読み取れるためです。

📖 まとめ


📝 練習問題

  1. 基本問題 (難易度:⭐):useCartStoreを作成し, addItemremoveItemを実装し, 2つの異なるページでショッピングカートを操作して状態が共有されることを検証してください。
  2. 応用問題 (難易度:⭐⭐):Setup構文でuseUserStoreを定義し, login, logout, fetchProfileを実装し, SSR水和が正しく動作することを検証してください。
  3. チャレンジ (難易度:⭐⭐⭐):ショッピングカートの永続化を実装し (ページリフレッシュ後もデータが保持される), localStorageプラグインとcookie同期の長所と短所を比較してください。

---|

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%