3 min read
On this page

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 $patch for batch updates. Setting five properties individually triggers five reactive cycles. Use $patch to 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: true for 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

  • $subscribe watches all state changes. $onAction hooks into action calls for logging and error tracking.
  • Plugins extend every store. Use them for cross-cutting concerns like logging and persistence.
  • pinia-plugin-persistedstate handles localStorage/cookie sync. Use cookie storage for SSR.
  • $patch batches 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() in beforeEach. Use createTestingPinia for component tests with pre-populated state.