Vue3 Pinia State Management: 6 Enterprise Best Practices from Store Design to Persistence

前端工程

Your Vue3 Project State Management Is Out of Control Again

Component A changes user info, Component B doesn't refresh; login state stored in localStorage gets wiped by XSS; Store grows to 3000 lines; SSR hydration mismatches everywhere. You migrated from Vuex to Pinia thinking lighter is better, but you're just tripping over different pitfalls.

Vue3 Pinia state management isn't just defining a few Stores. How to split Stores? How to persist state? How to design plugins? How to handle SSR? How to integrate TypeScript? Without understanding these, Pinia is just "lightweight Vuex" with the same pitfalls.

This article starts from 6 enterprise best practices and guides you through the full Store design → plugin system → state persistence → SSR compatibility → TypeScript integration → performance optimization pipeline.


Pinia Core Concepts

Concept Description
defineStore API for defining Stores, supports Options API and Composition API styles
useStore Hook function for using a Store, called in components to get Store instance
State Store's state data, similar to component's data
Getters Store's computed properties, similar to component's computed
Actions Store's methods, supports sync and async, similar to component's methods
Plugins Pinia plugin system for extending Store functionality (persistence, logging, sync)
$subscribe Listen to State changes, similar to Vuex's subscribe
$onAction Listen to Action calls, similar to Vuex's subscribeAction

Pinia vs Vuex

Pinia Advantages:
1. Full TypeScript support, no manual type declarations needed
2. Removes Mutations, Actions support both sync and async
3. Multiple Store instances, natural code splitting
4. Lightweight, ~1KB gzipped
5. Supports Composition API style, more flexible
6. Native DevTools support

Vuex Disadvantages:
1. Poor TypeScript support, requires extensive type declarations
2. Mutations and Actions have confusing responsibilities
3. Single Store leads to file bloat
4. Larger bundle size

Problem Analysis: 5 Major Challenges in Pinia State Management

  1. Store granularity: Too fine causes Store explosion, too coarse causes unclear responsibilities
  2. State persistence security: Storing sensitive data in plaintext localStorage has XSS risks
  3. SSR Hydration: Server and client state mismatch causes hydration errors
  4. Cross-Store communication: How Stores share state and call each other's Actions
  5. TypeScript type safety: How to get full type inference for State/Getters/Actions

Step-by-Step: 6 Enterprise Best Practices

Practice 1: Modular Store Design

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>('en')
  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,
  }
})

Practice 2: Pinia Plugin System

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 }

Practice 3: Secure State Persistence

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 }

Practice 4: SSR Compatibility

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
}

Practice 5: Full TypeScript Type Inference

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: 'en',
      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 }
    },
  },
})

Practice 6: Performance Optimization — Store Lazy Loading

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()
    },
  }
}

Pitfall Guide

Pitfall 1: Destructuring Store Loses Reactivity

// ❌ Wrong: direct destructuring loses reactivity
const { userInfo, token } = useUserStore()

// ✅ Correct: use storeToRefs to maintain reactivity
import { storeToRefs } from 'pinia'
const userStore = useUserStore()
const { userInfo, token } = storeToRefs(userStore)
const { login, logout } = userStore // actions don't need storeToRefs

Pitfall 2: Directly Modifying State Outside Store

// ❌ Wrong: directly modifying State, can't track changes
const userStore = useUserStore()
userStore.token = 'new-token'

// ✅ Correct: use $patch for batch updates or Action
const userStore = useUserStore()
userStore.$patch({ token: 'new-token' })
// or via Action
userStore.updateToken('new-token')

Pitfall 3: Persisting Sensitive Data Unencrypted

// ❌ Wrong: token stored in plaintext in localStorage
localStorage.setItem('token', token) // XSS can read it

// ✅ Correct: encrypt sensitive data or use httpOnly Cookie
const encryptedToken = simpleEncrypt(token)
localStorage.setItem('token', encryptedToken)
// Best: store token in httpOnly Cookie, frontend never touches it

Pitfall 4: Using Store Outside setup in SSR

// ❌ Wrong: calling useStore outside component setup
const userStore = useUserStore() // Called at module top level, pinia may not exist

export default defineComponent({
  setup() {
    // Safe to use here
  }
})

// ✅ Correct: call useStore inside setup function
export default defineComponent({
  setup() {
    const userStore = useUserStore()
    return { userStore }
  }
})

Pitfall 5: Store Circular Dependency

// ❌ Wrong: Store A depends on Store B, Store B depends on Store A
// storeA.ts
export const useStoreA = defineStore('a', () => {
  const storeB = useStoreB() // Circular dependency!
})

// ✅ Correct: get dependent Store inside Action, lazy evaluation
export const useStoreA = defineStore('a', () => {
  function doSomething() {
    const storeB = useStoreB() // Get inside Action
    storeB.someAction()
  }
  return { doSomething }
})

Error Troubleshooting

# Error Message Cause Solution
1 getActivePinia was called with no active Pinia useStore called before pinia instance created Ensure createPinia() and app.use(pinia) before useStore
2 Hydration mismatch SSR server state differs from client Ensure SSR state injection, use PINIA_STATE
3 Cannot read property of null Destructured Store lost reactivity Use storeToRefs instead of direct destructuring
4 Maximum call stack size exceeded Store circular dependency Get dependent Store inside Action, lazy evaluation
5 localStorage is not defined Accessing browser API in SSR Use import.meta.client guard or conditional check
6 Pinia: store "$id" already exists Duplicate Store with same ID registered Check defineStore IDs for duplicates
7 Computed is read-only Attempting to modify computed (getter) value Use $patch or Action to modify State, getters are read-only
8 $subscribe callback not triggered $subscribe not firing Ensure State changes via $patch or direct assignment, not object replacement
9 Type 'X' is not assignable to type 'Y' TypeScript type mismatch Ensure State/Action return types match interface definitions
10 Plugin did not return a function Pinia plugin format error Plugin must return function or use correct plugin signature

Advanced Optimization

1. Store Composition Pattern

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 === 'en' ? 'USD' : 'CNY',
  }))

  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. Offline State Sync

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 Testing Utilities

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)
  })
})

Comparison Analysis

Dimension Pinia Vuex 4 Vuex 5(RFC) Zustand(React) Redux Toolkit
TypeScript ✅ Native ⚠️ Manual
Mutations ❌ Removed ✅ Required ❌ Removed ❌ (reducers)
Code Splitting ✅ Native ⚠️ Dynamic reg ⚠️ Config needed
SSR ✅ Native ⚠️ Config needed ⚠️ Config needed ⚠️ Config needed
DevTools ⚠️ Plugin needed
Bundle Size ~1KB ~6KB ~2KB ~1.5KB ~11KB
Learning Curve Low Medium Low Low Medium
Plugin Ecosystem Medium Rich Medium Sparse Rich

Summary: Pinia isn't "lightweight Vuex" — it's "rethought state management." The core of 6 enterprise best practices: 1) Split Stores by business domain, not technical layers; 2) Plugin system handles cross-cutting concerns (logging, persistence, reset); 3) Persistence must consider security — encrypt sensitive data or use httpOnly Cookies; 4) SSR's key is state injection and hydration timing; 5) Full TypeScript inference starts from interface definitions; 6) Performance optimization core is lazy loading and on-demand disposal. Remember: good state management isn't "store everything," it's "store what you should, don't store what you shouldn't."


Try these browser-local tools — no sign-up required →

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