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
- Store granularity: Too fine causes Store explosion, too coarse causes unclear responsibilities
- State persistence security: Storing sensitive data in plaintext localStorage has XSS risks
- SSR Hydration: Server and client state mismatch causes hydration errors
- Cross-Store communication: How Stores share state and call each other's Actions
- 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."
Recommended Online Tools
- JSON Formatter: /en/json/format
- Base64 Encode/Decode: /en/encode/base64
- Hash Calculator: /en/encode/hash
- JWT Decode: /en/encode/jwt-decode
Try these browser-local tools — no sign-up required →