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つの課題

  1. Store分割粒度:細かすぎるとStore爆発、粗すぎると責任不明確
  2. 状態永続化のセキュリティ:localStorageに平文で機密データを保存するとXSSリスク
  3. SSR Hydration:サーバーとクライアントの状態不一致でhydrationエラー
  4. クロスStore通信:Store間で状態を共有しActionを呼び出す方法
  5. 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)パフォーマンス最適化の核心はレイジーロードとオンデマンド解放。覚えておこう:良い状態管理は「何でも保存できる」ではなく「保存すべきものを正しく保存し、保存すべきでないものは保存しない」こと。


オンラインツール推奨

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

#Vue3#Pinia#状态管理#TypeScript#前端架构#2026#Store设计