State & data layer

Pinia

Setup stores versus option stores, how Pinia works in Nuxt SSR, storeToRefs and the reactivity traps, plugins and persistence, testing with createTestingPinia, Pinia 4, and when a store is the wrong tool.

Pinia is the official Vue store and the one interviewers assume. The questions are predictable: setup versus option stores, what happens to a store during SSR, why destructuring a store loses reactivity, how you test one, and, for seniors, when not to reach for it. Pinia 4 (ESM-only) is current as of September 2026, installed in Nuxt through @pinia/nuxt.

Know

  • Two syntaxes. Option stores: defineStore('cart', { state: () => ({}), getters: {}, actions: {} }). Setup stores: defineStore('cart', () => { const items = ref([]); const total = computed(...); function add() {} ; return { items, total, add } }). Setup stores read like composables, allow watchers and other composables inside, and are what most teams standardise on; option stores get $reset() for free and are easier to migrate from Vuex.
  • storeToRefs(store) extracts reactive refs for state and getters; destructuring the store directly (const { items } = useCartStore()) copies plain values and loses reactivity. Actions can be destructured because they are functions.
  • SSR in Nuxt: @pinia/nuxt creates one Pinia per request on the server and injects it into the app; the state is serialised into the payload (nuxtApp.payload.pinia) and hydrated on the client, so a store filled during SSR is already populated in the browser. Calling useStore() works anywhere in the Nuxt context (pages, middleware, plugins); outside it, pass the instance explicitly (useStore(useNuxtApp().$pinia)).
  • API surface: $patch(object | fn) for batched mutations, $reset() (option stores; implement your own in setup stores), $subscribe(cb, { detached }) for state changes, $onAction for action hooks, $state to replace state wholesale, $dispose. Plugins via pinia.use() add properties, options or persistence to every store.
  • Persistence: pinia-plugin-persistedstate (with its Nuxt module) stores selected picked paths in cookies (SSR-visible) or localStorage/sessionStorage (client only, restored after hydration). Cookies are size-limited; do not persist server data or secrets.
  • Composition: stores can use other stores inside getters and actions (useUserStore() inside useCartStore); avoid two stores reading each other's state in getters cyclically.
  • Testing: @pinia/testing's createTestingPinia({ stubActions, initialState, createSpy }) gives an active Pinia with actions stubbed as spies, so components can be tested against store state without hitting APIs; unit-test a store directly with setActivePinia(createPinia()).
  • Pinia 4 contains only technical breaking changes: ESM-only distribution, and @vue/devtools-api (v8) must be installed alongside; @pinia/nuxt 1.x pairs with it. Pinia Colada is the companion data-fetching layer (see the next page).
  • When not to use a store: for local UI state (a modal's open flag), for server data (that is a cache concern), or for anything only one component reads. A store is a global singleton per app; every consumer couples to it.

How it works

A setup store with everything an interviewer expects to see, SSR-safe:

app/stores/cart.ts
import { defineStore } from 'pinia'

export interface CartItem { id: string, name: string, price: number, qty: number }

export const useCartStore = defineStore('cart', () => {
  const items = ref<CartItem[]>([])
  const coupon = ref<string | null>(null)

  const count = computed(() => items.value.reduce((n, i) => n + i.qty, 0))
  const subtotal = computed(() => items.value.reduce((n, i) => n + i.price * i.qty, 0))
  const total = computed(() => (coupon.value === 'TEN' ? subtotal.value * 0.9 : subtotal.value))

  function add(item: Omit<CartItem, 'qty'>, qty = 1) {
    const existing = items.value.find(i => i.id === item.id)
    if (existing) existing.qty += qty
    else items.value.push({ ...item, qty })
  }

  function remove(id: string) {
    items.value = items.value.filter(i => i.id !== id)
  }

  async function checkout() {
    // Server owns the order; the store only holds the client-side cart.
    const order = await $fetch('/api/orders', { method: 'POST', body: { items: items.value, coupon: coupon.value } })
    $reset()
    return order
  }

  // Setup stores have no automatic $reset: provide one.
  function $reset() {
    items.value = []
    coupon.value = null
  }

  return { items, coupon, count, subtotal, total, add, remove, checkout, $reset }
}, {
  // pinia-plugin-persistedstate: cookie so the server render sees the cart too
  persist: { pick: ['items', 'coupon'], storage: piniaPluginPersistedstate.cookies({ maxAge: 60 * 60 * 24 * 7 }) },
})

Using it without losing reactivity, and hydrating it on the server:

app/components/Cart/Badge.vue
<script setup lang="ts">
import { storeToRefs } from 'pinia'

const cart = useCartStore()
const { count, total } = storeToRefs(cart) // refs; `const { count } = cart` would be a frozen number
const { add } = cart // actions are plain functions and can be destructured
</script>

<template>
  <button type="button" @click="add({ id: 'sku-1', name: 'Sample', price: 9 })">
    {{ count }} items · {{ total.toFixed(2) }}
  </button>
</template>
app/plugins/user.ts
export default defineNuxtPlugin(async () => {
  // Runs per request on the server; the resulting store state travels in the payload.
  const user = useUserStore()
  if (import.meta.server) await user.loadFromSession()
})

Testing a component against store state:

test/nuxt/CartBadge.spec.ts
import { mountSuspended } from '@nuxt/test-utils/runtime'
import { createTestingPinia } from '@pinia/testing'
import { describe, expect, it } from 'vitest'
import CartBadge from '~/components/Cart/Badge.vue'

describe('CartBadge', () => {
  it('shows the count from the store', async () => {
    const wrapper = await mountSuspended(CartBadge, {
      global: {
        plugins: [createTestingPinia({ initialState: { cart: { items: [{ id: '1', name: 'A', price: 2, qty: 3 }] } } })],
      },
    })
    expect(wrapper.text()).toContain('3 items')
  })
})
Gotcha· A store created at module level

const store = useCartStore() at the top of a composable file runs once per server process and pins the store to whatever Pinia was active at import time, which on the server is another request's. Call useStore() inside setup, a plugin or a middleware, where the per-request Pinia is active.

Gotcha· Getters are computeds, and they cache

A getter that reads Date.now() or a random value is cached until a reactive dependency changes and looks "stuck". Getters must be pure functions of state; time-dependent values are actions or refs updated by a timer.

Verify before the interview:

The current @pinia/nuxt major, whether stores in app/stores are auto-imported by the module, and the persistedstate plugin's Nuxt module name and cookie storage API.

docs ↗

Exercise

Exercise
  • Build the cart store, fill it in a server plugin from a fake session, and confirm the client renders the count without a flash. Inspect #__NUXT_DATA__ for the pinia entry.
  • Destructure count directly and with storeToRefs; add an item and watch which one updates.
  • Write the createTestingPinia test, then a second one asserting that add was called (it is a spy) when the button is clicked.

Be able to say

Be able to say· How does Pinia work with server-side rendering in Nuxt?

"The @pinia/nuxt module creates a fresh Pinia for every request on the server and installs it into that request's Vue app, so stores never leak between users as long as I call useStore() inside setup, middleware or plugins rather than at module level. Whatever state the stores hold at the end of the server render is serialised into the Nuxt payload under a pinia key and restored on the client before hydration, so a store populated during SSR is already there in the browser. The classic mistakes are destructuring the store without storeToRefs, which loses reactivity, and using a store as a cache for server data, which belongs in a keyed data-fetching layer."

Be able to say· Setup store or option store?

"Setup stores, for the same reasons I prefer the Composition API: they read like composables, they can use watchers and other composables inside, they type without effort, and the team already knows the idiom. Option stores are fine for small stores or when migrating from Vuex and give you $reset for free, which in a setup store I write myself. Either way the rules are the same: getters are pure computeds, actions are the only place for async work, and the store never becomes a copy of server data."

Your notes

Open in Notes

Nothing yet. Notes filed here show up on this page and the section overview.