Advanced Pinia
Once you have stores handling basic state, the next questions are always the same: how do I persist state across reloads, subscribe to changes, extend stores with plugins, and handle SSR hydration? Pinia has answers for all of these, and most are simpler than you would expect.
Store Subscriptions
$subscribe watches for state changes. Unlike a Vue watch, it fires on any state mutation, including direct assignments, $patch, and $reset:
const cart = useCartStore()
cart.$subscribe((mutation, state) => {
// mutation.type: 'direct' | 'patch object' | 'patch function'
// mutation.storeId: 'cart'
localStorage.setItem('cart', JSON.stringify(state.items))
})
By default, subscriptions are bound to the component that creates them. To keep a subscription alive beyond the component lifecycle:
cart.$subscribe(callback, { detached: true })
Action Subscriptions
$onAction lets you hook into action calls. Useful for logging, analytics, or error tracking:
const auth = useAuthStore()
auth.$onAction(({ name, args, after, onError }) => {
const startTime = Date.now()
after((result) => {
console.log(`${name} completed in ${Date.now() - startTime}ms`)
})
onError((error) => {
console.error(`${name} failed:`, error)
reportToSentry(error, { action: name, args })
})
})
Pinia Plugins
Plugins extend every store in your app. They run once per store creation and can add state, actions, or side effects.
// plugins/pinia-logger.ts
import type { PiniaPlugin } from 'pinia'
export const piniaLogger: PiniaPlugin = ({ store }) => {
store.$subscribe((mutation) => {
console.log(`[${store.$id}] ${mutation.type}`, mutation.payload)
})
}
In Nuxt:
// plugins/pinia-logger.ts
export default defineNuxtPlugin(({ $pinia }) => {
$pinia.use(({ store }) => {
store.$subscribe((mutation) => {
if (import.meta.dev) {
console.log(`[${store.$id}]`, mutation.type)
}
})
})
})
Persisted State
The most common Pinia plugin is pinia-plugin-persistedstate, which syncs store state to localStorage, sessionStorage, or cookies:
npm install pinia-plugin-persistedstate
Enable persistence per store:
export const usePreferencesStore = defineStore('preferences', () => {
const locale = ref('en')
const theme = ref<'light' | 'dark'>('light')
const sidebarCollapsed = ref(false)
return { locale, theme, sidebarCollapsed }
}, {
persist: true,
})
For finer control:
export const useAuthStore = defineStore('auth', () => {
const user = ref(null)
const token = ref<string | null>(null)
const loginAttempts = ref(0)
return { user, token, loginAttempts }
}, {
persist: {
pick: ['token'], // Only persist the token
storage: localStorage,
},
})
For SSR apps (Nuxt), use cookie storage so the server can read persisted state:
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(null)
return { token }
}, {
persist: {
storage: piniaPluginPersistedstate.cookies(),
},
})
$patch for Batch Updates
$patch updates multiple state properties in a single reactive cycle:
const user = useUserStore()
// Object syntax
user.$patch({
name: 'Alice',
email: 'alice@example.com',
lastLogin: new Date(),
})
// Function syntax (for complex mutations)
user.$patch((state) => {
state.notifications = state.notifications.filter(n => !n.read)
state.unreadCount = state.notifications.length
})
The function syntax is preferred when you need to mutate arrays or perform conditional updates.
SSR Hydration
In Nuxt, Pinia handles SSR hydration automatically. State set during server-side rendering is serialized into the page payload and rehydrated on the client.
The one caveat: store state must be serializable. Dates become strings, functions are lost, and class instances are flattened. Stick to plain objects, arrays, strings, numbers, and booleans.
// This works with SSR
const user = ref({ name: 'Alice', createdAt: '2024-01-15' }) // string date
// This breaks SSR hydration
const user = ref({ name: 'Alice', createdAt: new Date() }) // Date object
Testing Stores
Use createPinia() in tests to isolate store state:
import { setActivePinia, createPinia } from 'pinia'
import { beforeEach, describe, it, expect } from 'vitest'
describe('useCartStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('adds items', () => {
const cart = useCartStore()
cart.addItem({ productId: '1', name: 'Widget', price: 999 })
expect(cart.items).toHaveLength(1)
})
})
For component tests with pre-populated state, use createTestingPinia:
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
const wrapper = mount(CartSummary, {
global: {
plugins: [
createTestingPinia({
initialState: {
cart: {
items: [{ productId: '1', name: 'Widget', price: 999, quantity: 2 }],
},
},
}),
],
},
})
Common Pitfalls
- Not using
$patchfor batch updates. Setting five properties individually triggers five reactive cycles. Use$patchto batch them. - Persisting sensitive data. Tokens in localStorage are accessible to any JavaScript on the page. For auth tokens, prefer httpOnly cookies.
- Non-serializable state with SSR. Dates, Maps, Sets, and class instances do not survive JSON serialization.
- Forgetting
detached: truefor global subscriptions. Without it, a subscription created in a component dies when that component unmounts. - Plugin ordering. Plugins run in registration order. If plugin B depends on state added by plugin A, register A first.
Key Takeaways
$subscribewatches all state changes.$onActionhooks into action calls for logging and error tracking.- Plugins extend every store. Use them for cross-cutting concerns like logging and persistence.
pinia-plugin-persistedstatehandles localStorage/cookie sync. Use cookie storage for SSR.$patchbatches multiple state updates into a single reactive cycle.- Pinia handles SSR hydration automatically in Nuxt, but store state must be JSON-serializable.
- Isolate tests with
createPinia()inbeforeEach. UsecreateTestingPiniafor component tests with pre-populated state.