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互換性 | ✅ 組み込み対応 | ⚠️ 注意が必要 | ✅ 自己処理 |
| 学習曲線 | 低 | 中 | 低 |
| テスト容易性 | ✅ モックが容易 | ❌ 分離が困難 | ✅ 完全に制御可能 |
オンラインツールおすすめ
- JSONフォーマッター — Pinia永続化データのデバッグ時に、localStorage内のStoreスナップショットをフォーマットして確認
- ハッシュエンコードツール — 永続化キーに確定的ハッシュを生成し、アプリ間のキー競合を回避
- cURL→コード変換ツール — APIリクエストをPinia Storeのactionコードに素早く変換
まとめと展望
Piniaプラグインシステムは、Vue 3状態管理において深刻に過小評価されている機能です。5つのコアパターン——基本プラグイン構造、永続化、DevTools統合、ネットワークリクエスト状態、プラグイン構成フレームワーク——を通じて、Store内の共通ロジックを再利用可能なプラグインとして抽出し、ビジネスStoreをスリムに保つことができます。2026年、Pinia 3は公式永続化プラグインとより完全なプラグインライフサイクルAPIを導入する予定で、プラグインエコシステムはさらに成熟するでしょう。
関連記事
- Pinia Official Plugin Docs — Piniaプラグイン公式ドキュメント
- pinia-plugin-persistedstate — コミュニティ永続化プラグイン参考実装
- Vue 3 Design Patterns — Vue 3デザインパターンとベストプラクティス
- TypeScript Module Augmentation — Pinia型の拡張方法
- Pinia 3 Roadmap — 将来のPiniaバージョン計画ディスカッション
ブラウザローカルツールを無料で試す →