Pinia in Vue 3: Setup vs Option Stores, storeToRefs, Stores Outside Components, SSR and Testing
Key takeaways
Pinia's API fits on one page. The trouble in real apps comes from a few behaviours: setup and option stores differ in subtle ways, destructuring a store breaks reactivity, stores used outside components need an active Pinia, and SSR shares state between requests unless you set it up carefully. This guide covers each one, with the actual error messages.
Pinia is the official state management library for Vue. It replaced Vuex as the recommendation, and it is much smaller in API terms: no mutations, no namespaced modules, just stores with state, getters and actions. You can learn that part in an afternoon. This article covers what the short tour skips: the handful of behaviours that cause bugs in real applications, with the error messages you see when you hit them.
Versions: the code below was run against Pinia 4.0 and Vue 3.5. Pinia 4 is ESM-only and requires Vue 3.5.11 or later. Pinia 3 had already dropped Vue 2.
Setup stores vs option stores
Pinia offers two ways to define a store, and they are not just two syntaxes for the same thing.
// Option store: structured like Vuex / the Options API
export const useCounterStore = defineStore('counter', {
state: () => ({ count: 0, items: [] as string[] }),
getters: {
double: (state) => state.count * 2,
},
actions: {
increment() {
this.count++
},
},
})
// Setup store: a function like <script setup>
export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([])
const total = computed(() => items.value.reduce((sum, i) => sum + i.price * i.qty, 0))
function add(item: CartItem) {
items.value.push(item)
}
return { items, total, add }
})
In a setup store, refs become state, computeds become getters and functions become actions. The differences that matter in practice:
| Option store | Setup store | |
|---|---|---|
$reset() | Built in | Throws; write your own |
Composables, watch, inject inside the store | Awkward | Natural |
| TypeScript inference | Good, but this in getters sometimes needs an explicit return type | Plain function inference |
| What counts as state | Everything returned by state() | Only refs you return |
That last row causes a subtle bug. In a setup store, a ref you create but do not return is private to the closure. It works in the browser, but Pinia does not know it exists. DevTools do not show it, $patch and $subscribe ignore it, and during SSR it is not serialized, so the client starts with the initial value again. Pinia’s documentation says it plainly: return all state properties from a setup store. If you want something truly private, accept those trade-offs knowingly.
$reset only exists on option stores
🍍: Store "cart" is built using the setup syntax and does not implement $reset().
An option store can reset because Pinia can call state() again. A setup store has no separate state factory. Write a reset action:
export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([])
const coupon = ref<string | null>(null)
function $reset() {
items.value = []
coupon.value = null
}
return { items, coupon, $reset }
})
Resetting is the usual logout requirement (“clear every store”). With setup stores, each store needs its own reset action. Forgetting one is how a previous user’s cart shows up after a new login.
Destructuring a store loses reactivity
This is the most common Pinia bug:
<script setup lang="ts">
const cart = useCartStore()
const { items, total } = cart // plain values, frozen at this moment
cart.add(newItem)
// items/total here do not change; the template shows stale values
</script>
The store is a reactive() object. Destructuring reads each property once and copies the value into a local variable, the same thing that happens when you destructure any reactive object in Vue. The fix is storeToRefs, which returns refs for state and getters:
import { storeToRefs } from 'pinia'
const cart = useCartStore()
const { items, total } = storeToRefs(cart) // Ref<CartItem[]>, ComputedRef<number>
const { add } = cart // actions are fine to destructure
storeToRefs skips actions on purpose. Actions are bound to the store, so const { add } = cart works. Use plain toRefs() on a store and you get refs for everything, including internal properties, which is why Pinia provides its own helper.
In templates you rarely need either. cart.total in the template stays reactive because it reads through the store every render.
Using a store outside a component
[🍍]: "getActivePinia()" was called but there was no active Pinia.
Are you trying to use a store before calling "app.use(pinia)"?
Inside setup(), useXStore() finds the Pinia instance through the component’s inject. Outside a component there is no component to inject from, so Pinia falls back to the “active” instance that app.use(pinia) sets. If your code runs before that, it fails.
The usual cause is a module-level call:
// router.ts
const auth = useAuthStore() // runs on import, before app.use(pinia)
router.beforeEach((to) => {
if (to.meta.requiresAuth && !auth.isLoggedIn) return '/login'
})
Move the call into the function that needs it. By the time the guard first runs, the app is set up:
router.beforeEach((to) => {
const auth = useAuthStore()
if (to.meta.requiresAuth && !auth.isLoggedIn) return '/login'
})
The same applies to Axios interceptors and other API client modules. For code that runs where no active Pinia is guaranteed, pass the instance explicitly: useAuthStore(pinia).
On the server this is more serious than an error message. During SSR, a store captured at module level belongs to whichever request imported the module first. It can then leak one user’s state into another user’s response. Always get stores inside request-scoped code.
Async actions and error handling
Actions are ordinary functions, async included. Pinia does not track loading or error state for you, so model it explicitly:
export const useProductStore = defineStore('products', () => {
const products = ref<Product[]>([])
const status = ref<'idle' | 'loading' | 'error'>('idle')
const error = ref<string | null>(null)
async function fetchProducts() {
status.value = 'loading'
error.value = null
try {
const res = await fetch('/api/products')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
products.value = await res.json()
status.value = 'idle'
} catch (e) {
error.value = e instanceof Error ? e.message : String(e)
status.value = 'error'
}
}
return { products, status, error, fetchProducts }
})
Two details matter. First, fetch does not reject on a 404 or 500, so check res.ok, or an error page’s JSON ends up stored as your product list. Second, if a user triggers fetchProducts twice quickly, the slower response wins regardless of order. For search-as-you-type, keep a request counter or an AbortController and ignore stale responses.
The trap I keep seeing is a store that becomes a general data cache. Every API response goes into Pinia “so components can share it”, and soon the store holds stale server data with hand-rolled loading flags and no invalidation. Pinia is good at client state: the cart, the current user, UI preferences, a multi-step form. For server data that needs caching, refetching and deduplication, a query library such as TanStack Query for Vue handles those problems directly, and it works fine alongside Pinia.
$patch, $subscribe and $onAction
const cart = useCartStore()
// Several changes grouped into one mutation entry
cart.$patch({ coupon: 'SPRING', note: '' })
// Function form for arrays and conditional logic
cart.$patch((state) => {
state.items.push(item)
state.coupon = null
})
Vue already batches DOM updates, so $patch is not mainly a render optimisation. It groups the changes into one entry in DevTools and one $subscribe notification, instead of one per assignment. The object form merges objects deeply but replaces arrays, so use the function form for array changes.
// React to state changes (e.g. saving a draft)
cart.$subscribe((mutation, state) => {
localStorage.setItem('cart', JSON.stringify(state.items))
})
// Observe actions: logging, analytics, error reporting
cart.$onAction(({ name, args, after, onError }) => {
const start = performance.now()
after(() => console.debug(`${name} took ${performance.now() - start}ms`))
onError((err) => reportError(err, { action: name, args }))
})
A $subscribe or $onAction call made inside a component is removed automatically when that component unmounts. That is usually what you want, but it surprises people who register an app-wide listener from a page component and see it stop firing after navigating away from that page. Pass { detached: true } as the second argument to keep it alive.
Plugins and persistence
A plugin is a function that Pinia calls once for every store it creates. It can add properties, wrap actions or subscribe to changes:
import type { PiniaPluginContext } from 'pinia'
export function persistPlugin({ store }: PiniaPluginContext) {
const key = `pinia:${store.$id}`
const saved = localStorage.getItem(key)
if (saved) store.$patch(JSON.parse(saved))
store.$subscribe((_mutation, state) => {
localStorage.setItem(key, JSON.stringify(state))
}, { detached: true })
}
const pinia = createPinia()
pinia.use(persistPlugin)
This minimal version shows the mechanism and its problems. It persists every store, including ones holding tokens or large lists. It crashes during SSR, where localStorage does not exist. It loads stale data after you change a store’s shape. And a Set, Map or Date in state does not survive JSON.stringify. The maintained pinia-plugin-persistedstate package handles per-store opt-in, choosing which paths to persist, storage options and Nuxt. Prefer it over a home-grown version, and still think twice before persisting anything sensitive to localStorage.
SSR: serialize state once, hydrate before any store is used
With server rendering, the server runs your stores, renders HTML, and must hand the resulting state to the client so it does not refetch or flash initial values. Nuxt does this for you through @pinia/nuxt. In a custom Vite SSR setup, you do it yourself:
// server: after rendering
import { stringify } from 'devalue'
const html = await renderToString(app)
const state = stringify(pinia.state.value)
// embed `state` in the page, e.g. <script>window.__PINIA__ = ...</script>
// client: before app.mount() and before any useXStore() call
import { parse } from 'devalue'
pinia.state.value = parse(window.__PINIA__)
Pinia’s documentation recommends a serializer like devalue rather than raw JSON.stringify inside a <script> tag. JSON does not escape </script>, so user-controlled text in state can become an XSS vector. JSON also loses Date, Map and undefined.
Two further SSR rules:
- Create a new Pinia per request (
createPinia()inside the request handler). A module-level Pinia on the server is shared state across all users. - Mark client-only values with
skipHydratein setup stores, for example a ref backed bylocalStorageor a DOM element. Otherwise the server’s value overwrites them during hydration.
The Nuxt 3 guide covers the Nuxt side, including useState versus Pinia.
Testing stores and components that use them
For unit tests of a store itself, give each test a fresh Pinia so no state leaks between tests:
import { setActivePinia, createPinia } from 'pinia'
import { beforeEach, it, expect } from 'vitest'
beforeEach(() => {
setActivePinia(createPinia())
})
it('sums the cart', () => {
const cart = useCartStore()
cart.add({ id: 1, price: 500, qty: 2 })
expect(cart.total).toBe(1000)
})
For component tests, @pinia/testing provides createTestingPinia. By default it stubs every action: calling cart.add() records the call but does nothing. That is useful for asserting “the button called add”, and confusing when you expect state to change and it does not. Pass stubActions: false to run the real actions, and initialState to start from a known state:
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { vi } from 'vitest'
const wrapper = mount(CartButton, {
global: {
plugins: [createTestingPinia({
createSpy: vi.fn,
initialState: { cart: { items: [] } },
})],
},
})
const cart = useCartStore()
await wrapper.find('button').trigger('click')
expect(cart.add).toHaveBeenCalledTimes(1)
The Vitest guide covers mocking and component-test setup in more depth.
When you do not need Pinia
For state used by one component and its children, props or provide/inject are simpler. A composable that returns module-level refs gives you shared state in a few lines. It works well in a client-only app, but it has exactly the SSR cross-request problem described above, and you get no DevTools support. Reach for Pinia when state is shared across unrelated parts of the app, when you want DevTools and plugins, or when you render on the server.
If you are coming from React, Pinia plays roughly the role of Zustand: small stores, direct mutation through actions, no reducers. Vue’s reactivity means you do not need selectors to avoid re-renders.