Vue + Pinia
Pinia is the standard state management library for Vue 3, and setup stores built with the Composition API give shared application state the same ergonomics as local ref/computed state.
Workflow for Adding a Pinia Store
- Decide if Pinia is the right tool — Confirm the state is shared across routes/components, not server cache data, and not purely local UI state (see State Ownership below).
- Create the store file — Add one file per domain under
stores/, e.g. stores/cart.ts, using defineStore('cart', () => { ... }).
- Define state, getters, and actions — Declare state with
ref/reactive, derive values with computed, and put all writes/side effects in functions (actions).
- Return everything from the setup function — Return every piece of state, every getter, and every action so Pinia devtools, SSR, and plugins can track them.
- Consume in components — Call the store at the top of
<script setup>, destructure reactive state with storeToRefs(), and destructure actions directly.
- Handle SSR/router-guard usage — Call the store from inside setup, a getter, an action, or pass the active Pinia instance explicitly outside setup contexts.
- Add persistence and tests — Allowlist persisted fields, validate rehydrated data, and test actions directly with
@pinia/testing.
State Ownership
- Keep component-only state in the component with
ref, reactive, or computed.
- Use Pinia for shared client state that spans routes, layouts, or unrelated component trees.
- Use route params and query strings for shareable navigation state — don't duplicate it into a store.
- Use Nuxt
useFetch / useAsyncData, TanStack Query for Vue, or the existing API layer for server state — Pinia is for client state, not a data-fetching cache.
- Do not copy server cache data into Pinia unless it is an intentional editable draft, offline cache, or workflow snapshot with its own lifecycle.
Store Structure
- Prefer setup stores with
defineStore('name', () => { ... }) for Vue 3 Composition API projects over the older options-API defineStore({ state, getters, actions }) form.
- Use one store file per domain under
stores/, such as stores/cart.ts or stores/session.ts.
- Keep state, computed getters, and actions together when they represent one cohesive domain.
- Return every state property from setup stores so Pinia can track it for devtools, SSR, and plugins — an un-returned
ref is invisible to the rest of the system.
- Keep getters pure and side-effect free; put writes, I/O, and orchestration in actions.
// stores/cart.ts
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
interface CartLine {
id: string
name: string
quantity: number
unitPrice: number
}
export const useCartStore = defineStore('cart', () => {
const lines = ref<CartLine[]>([])
const itemCount = computed(() =>
lines.value.reduce((total, line) => total + line.quantity, 0),
)
const subtotal = computed(() =>
lines.value.reduce((total, line) => total + line.quantity * line.unitPrice, 0),
)
function addLine(line: CartLine) {
const existing = lines.value.find((item) => item.id === line.id)
if (existing) {
existing.quantity += line.quantity
return
}
lines.value.push(line)
}
function removeLine(id: string) {
lines.value = lines.value.filter((line) => line.id !== id)
}
function clearCart() {
lines.value = []
}
return {
lines,
itemCount,
subtotal,
addLine,
removeLine,
clearCart,
}
})
Component Usage
- Call stores at the top of
<script setup> or inside setup functions, getters, and actions — never conditionally, and never inside a plain .js/.ts module loaded at import time.
- Use
storeToRefs() when destructuring store state or getters in components, so reactivity is preserved.
- Destructure actions directly when useful; actions remain bound to the store and don't need
storeToRefs().
- Avoid writing large business workflows in components; move them to store actions or composables so they're testable and reusable.
- Prefer computed values over watchers when deriving state — a
watch that just recomputes a value should usually be a computed.
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useCartStore } from '@/stores/cart'
const cart = useCartStore()
const { itemCount, subtotal } = storeToRefs(cart)
const { clearCart } = cart
</script>
<template>
<div>
<p>{{ itemCount }} items — ${{ subtotal.toFixed(2) }}</p>
<button type="button" :disabled="itemCount === 0" @click="clearCart">
Clear cart
</button>
</div>
</template>
TypeScript
- Type store state, action payloads, and API responses explicitly.
- Avoid
any; use unknown and narrow external inputs (API responses, persisted storage) before committing them to state.
- Use interfaces for object state that is shared across components or API boundaries.
- Prefer discriminated unions for workflow status and error state, e.g.
{ status: 'idle' } | { status: 'loading' } | { status: 'error'; error: string }.
- Keep store IDs (the first argument to
defineStore) stable and descriptive because they appear in devtools and persistence keys.
Actions and Side Effects
- Actions may be sync or async; keep each action focused on one user or domain workflow.
- Validate action inputs at the boundary before mutating store state.
- Represent async workflows with explicit
status, error, and lastUpdatedAt fields when the UI depends on them.
- Reset stale errors before retrying an async action so a previous failure doesn't linger in the UI.
- Keep subscriptions, intervals, sockets, and browser listeners outside stores unless the store owns their lifecycle and cleanup (e.g. an explicit
connect()/disconnect() action pair).
SSR, Nuxt, and Router Guards
- In SSR contexts, use the store inside setup, getters, or actions so Pinia can resolve the active app instance.
- When using a store outside setup, such as in a router guard, pass the active Pinia instance if the framework requires it (Nuxt's auto-imports handle this automatically).
- Do not read browser-only storage (
localStorage, window) during server rendering — guard with import.meta.client (Nuxt) or an onMounted check.
- In Nuxt, prefer the
@pinia/nuxt integration and SSR-safe composables for data fetching instead of manually wiring a Pinia instance.
- Avoid singleton state leaks across requests by relying on the framework-created Pinia instance per request rather than a module-level singleton.
// router guard example (outside setup)
router.beforeEach((to) => {
const auth = useAuthStore(pinia) // pass the active Pinia instance explicitly
if (to.meta.requiresAuth && !auth.isAuthenticated) {
return { name: 'login' }
}
})
Persistence
- Persist only the fields that must survive reloads, such as preferences or incomplete local drafts.
- Never persist secrets, access tokens, refresh tokens, raw PII, or authorization decisions in browser storage.
- Use field allowlists and versioned migrations for persisted store schemas so old persisted shapes don't crash a new store version.
- Treat persisted state as untrusted input and validate it before using it for critical workflows — a user can edit
localStorage directly.
- Account for hydration timing before rendering UI that depends on persisted values, especially under SSR where the server has no access to browser storage.
Testing and Tooling
- Use
@pinia/testing (createTestingPinia()) for component tests that need stores, with actions stubbed by default.
- Test store actions directly for domain behavior and edge cases, independent of any component.
- Reset Pinia between tests to avoid shared state leakage across test cases.
- Add HMR support with
acceptHMRUpdate() in stores when the project uses Vite HMR patterns.
- Keep stores easy to inspect in Vue Devtools by using clear state names and focused, single-domain stores.
import { setActivePinia, createPinia } from 'pinia'
import { describe, it, expect, beforeEach } from 'vitest'
import { useCartStore } from '@/stores/cart'
describe('cart store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('merges quantities for duplicate line items', () => {
const cart = useCartStore()
cart.addLine({ id: '1', name: 'Widget', quantity: 1, unitPrice: 10 })
cart.addLine({ id: '1', name: 'Widget', quantity: 2, unitPrice: 10 })
expect(cart.itemCount).toBe(3)
})
})
Anti-Patterns
- Do not use Pinia as a dumping ground for every reactive value — most component state should stay local.
- Do not destructure state directly from a store without
storeToRefs(); this breaks reactivity silently.
- Do not mutate props or route objects through store actions.
- Do not put server-only objects, request instances, DOM nodes, or timers in store state — they don't serialize and break SSR/devtools.
- Do not create circular reads between stores in setup functions; compose stores through actions or computed values instead of having two stores read each other at module scope.
1---2name: vue-pinia3description: Vue 3 state management with Pinia using the Composition API, covering setup stores, SSR/Nuxt integration, persistence, and testing. Use when creating or refactoring Pinia stores, deciding what state belongs in a store versus a component, wiring stores into router guards, persisting store state to storage, or testing store actions with @pinia/testing.4---5
6# Vue + Pinia
7
8Pinia is the standard state management library for Vue 3, and setup stores built with the Composition API give shared application state the same ergonomics as local `ref`/`computed` state.
9
10## Workflow for Adding a Pinia Store
11
121. **Decide if Pinia is the right tool** — Confirm the state is shared across routes/components, not server cache data, and not purely local UI state (see State Ownership below).
132. **Create the store file** — Add one file per domain under `stores/`, e.g. `stores/cart.ts`, using `defineStore('cart', () => { ... })`.
143. **Define state, getters, and actions** — Declare state with `ref`/`reactive`, derive values with `computed`, and put all writes/side effects in functions (actions).
154. **Return everything from the setup function** — Return every piece of state, every getter, and every action so Pinia devtools, SSR, and plugins can track them.
165. **Consume in components** — Call the store at the top of `<script setup>`, destructure reactive state with `storeToRefs()`, and destructure actions directly.
176. **Handle SSR/router-guard usage** — Call the store from inside setup, a getter, an action, or pass the active Pinia instance explicitly outside setup contexts.
187. **Add persistence and tests** — Allowlist persisted fields, validate rehydrated data, and test actions directly with `@pinia/testing`.
19
20## State Ownership
21
22- Keep component-only state in the component with `ref`, `reactive`, or `computed`.
23- Use Pinia for shared client state that spans routes, layouts, or unrelated component trees.
24- Use route params and query strings for shareable navigation state — don't duplicate it into a store.
25- Use Nuxt `useFetch` / `useAsyncData`, TanStack Query for Vue, or the existing API layer for server state — Pinia is for client state, not a data-fetching cache.
26- Do not copy server cache data into Pinia unless it is an intentional editable draft, offline cache, or workflow snapshot with its own lifecycle.
27
28## Store Structure
29
30- Prefer setup stores with `defineStore('name', () => { ... })` for Vue 3 Composition API projects over the older options-API `defineStore({ state, getters, actions })` form.
31- Use one store file per domain under `stores/`, such as `stores/cart.ts` or `stores/session.ts`.
32- Keep state, computed getters, and actions together when they represent one cohesive domain.
33- Return every state property from setup stores so Pinia can track it for devtools, SSR, and plugins — an un-returned `ref` is invisible to the rest of the system.
34- Keep getters pure and side-effect free; put writes, I/O, and orchestration in actions.
35
36```ts
37// stores/cart.ts
38import { computed, ref } from 'vue'
39import { defineStore } from 'pinia'
40
41interface CartLine {
42 id: string
43 name: string
44 quantity: number
45 unitPrice: number
46}
47
48export const useCartStore = defineStore('cart', () => {
49 const lines = ref<CartLine[]>([])
50
51 const itemCount = computed(() =>
52 lines.value.reduce((total, line) => total + line.quantity, 0),
53 )
54
55 const subtotal = computed(() =>
56 lines.value.reduce((total, line) => total + line.quantity * line.unitPrice, 0),
57 )
58
59 function addLine(line: CartLine) {
60 const existing = lines.value.find((item) => item.id === line.id)
61 if (existing) {
62 existing.quantity += line.quantity
63 return
64 }
65 lines.value.push(line)
66 }
67
68 function removeLine(id: string) {
69 lines.value = lines.value.filter((line) => line.id !== id)
70 }
71
72 function clearCart() {
73 lines.value = []
74 }
75
76 return {
77 lines,
78 itemCount,
79 subtotal,
80 addLine,
81 removeLine,
82 clearCart,
83 }
84})
85```
86
87## Component Usage
88
89- Call stores at the top of `<script setup>` or inside setup functions, getters, and actions — never conditionally, and never inside a plain `.js`/`.ts` module loaded at import time.
90- Use `storeToRefs()` when destructuring store state or getters in components, so reactivity is preserved.
91- Destructure actions directly when useful; actions remain bound to the store and don't need `storeToRefs()`.
92- Avoid writing large business workflows in components; move them to store actions or composables so they're testable and reusable.
93- Prefer computed values over watchers when deriving state — a `watch` that just recomputes a value should usually be a `computed`.
94
95```vue
96<script setup lang="ts">
97import { storeToRefs } from 'pinia'
98import { useCartStore } from '@/stores/cart'
99
100const cart = useCartStore()
101const { itemCount, subtotal } = storeToRefs(cart)
102const { clearCart } = cart
103</script>
104
105<template>
106 <div>
107 <p>{{ itemCount }} items — ${{ subtotal.toFixed(2) }}</p>
108 <button type="button" :disabled="itemCount === 0" @click="clearCart">
109 Clear cart
110 </button>
111 </div>
112</template>
113```
114
115## TypeScript
116
117- Type store state, action payloads, and API responses explicitly.
118- Avoid `any`; use `unknown` and narrow external inputs (API responses, persisted storage) before committing them to state.
119- Use interfaces for object state that is shared across components or API boundaries.
120- Prefer discriminated unions for workflow status and error state, e.g. `{ status: 'idle' } | { status: 'loading' } | { status: 'error'; error: string }`.
121- Keep store IDs (the first argument to `defineStore`) stable and descriptive because they appear in devtools and persistence keys.
122
123## Actions and Side Effects
124
125- Actions may be sync or async; keep each action focused on one user or domain workflow.
126- Validate action inputs at the boundary before mutating store state.
127- Represent async workflows with explicit `status`, `error`, and `lastUpdatedAt` fields when the UI depends on them.
128- Reset stale errors before retrying an async action so a previous failure doesn't linger in the UI.
129- Keep subscriptions, intervals, sockets, and browser listeners outside stores unless the store owns their lifecycle and cleanup (e.g. an explicit `connect()`/`disconnect()` action pair).
130
131## SSR, Nuxt, and Router Guards
132
133- In SSR contexts, use the store inside setup, getters, or actions so Pinia can resolve the active app instance.
134- When using a store outside setup, such as in a router guard, pass the active Pinia instance if the framework requires it (Nuxt's auto-imports handle this automatically).
135- Do not read browser-only storage (`localStorage`, `window`) during server rendering — guard with `import.meta.client` (Nuxt) or an `onMounted` check.
136- In Nuxt, prefer the `@pinia/nuxt` integration and SSR-safe composables for data fetching instead of manually wiring a Pinia instance.
137- Avoid singleton state leaks across requests by relying on the framework-created Pinia instance per request rather than a module-level singleton.
138
139```ts
140// router guard example (outside setup)
141router.beforeEach((to) => {
142 const auth = useAuthStore(pinia) // pass the active Pinia instance explicitly
143 if (to.meta.requiresAuth && !auth.isAuthenticated) {
144 return { name: 'login' }
145 }
146})
147```
148
149## Persistence
150
151- Persist only the fields that must survive reloads, such as preferences or incomplete local drafts.
152- Never persist secrets, access tokens, refresh tokens, raw PII, or authorization decisions in browser storage.
153- Use field allowlists and versioned migrations for persisted store schemas so old persisted shapes don't crash a new store version.
154- Treat persisted state as untrusted input and validate it before using it for critical workflows — a user can edit `localStorage` directly.
155- Account for hydration timing before rendering UI that depends on persisted values, especially under SSR where the server has no access to browser storage.
156
157## Testing and Tooling
158
159- Use `@pinia/testing` (`createTestingPinia()`) for component tests that need stores, with actions stubbed by default.
160- Test store actions directly for domain behavior and edge cases, independent of any component.
161- Reset Pinia between tests to avoid shared state leakage across test cases.
162- Add HMR support with `acceptHMRUpdate()` in stores when the project uses Vite HMR patterns.
163- Keep stores easy to inspect in Vue Devtools by using clear state names and focused, single-domain stores.
164
165```ts
166import { setActivePinia, createPinia } from 'pinia'
167import { describe, it, expect, beforeEach } from 'vitest'
168import { useCartStore } from '@/stores/cart'
169
170describe('cart store', () => {
171 beforeEach(() => {
172 setActivePinia(createPinia())
173 })
174
175 it('merges quantities for duplicate line items', () => {
176 const cart = useCartStore()
177 cart.addLine({ id: '1', name: 'Widget', quantity: 1, unitPrice: 10 })
178 cart.addLine({ id: '1', name: 'Widget', quantity: 2, unitPrice: 10 })
179 expect(cart.itemCount).toBe(3)
180 })
181})
182```
183
184## Anti-Patterns
185
186- Do not use Pinia as a dumping ground for every reactive value — most component state should stay local.
187- Do not destructure state directly from a store without `storeToRefs()`; this breaks reactivity silently.
188- Do not mutate props or route objects through store actions.
189- Do not put server-only objects, request instances, DOM nodes, or timers in store state — they don't serialize and break SSR/devtools.
190- Do not create circular reads between stores in setup functions; compose stores through actions or computed values instead of having two stores read each other at module scope.