Vue3 Pinia状態管理:Store設計から永続化までの6つのエンタープライズベストプラクティス
またVue3プロジェクトの状態管理が制御不能に
コンポーネントAがユーザー情報を変更、コンポーネントBが更新されない;ログイン状態がlocalStorageに保存され、XSSで全て奪われる;Storeが大きくなり1ファイル3000行;SSRでhydration mismatchが画面いっぱいに。VuexからPiniaに移行して軽くなればいいと思ったら、別の落とし穴にハマるだけ。
Vue3 Pinia状態管理はいくつかStoreを定義すれば終わりではない。Storeをどう分割するか?状態をどう永続化するか?プラグインをどう設計するか?SSRにどう対応するか?TypeScriptをどう統合するか?これらを理解しないと、Piniaは「軽量版Vuex」にすぎず、同じ落とし穴に落ちる。
本記事は6つのエンタープライズベストプラクティスから出発し、Store設計→プラグインシステム→状態永続化→SSR互換性→TypeScript統合→パフォーマンス最適化のフルパイプラインを実践する。
Piniaコア概念
| 概念 | 説明 |
|---|---|
| defineStore | Store定義API、Options APIとComposition APIの2スタイルをサポート |
| useStore | Store使用用のHook関数、コンポーネント内で呼び出しStoreインスタンスを取得 |
| State | Storeの状態データ、コンポーネントのdataに類似 |
| Getters | Storeの算出プロパティ、コンポーネントのcomputedに類似 |
| Actions | Storeのメソッド、同期/非同期対応、コンポーネントのmethodsに類似 |
| Plugins | Piniaプラグインシステム、Store機能拡張用(永続化、ログ、同期等) |
| $subscribe | State変更の監視、Vuexのsubscribeに類似 |
| $onAction | Action呼び出しの監視、VuexのsubscribeActionに類似 |
Pinia vs Vuex
Piniaの利点:
1. 完全なTypeScriptサポート、手動型宣言不要
2. Mutationsを削除、Actionsが同期/非同期両対応
3. 複数Storeインスタンス対応、自然なコード分割
4. 軽量、gzip後約1KB
5. Composition APIスタイル対応、より柔軟
6. ネイティブDevToolsサポート
Vuexの欠点:
1. TypeScriptサポートが不十分、大量の型宣言が必要
2. MutationsとActionsの責任が混在
3. 単一Storeでファイルが肥大化
4. バンドルサイズが大きい
問題分析:Pinia状態管理の5つの課題
- Store分割粒度:細かすぎるとStore爆発、粗すぎると責任不明確
- 状態永続化のセキュリティ:localStorageに平文で機密データを保存するとXSSリスク
- SSR Hydration:サーバーとクライアントの状態不一致でhydrationエラー
- クロスStore通信:Store間で状態を共有しActionを呼び出す方法
- TypeScript型安全性:State/Getters/Actionsの完全な型推導を得る方法
ステップバイステップ:6つのエンタープライズベストプラクティス
プラクティス1:Storeモジュラー設計
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', () => {
const userInfo = ref<UserInfo | null>(null)
const token = ref<string>('')
const isLoggedIn = computed(() => !!token.value)
const userDisplayName = computed(() => userInfo.value?.displayName ?? 'Guest')
async function login(credentials: LoginCredentials) {
const response = await authApi.login(credentials)
token.value = response.token
userInfo.value = response.user
return response
}
function logout() {
token.value = ''
userInfo.value = null
}
async function fetchProfile() {
const profile = await userApi.getProfile()
userInfo.value = profile
return profile
}
return {
userInfo,
token,
isLoggedIn,
userDisplayName,
login,
logout,
fetchProfile,
}
})
export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([])
const totalAmount = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
)
const itemCount = computed(() =>
items.value.reduce((sum, item) => sum + item.quantity, 0)
)
function addItem(product: Product, quantity: number = 1) {
const existing = items.value.find(item => item.productId === product.id)
if (existing) {
existing.quantity += quantity
} else {
items.value.push({
productId: product.id,
name: product.name,
price: product.price,
quantity,
})
}
}
function removeItem(productId: string) {
items.value = items.value.filter(item => item.productId !== productId)
}
function clearCart() {
items.value = []
}
return {
items,
totalAmount,
itemCount,
addItem,
removeItem,
clearCart,
}
})
export const useAppStore = defineStore('app', () => {
const theme = ref<'light' | 'dark'>('light')
const sidebarCollapsed = ref(false)
const locale = ref<string>('ja')
const loading = ref(false)
function toggleTheme() {
theme.value = theme.value === 'light' ? 'dark' : 'light'
}
function toggleSidebar() {
sidebarCollapsed.value = !sidebarCollapsed.value
}
function setLocale(newLocale: string) {
locale.value = newLocale
}
return {
theme,
sidebarCollapsed,
locale,
loading,
toggleTheme,
toggleSidebar,
setLocale,
}
})
プラクティス2:Piniaプラグインシステム
import type { PiniaPluginContext } from 'pinia'
interface PiniaPluginOptions {
enableLog?: boolean
enablePersistence?: boolean
persistenceConfig?: PersistenceConfig
}
interface PersistenceConfig {
key?: string
storage?: Storage
paths?: string[]
encrypt?: boolean
}
function piniaLoggerPlugin(context: PiniaPluginContext) {
context.store.$onAction(({ name, args, after, onError }) => {
const startTime = Date.now()
console.log(`[${context.store.$id}] Action "${name}" started`, args)
after((result) => {
const duration = Date.now() - startTime
console.log(`[${context.store.$id}] Action "${name}" completed in ${duration}ms`, result)
})
onError((error) => {
const duration = Date.now() - startTime
console.error(`[${context.store.$id}] Action "${name}" failed in ${duration}ms`, error)
})
})
}
function piniaResetPlugin(context: PiniaPluginContext) {
const initialState = JSON.parse(JSON.stringify(context.store.$state))
context.store.$reset = () => {
context.store.$patch(JSON.parse(JSON.stringify(initialState)))
}
}
function createPiniaPlugins(options: PiniaPluginOptions = {}) {
return [
piniaLoggerPlugin,
piniaResetPlugin,
]
}
export { createPiniaPlugins, piniaLoggerPlugin, piniaResetPlugin }
export type { PiniaPluginOptions, PersistenceConfig }
プラクティス3:安全な状態永続化
import type { PiniaPluginContext } from 'pinia'
interface SecurePersistenceOptions {
key: string
storage?: Storage
paths?: string[]
encrypt?: boolean
encryptFn?: (data: string) => string
decryptFn?: (data: string) => string
}
function simpleEncrypt(data: string): string {
return btoa(encodeURIComponent(data))
}
function simpleDecrypt(data: string): string {
return decodeURIComponent(atob(data))
}
function createSecurePersistencePlugin(options: SecurePersistenceOptions) {
const {
key,
storage = localStorage,
paths,
encrypt = false,
encryptFn = simpleEncrypt,
decryptFn = simpleDecrypt,
} = options
return (context: PiniaPluginContext) => {
const storedData = storage.getItem(key)
if (storedData) {
try {
const parsed = JSON.parse(encrypt ? decryptFn(storedData) : storedData)
if (paths && paths.length > 0) {
const filteredState: Record<string, any> = {}
for (const path of paths) {
if (parsed[path] !== undefined) {
filteredState[path] = parsed[path]
}
}
context.store.$patch(filteredState)
} else {
context.store.$patch(parsed)
}
} catch (e) {
console.error(`[Pinia Persistence] Failed to parse stored data for "${key}":`, e)
}
}
context.store.$subscribe((mutation, state) => {
try {
let dataToStore: Record<string, any>
if (paths && paths.length > 0) {
dataToStore = {}
for (const path of paths) {
if ((state as any)[path] !== undefined) {
dataToStore[path] = (state as any)[path]
}
}
} else {
dataToStore = { ...state }
}
const serialized = JSON.stringify(dataToStore)
storage.setItem(key, encrypt ? encryptFn(serialized) : serialized)
} catch (e) {
console.error(`[Pinia Persistence] Failed to persist state for "${key}":`, e)
}
})
}
}
const userPersistence = createSecurePersistencePlugin({
key: 'app_user_store',
paths: ['token', 'userInfo'],
encrypt: true,
})
const appPersistence = createSecurePersistencePlugin({
key: 'app_settings_store',
paths: ['theme', 'locale', 'sidebarCollapsed'],
})
export { createSecurePersistencePlugin, userPersistence, appPersistence }
プラクティス4:SSR互換性
import { createPinia, defineStore } from 'pinia'
import type { Pinia } from 'pinia'
export function createSSRPinia(): Pinia {
const pinia = createPinia()
if (import.meta.server) {
pinia.use(({ store }) => {
store.$onAction(({ after }) => {
after(() => {
console.log(`[SSR] Action completed in store: ${store.$id}`)
})
})
})
}
return pinia
}
export const useUserStore = defineStore('user', () => {
const userInfo = ref<UserInfo | null>(null)
const token = ref<string>('')
async function hydrate(serverState: { userInfo: UserInfo | null; token: string }) {
userInfo.value = serverState.userInfo
token.value = serverState.token
}
return { userInfo, token, hydrate }
})
export function useSSRInitialState() {
if (import.meta.client) {
const initialState = (window as any).__PINIA_STATE__ || {}
return initialState
}
return {}
}
export function serializePiniaState(pinia: Pinia): Record<string, any> {
const state: Record<string, any> = {}
for (const [id, store] of pinia.state.value) {
state[id] = store
}
return state
}
プラクティス5:完全なTypeScript型推導
import { defineStore } from 'pinia'
interface UserInfo {
id: string
username: string
displayName: string
email: string
avatar: string
roles: string[]
}
interface LoginCredentials {
username: string
password: string
rememberMe?: boolean
}
interface LoginResponse {
token: string
user: UserInfo
expiresIn: number
}
interface UserPreferences {
theme: 'light' | 'dark'
language: string
notifications: boolean
}
export const useTypedUserStore = defineStore('typed-user', {
state: (): {
userInfo: UserInfo | null
token: string
lastLoginAt: Date | null
preferences: UserPreferences
} => ({
userInfo: null,
token: '',
lastLoginAt: null,
preferences: {
theme: 'light',
language: 'ja',
notifications: true,
},
}),
getters: {
isLoggedIn: (state): boolean => !!state.token,
userDisplayName: (state): string => state.userInfo?.displayName ?? 'Guest',
userRoles: (state): string[] => state.userInfo?.roles ?? [],
isAdmin: (state): boolean => state.userInfo?.roles.includes('admin') ?? false,
},
actions: {
async login(credentials: LoginCredentials): Promise<LoginResponse> {
const response = await authApi.login(credentials)
this.token = response.token
this.userInfo = response.user
this.lastLoginAt = new Date()
return response
},
logout(): void {
this.token = ''
this.userInfo = null
this.lastLoginAt = null
},
async updateProfile(data: Partial<UserInfo>): Promise<UserInfo> {
const updated = await userApi.updateProfile(data)
this.userInfo = { ...this.userInfo!, ...updated }
return updated
},
updatePreferences(prefs: Partial<UserPreferences>): void {
this.preferences = { ...this.preferences, ...prefs }
},
},
})
プラクティス6:パフォーマンス最適化——Storeレイジーロード
import { defineStore } from 'pinia'
const storeCache = new Map<string, any>()
export function useLazyStore<T>(storeName: string, storeFactory: () => T): T {
if (!storeCache.has(storeName)) {
storeCache.set(storeName, storeFactory())
}
return storeCache.get(storeName) as T
}
export const useAdminStore = defineStore('admin', () => {
const dashboardData = ref<DashboardData | null>(null)
const userManagementList = ref<UserManagementItem[]>([])
const systemLogs = ref<SystemLog[]>([])
async function fetchDashboard() {
dashboardData.value = await adminApi.getDashboard()
}
async function fetchUsers(page: number, pageSize: number) {
const response = await adminApi.getUsers({ page, pageSize })
userManagementList.value = response.items
return response
}
function $dispose() {
dashboardData.value = null
userManagementList.value = []
systemLogs.value = []
}
return {
dashboardData,
userManagementList,
systemLogs,
fetchDashboard,
fetchUsers,
$dispose,
}
})
export function createStoreDisposer() {
const activeStores = new Set<string>()
return {
track(storeId: string) {
activeStores.add(storeId)
},
dispose(storeId: string) {
const store = getStoreById(storeId)
if (store && typeof store.$dispose === 'function') {
store.$dispose()
}
activeStores.delete(storeId)
},
disposeAll() {
for (const storeId of activeStores) {
this.dispose(storeId)
}
activeStores.clear()
},
}
}
落とし穴ガイド
落とし穴1:Storeの分割代入でリアクティビティが失われる
// ❌ 誤り:直接分割代入でリアクティビティが失われる
const { userInfo, token } = useUserStore()
// ✅ 正しい:storeToRefsでリアクティビティを維持
import { storeToRefs } from 'pinia'
const userStore = useUserStore()
const { userInfo, token } = storeToRefs(userStore)
const { login, logout } = userStore // actionsはstoreToRefs不要
落とし穴2:Store外でStateを直接変更
// ❌ 誤り:Stateを直接変更、変更を追跡できない
const userStore = useUserStore()
userStore.token = 'new-token'
// ✅ 正しい:$patchで一括更新またはActionで変更
const userStore = useUserStore()
userStore.$patch({ token: 'new-token' })
// またはAction経由
userStore.updateToken('new-token')
落とし穴3:機密データを暗号化せずに永続化
// ❌ 誤り:tokenをlocalStorageに平文保存
localStorage.setItem('token', token) // XSSで読み取り可能
// ✅ 正しい:機密データを暗号化またはhttpOnly Cookieを使用
const encryptedToken = simpleEncrypt(token)
localStorage.setItem('token', encryptedToken)
// ベスト:tokenをhttpOnly Cookieに保存、フロントエンドは触れない
落とし穴4:SSRでsetup外でStoreを使用
// ❌ 誤り:コンポーネントsetup外でuseStoreを呼び出し
const userStore = useUserStore() // モジュールトップレベルで呼び出し、piniaインスタンスが存在しない可能性
export default defineComponent({
setup() {
// ここで安全に使用可能
}
})
// ✅ 正しい:setup関数内でuseStoreを呼び出し
export default defineComponent({
setup() {
const userStore = useUserStore()
return { userStore }
}
})
落とし穴5:Storeの循環依存
// ❌ 誤り:Store AがStore Bに依存、Store BがStore Aに依存
// storeA.ts
export const useStoreA = defineStore('a', () => {
const storeB = useStoreB() // 循環依存!
})
// ✅ 正しい:Action内で依存Storeを取得、遅延評価
export const useStoreA = defineStore('a', () => {
function doSomething() {
const storeB = useStoreB() // Action内で取得
storeB.someAction()
}
return { doSomething }
})
エラートラブルシューティング
| # | エラーメッセージ | 原因 | 解決方法 |
|---|---|---|---|
| 1 | getActivePinia was called with no active Pinia |
piniaインスタンス作成前にuseStoreを呼び出し | createPinia()とapp.use(pinia)の後にuseStoreを呼び出す |
| 2 | Hydration mismatch |
SSRサーバー状態がクライアントと不一致 | SSR状態注入を確認、__PINIA_STATE__を使用 |
| 3 | Cannot read property of null |
Store分割代入でリアクティビティが消失 | storeToRefsを直接分割代入の代わりに使用 |
| 4 | Maximum call stack size exceeded |
Storeの循環依存 | Action内で依存Storeを取得、遅延評価 |
| 5 | localStorage is not defined |
SSRでブラウザAPIにアクセス | import.meta.clientガードまたは条件判定を使用 |
| 6 | Pinia: store "$id" already exists |
同名Storeの重複登録 | defineStoreのID重複を確認 |
| 7 | Computed is read-only |
computed(getter)値の変更を試行 | $patchまたはActionでStateを変更、getterは読み取り専用 |
| 8 | $subscribe callback not triggered |
$subscribeが発火しない | $patchまたは直接代入でState変更、オブジェクト置換ではないことを確認 |
| 9 | Type 'X' is not assignable to type 'Y' |
TypeScript型不一致 | State/Actionの戻り値型がインターフェース定義と一致することを確認 |
| 10 | Plugin did not return a function |
Piniaプラグインフォーマットエラー | プラグインは関数を返すか正しいプラグインシグネチャを使用 |
高度な最適化
1. Storeコンポジションパターン
import { defineStore } from 'pinia'
export const useOrderStore = defineStore('order', () => {
const userStore = useUserStore()
const cartStore = useCartStore()
const appStore = useAppStore()
const orders = ref<Order[]>([])
const currentOrder = ref<Order | null>(null)
const orderSummary = computed(() => ({
userName: userStore.userDisplayName,
items: cartStore.items,
totalAmount: cartStore.totalAmount,
currency: appStore.locale === 'ja' ? 'JPY' : 'USD',
}))
async function placeOrder(): Promise<Order> {
if (!userStore.isLoggedIn) {
throw new Error('User must be logged in to place order')
}
const order: Order = {
id: generateOrderId(),
userId: userStore.userInfo!.id,
items: [...cartStore.items],
totalAmount: cartStore.totalAmount,
status: 'pending',
createdAt: new Date(),
}
const created = await orderApi.create(order)
orders.value.push(created)
currentOrder.value = created
cartStore.clearCart()
return created
}
return {
orders,
currentOrder,
orderSummary,
placeOrder,
}
})
2. オフライン状態同期
import { defineStore } from 'pinia'
interface OfflineAction {
id: string
storeId: string
actionName: string
args: any[]
timestamp: number
retryCount: number
}
export const useOfflineStore = defineStore('offline', () => {
const pendingActions = ref<OfflineAction[]>([])
const isOnline = ref(navigator.onLine)
const isSyncing = ref(false)
function enqueueAction(storeId: string, actionName: string, args: any[]) {
const action: OfflineAction = {
id: crypto.randomUUID(),
storeId,
actionName,
args,
timestamp: Date.now(),
retryCount: 0,
}
pendingActions.value.push(action)
persistPendingActions()
}
async function syncPendingActions(): Promise<void> {
if (isSyncing.value || !isOnline.value || pendingActions.value.length === 0) return
isSyncing.value = true
const actions = [...pendingActions.value]
for (const action of actions) {
try {
const store = getStoreById(action.storeId)
if (store && typeof store[action.actionName] === 'function') {
await store[action.actionName](...action.args)
pendingActions.value = pendingActions.value.filter(a => a.id !== action.id)
}
} catch (error) {
const existing = pendingActions.value.find(a => a.id === action.id)
if (existing) {
existing.retryCount++
if (existing.retryCount >= 3) {
pendingActions.value = pendingActions.value.filter(a => a.id !== action.id)
}
}
}
}
persistPendingActions()
isSyncing.value = false
}
function persistPendingActions() {
localStorage.setItem('offline_actions', JSON.stringify(pendingActions.value))
}
function restorePendingActions() {
const stored = localStorage.getItem('offline_actions')
if (stored) {
try {
pendingActions.value = JSON.parse(stored)
} catch {
pendingActions.value = []
}
}
}
if (import.meta.client) {
window.addEventListener('online', () => {
isOnline.value = true
syncPendingActions()
})
window.addEventListener('offline', () => {
isOnline.value = false
})
restorePendingActions()
}
return {
pendingActions,
isOnline,
isSyncing,
enqueueAction,
syncPendingActions,
}
})
3. Storeテストユーティリティ
import { setActivePinia, createPinia } from 'pinia'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { useUserStore } from '@/stores/user'
function createTestingPinia() {
const pinia = createPinia()
setActivePinia(pinia)
return pinia
}
describe('useUserStore', () => {
beforeEach(() => {
createTestingPinia()
})
it('should start with empty state', () => {
const store = useUserStore()
expect(store.userInfo).toBeNull()
expect(store.token).toBe('')
expect(store.isLoggedIn).toBe(false)
})
it('should update state after login', async () => {
const store = useUserStore()
const mockLogin = vi.fn().mockResolvedValue({
token: 'test-token',
user: { id: '1', username: 'test', displayName: 'Test User', email: 'test@test.com', avatar: '', roles: ['user'] },
expiresIn: 3600,
})
vi.stubGlobal('authApi', { login: mockLogin })
await store.login({ username: 'test', password: 'password' })
expect(store.token).toBe('test-token')
expect(store.isLoggedIn).toBe(true)
expect(store.userDisplayName).toBe('Test User')
})
it('should clear state after logout', () => {
const store = useUserStore()
store.$patch({
token: 'test-token',
userInfo: { id: '1', username: 'test', displayName: 'Test', email: 't@t.com', avatar: '', roles: [] },
})
store.logout()
expect(store.token).toBe('')
expect(store.userInfo).toBeNull()
expect(store.isLoggedIn).toBe(false)
})
})
比較分析
| 次元 | Pinia | Vuex 4 | Vuex 5(RFC) | Zustand(React) | Redux Toolkit |
|---|---|---|---|---|---|
| TypeScript | ✅ネイティブ | ⚠️手動 | ✅ | ✅ | ✅ |
| Mutations | ❌削除 | ✅必須 | ❌削除 | ❌ | ❌(reducers) |
| コード分割 | ✅ネイティブ | ⚠️動的登録 | ✅ | ✅ | ⚠️設定必要 |
| SSR | ✅ネイティブ | ⚠️設定必要 | ✅ | ⚠️設定必要 | ⚠️設定必要 |
| DevTools | ✅ | ✅ | ✅ | ⚠️プラグイン必要 | ✅ |
| バンドルサイズ | ~1KB | ~6KB | ~2KB | ~1.5KB | ~11KB |
| 学習曲線 | 低 | 中 | 低 | 低 | 中 |
| プラグインエコシステム | 中 | 豊富 | 中 | 少ない | 豊富 |
まとめ:Piniaは「軽量版Vuex」ではなく「再考された状態管理」です。6つのエンタープライズベストプラクティスの核心:1)Storeはビジネスドメインで分割、技術レイヤーでない;2)プラグインシステムで横断的関心事を統一処理(ログ、永続化、リセット);3)永続化はセキュリティを考慮——機密データは暗号化またはhttpOnly Cookie;4)SSRの鍵は状態注入とhydrationタイミング;5)完全なTypeScript型推導はインターフェース定義から始まる;6)パフォーマンス最適化の核心はレイジーロードとオンデマンド解放。覚えておこう:良い状態管理は「何でも保存できる」ではなく「保存すべきものを正しく保存し、保存すべきでないものは保存しない」こと。
オンラインツール推奨
- JSONフォーマッター:/ja/json/format
- Base64エンコード/デコード:/ja/encode/base64
- Hash計算:/ja/encode/hash
- JWTデコード:/ja/encode/jwt-decode
ブラウザローカルツールを無料で試す →