Vue 3 Piniaプラグインシステム:カスタムStoreプラグインの5つのコアパターン

前端工程

はじめに

このような場面に遭遇したことはありませんか?Storeを作成するたびにlocalStorage永続化ロジックを手動で書き、DevToolsでStoreのカスタムデバッグ情報が見えず、ネットワークリクエストのloading/error状態が各コンポーネントに散在して統一管理が困難に。PiniaはシンプルなStore APIを提供していますが、そのプラグインシステムは開発者に見落とされがちです——それこそが上記の課題を解決する鍵なのに。

Piniaプラグインシステムは、すべてのStore作成時に共通ロジックを注入でき、各Storeの定義を修正する必要がありません。本記事では5つのコアパターンを通じて、ゼロからプロダクション級のPiniaプラグインを構築します。

コア概念クイックリファレンス

概念 説明
Pinia Plugin Store作成時に実行される関数、Store機能を拡張
PiniaPluginインターフェース ({ store }) => void、プラグイン関数シグネチャ
store.$onAction Store内のすべてのaction呼び出しをサブスクライブ
store.$subscribe Store内のすべてのstate変化をサブスクライブ
永続化 Store状態をストレージメディアに自動同期
DevTools Vueブラウザ開発ツール、Store状態をデバッグ可能
プラグイン登録 pinia.use()でグローバルプラグインを登録
Store拡張 プラグイン経由でStoreに新しいstate/getter/actionを注入

問題分析:Piniaプラグインの5つの課題

1. 永続化の手動実装:永続化が必要なStoreごとにlocalStorage読み書きロジックを繰り返し記述し、シリアライズやバージョン移行などのエッジケースも処理する必要がある。

2. DevTools統合の困難さ:カスタムStoreデータがDevToolsで見づらく、デバッグ時にプラグイン注入の状態変化を追跡できない。

3. プラグインAPIへの不慣れ$onAction$subscribeなどのフックの呼び出しタイミングとパラメータ構造が直感的でなく、誤用しやすい。

4. Storeライフサイクルフックの欠如:PiniaにはStore破棄フックがなく、プラグインが登録した副作用を自動クリーンアップできない。

5. プラグイン構成の競合:複数のプラグインが同時にStoreを変更する際、プロパティの上書きや実行順序の問題を予測困難。

パターン1:基本Piniaプラグイン構造

import type { PiniaPluginContext } from 'pinia'

interface PluginOptions {
  prefix?: string
}

export function createLoggerPlugin(options: PluginOptions = {}) {
  const { prefix = '[Pinia]' } = options

  return ({ store }: PiniaPluginContext) => {
    store.$onAction(({ name, args, after, onError }) => {
      const startTime = Date.now()
      console.log(`${prefix} ${store.$id}/${name} started`, args)

      after((result) => {
        console.log(
          `${prefix} ${store.$id}/${name} finished (${Date.now() - startTime}ms)`,
          result
        )
      })

      onError((error) => {
        console.error(
          `${prefix} ${store.$id}/${name} failed`,
          error
        )
      })
    })
  }
}

// 登録
const pinia = createPinia()
pinia.use(createLoggerPlugin({ prefix: '[App]' }))

パターン2:永続化プラグイン実装

interface PersistenceOptions {
  key?: string
  storage?: Storage
  paths?: string[]
  serializer?: {
    serialize: (value: unknown) => string
    deserialize: (value: string) => unknown
  }
}

declare module 'pinia' {
  export interface DefineStoreOptionsBase<S> {
    persistence?: PersistenceOptions
  }
}

export function createPersistencePlugin() {
  return ({ store, options }: PiniaPluginContext) => {
    const config = options.persistence
    if (!config) return

    const {
      key = store.$id,
      storage = localStorage,
      paths,
      serializer = {
        serialize: JSON.stringify,
        deserialize: JSON.parse,
      },
    } = config

    const hydrate = () => {
      try {
        const raw = storage.getItem(key)
        if (raw) {
          const data = serializer.deserialize(raw)
          if (paths) {
            paths.forEach((path) => {
              if (path in data) {
                store.$patch({ [path]: data[path] })
              }
            })
          } else {
            store.$patch(data)
          }
        }
      } catch (e) {
        console.error(`[PiniaPersistence] Failed to hydrate ${key}:`, e)
      }
    }

    hydrate()

    store.$subscribe((_mutation, state) => {
      try {
        const data = paths
          ? paths.reduce((acc, path) => {
              acc[path] = state[path]
              return acc
            }, {} as Record<string, unknown>)
          : state
        storage.setItem(key, serializer.serialize(data))
      } catch (e) {
        console.error(`[PiniaPersistence] Failed to persist ${key}:`, e)
      }
    })
  }
}

// 使用
export const useUserStore = defineStore('user', {
  state: () => ({ token: '', preferences: {} }),
  persistence: {
    paths: ['token', 'preferences'],
    storage: sessionStorage,
  },
})

パターン3:DevTools統合プラグイン

export function createDevToolsPlugin() {
  return ({ store, app }: PiniaPluginContext) => {
    if (import.meta.env.DEV) {
      store._customProperties = new Set()

      const original$patch = store.$patch.bind(store)
      store.$patch = function (...args: unknown[]) {
        const snapshot = JSON.parse(JSON.stringify(store.$state))
        console.groupCollapsed(`[DevTools] ${store.$id}.$patch`)
        console.table({ before: snapshot, after: 'pending...' })
        console.groupEnd()
        return original$patch(...args)
      }

      app.config.globalProperties.$piniaDevTools = {
        getStoreState: (id: string) => {
          const targetStore = pinia._s.get(id)
          return targetStore ? JSON.parse(JSON.stringify(targetStore.$state)) : null
        },
        getTimeTravelLog: () => {
          return store._timeTravelLog || []
        },
      }
    }
  }
}

パターン4:ネットワークリクエスト状態プラグイン

interface AsyncState {
  loading: boolean
  error: string | null
  lastFetch: number | null
}

declare module 'pinia' {
  export interface DefineStoreOptionsBase<S> {
    asyncActions?: Record<string, (...args: unknown[]) => Promise<unknown>>
  }
}

export function createAsyncActionPlugin() {
  return ({ store, options }: PiniaPluginContext) => {
    const asyncConfig = options.asyncActions
    if (!asyncConfig) return

    const asyncStates = reactive<Record<string, AsyncState>>({})

    Object.entries(asyncConfig).forEach(([name, action]) => {
      asyncStates[name] = { loading: false, error: null, lastFetch: null }

      store[name] = async (...args: unknown[]) => {
        asyncStates[name].loading = true
        asyncStates[name].error = null
        try {
          const result = await action.call(store, ...args)
          asyncStates[name].lastFetch = Date.now()
          return result
        } catch (e) {
          asyncStates[name].error = (e as Error).message
          throw e
        } finally {
          asyncStates[name].loading = false
        }
      }
    })

    store.$asyncStates = readonly(asyncStates)
  }
}

// 使用
export const useApiStore = defineStore('api', {
  state: () => ({ users: [] }),
  asyncActions: {
    async fetchUsers(this: any) {
      const res = await fetch('/api/users')
      this.users = await res.json()
    },
  },
})

// コンポーネント内
const apiStore = useApiStore()
apiStore.$asyncStates.fetchUsers.loading // boolean

パターン5:プロダクション級プラグイン構成フレームワーク

interface PluginConfig {
  id: string
  plugin: PiniaPlugin
  order?: number
  enabled?: boolean
}

export class PiniaPluginManager {
  private plugins: PluginConfig[] = []

  register(config: PluginConfig): this {
    this.plugins.push(config)
    return this
  }

  install(pinia: Pinia, app: App): void {
    const enabledPlugins = this.plugins
      .filter((p) => p.enabled !== false)
      .sort((a, b) => (a.order ?? 100) - (b.order ?? 100))

    enabledPlugins.forEach(({ id, plugin }) => {
      try {
        pinia.use(plugin)
        if (import.meta.env.DEV) {
          console.log(`[PiniaPluginManager] Registered: ${id}`)
        }
      } catch (e) {
        console.error(`[PiniaPluginManager] Failed to register ${id}:`, e)
      }
    })
  }
}

// 使用
const manager = new PiniaPluginManager()
  .register({ id: 'logger', plugin: createLoggerPlugin(), order: 10 })
  .register({ id: 'persistence', plugin: createPersistencePlugin(), order: 20 })
  .register({ id: 'devtools', plugin: createDevToolsPlugin(), order: 30, enabled: import.meta.env.DEV })
  .register({ id: 'async', plugin: createAsyncActionPlugin(), order: 40 })

manager.install(pinia, app)

落とし穴ガイド:5つのよくある罠

1. ❌ プラグイン内でstore.$stateを直接変更 → ✅ store.$patch()を使用してリアクティブ更新をトリガー

2. ❌ storage例外の処理忘れ → ✅ 永続化プラグインはtry-catch必須、ストレージ満了やプライベートモードのクラッシュを防止

3. ❌ プラグインで作成したリアクティブ副作用のクリーンアップなし → ✅ onScopeDisposeまたはコンポーネントアンマウント時に手動クリーンアップ

4. ❌ 複数プラグインが同名プロパティを注入 → ✅ $persistence_statusのような名前空間プレフィックスで競合を回避

5. ❌ SSRでlocalStorageを使用 → ✅ 環境を検出し、SSR時はブラウザAPIをスキップまたはcookieを使用

エラートラブルシューティング:10のよくあるエラー

エラーメッセージ 原因 解決策
Cannot read property 'getItem' of undefined SSR環境にlocalStorageがない typeof windowを確認してからstorageを使用
getActivePinia was called but no pinia was active Store作成後にプラグイン登録 pinia.use()がStore作成前に呼ばれることを確認
Maximum call stack exceeded $subscribeコールバックでstateを再変更してループ コールバック内で自身のトリガーかどうかを判定
Store "$asyncStates" is not defined 型宣言が有効でない declare module 'pinia'がd.tsにあることを確認
Cannot set property which has only getter プラグイン注入のreadonlyプロパティを変更しようとした Object.definePropertyでwritableを設定
Plugin did not return a function プラグインファクトリ関数が未呼び出し pinia.use(createPlugin())ではなくpinia.use(createPlugin)
hydration mismatch 永続化データとSSR初期状態が不一致 SSR時にhydrateをスキップまたは一貫したデータソースを使用
$onAction callback not triggered action実行後にリスナーを登録 Store作成直後に$onActionを登録
Storage quota exceeded localStorage容量不足 永続化フィールドを制限またはIndexedDBを使用
Property '$asyncStates' does not exist TypeScript型拡張が有効でない d.tsファイルがtsconfigに含まれているか確認

高度なテクニック

1. プラグインホットアップデート:開発モードでVite HMRを活用し、ページリフレッシュなしでプラグインロジックのホットリプレースを実現。

2. プラグイン設定検証:zodやTypeBoxでプラグインオプションのランタイム検証を行い、開発段階で設定エラーを早期発見。

3. プラグインパフォーマンス監視$subscribe$onActionで実行時間を記録し、閾値超過時に警告を発行。

4. プラグイン間通信pinia._pで登録済みプラグインリストにアクセスし、プラグイン間の調整と依存関係を実現。

5. 条件付きプラグイン登録:ルートやユーザー権限などの条件に基づいてプラグインを動的に登録し、不要なランタイムオーバーヘッドを削減。

比較分析:Piniaプラグイン vs Vuexプラグイン vs 手動状態管理

特徴 Piniaプラグイン Vuexプラグイン 手動状態管理
型安全性 ✅ 完全TS対応 ❌ 追加設定が必要 ✅ 自己管理
プラグインAPIの簡潔さ ✅ 単一関数シグネチャ ❌ 複雑なsubscribe/action -
DevTools統合 ✅ ネイティブ対応 ✅ ネイティブ対応 ❌ 手動実装が必要
Store拡張能力 ✅ state/action注入 ❌ mutation/actionのみ ✅ 自由拡張
永続化実装 ✅ プラグインでワンクリック ❌ サードパーティライブラリが必要 ✅ 手動実装
SSR互換性 ✅ 組み込み対応 ⚠️ 注意が必要 ✅ 自己処理
学習曲線
テスト容易性 ✅ モックが容易 ❌ 分離が困難 ✅ 完全に制御可能

オンラインツールおすすめ

  1. JSONフォーマッター — Pinia永続化データのデバッグ時に、localStorage内のStoreスナップショットをフォーマットして確認
  2. ハッシュエンコードツール — 永続化キーに確定的ハッシュを生成し、アプリ間のキー競合を回避
  3. cURL→コード変換ツール — APIリクエストをPinia Storeのactionコードに素早く変換

まとめと展望

Piniaプラグインシステムは、Vue 3状態管理において深刻に過小評価されている機能です。5つのコアパターン——基本プラグイン構造、永続化、DevTools統合、ネットワークリクエスト状態、プラグイン構成フレームワーク——を通じて、Store内の共通ロジックを再利用可能なプラグインとして抽出し、ビジネスStoreをスリムに保つことができます。2026年、Pinia 3は公式永続化プラグインとより完全なプラグインライフサイクルAPIを導入する予定で、プラグインエコシステムはさらに成熟するでしょう。

関連記事

  1. Pinia Official Plugin Docs — Piniaプラグイン公式ドキュメント
  2. pinia-plugin-persistedstate — コミュニティ永続化プラグイン参考実装
  3. Vue 3 Design Patterns — Vue 3デザインパターンとベストプラクティス
  4. TypeScript Module Augmentation — Pinia型の拡張方法
  5. Pinia 3 Roadmap — 将来のPiniaバージョン計画ディスカッション

ブラウザローカルツールを無料で試す →

#Pinia插件系统#Vue3状态管理#Store插件#持久化插件#2026#前端工程