10 — State Management with Pinia

Setup Store — Composition API Style

stores/user.ts
import { defineStore, ref, computed } from '#imports'  // Nuxt auto-import; else from 'pinia'

// ── defineStore with setup function (Composition API style) ──
// More flexible than Options API stores: can use any composable, watcher, etc.
export const useUserStore = defineStore('user', () => {
  // ── State: refs ────────────────────────────────────────
  const user = ref<{ id: number; name: string; email: string } | null>(null)
  const token = ref<string | null>(null)
  const loading = ref(false)

  // ── Getters: computed ───────────────────────────────────
  const isAuthenticated = computed(() => !!token.value && !!user.value)
  const displayName = computed(() => user.value?.name ?? 'Guest')

  // ── Actions: plain functions (can be async) ─────────────
  async function login(credentials: { email: string; password: string }) {
    loading.value = true
    try {
      const res = await fetch('/api/auth/login', {
        method: 'POST',
        body: JSON.stringify(credentials),
      })
      const data = await res.json()
      user.value = data.user
      token.value = data.token
      localStorage.setItem('token', data.token)  // persistence
    } finally {
      loading.value = false
    }
  }

  function logout() {
    user.value = null
    token.value = null
    localStorage.removeItem('token')
  }

  // ── Everything returned is part of the store's public API ──
  // State (refs), getters (computed), and actions (functions) all exposed.
  return { user, token, loading, isAuthenticated, displayName, login, logout }
})

Options Store — The Alternative

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

// ── Options API style store: state/getters/actions separated ──
// More structured, less flexible. Similar to Vue's Options API.
export const useCartStore = defineStore('cart', {
  // ── State: a function returning the initial state (like data()) ──
  // MUST be a function (not a plain object) — fresh copy per store instance.
  state: () => ({
    items: [] as Array<{ id: number; name: string; price: number; qty: number }>,
    couponCode: null as string | null,
  }),

  // ── Getters: like computed, access state via `this` ──
  getters: {
    count: (state) => state.items.reduce((sum, i) => sum + i.qty, 0),

    // ── Getter with parameter: return a function from a getter ──
    itemPrice: (state) => (id: number) =>
      state.items.find(i => i.id === id)?.price ?? 0,

    // ── Getter using another getter: access via `this` ──
    total: (state) => {
      return state.items.reduce((sum, i) => sum + i.price * i.qty, 0)
    },
    // For getters referencing other getters, use `this`:
    // totalWithTax() { return this.total * 1.08 }
  },

  // ── Actions: methods, mutate state via `this` ──
  actions: {
    addItem(item: { id: number; name: string; price: number }) {
      const existing = this.items.find(i => i.id === item.id)
      if (existing) {
        existing.qty++  // direct mutation — Pinia allows it
      } else {
        this.items.push({ ...item, qty: 1 })
      }
    },

    removeItem(id: number) {
      this.items = this.items.filter(i => i.id !== id)
    },

    // ── Actions can be async ──
    async checkout() {
      const res = await fetch('/api/checkout', {
        method: 'POST',
        body: JSON.stringify({ items: this.items, coupon: this.couponCode }),
      })
      if (res.ok) this.$reset()  // reset to initial state
    },
  },
})

$patch — Batch Mutations for Performance

patch-mutations.ts
import { useUserStore } from './stores/user'

const store = useUserStore()

// ── $patch with object: batch multiple state changes into one mutation ──
// Triggers ONE re-render for all changes, not one per property.
store.$patch({
  user: { id: 1, name: 'Ada', email: 'ada@example.com' },
  token: 'xyz',
  loading: false,
})

// ── $patch with function: for complex mutations (arrays, nested objects) ──
// The function receives `state` and can mutate it freely.
// Pinia batches all mutations inside the function into one update.
store.$patch((state) => {
  state.user.name = 'Grace'
  state.user.email = 'grace@example.com'
  // Multiple mutations, single re-render batch.
})

// ── Direct mutation: also valid, triggers per-property ──
store.user.name = 'Ada'       // one update
store.token = 'abc'            // another update → two re-renders
// Use $patch to batch when updating multiple properties simultaneously.

Cross-Store Dependencies — Composing Stores

stores/checkout.ts
import { defineStore } from 'pinia'
import { useCartStore } from './cart'
import { useUserStore } from './user'

// ── One store can use another — call useXxxStore() inside actions ──
// Don't call at store definition time (circular dependency risk).
// Call inside actions or setup function body.
export const useCheckoutStore = defineStore('checkout', () => {
  const cart = useCartStore()
  const user = useUserStore()

  async function processPayment(paymentMethod: string) {
    if (!user.isAuthenticated) throw new Error('Must be logged in')

    const res = await fetch('/api/checkout', {
      method: 'POST',
      body: JSON.stringify({
        items: cart.items,
        userId: user.user?.id,
        paymentMethod,
      }),
    })

    if (res.ok) {
      cart.$reset()  // clear cart after successful checkout
    }

    return res.json()
  }

  return { processPayment }
})

Store Persistence — Plugin Pattern

plugins/persist.ts
import type { PiniaPluginContext } from 'pinia'

// ── Pinia plugin: runs for every store, persists state to localStorage ──
export function persistPlugin({ store, options }: PiniaPluginContext) {
  // ── Opt-in: only persist stores with `persist: true` in options ──
  if (!options.persist) return

  const key = `pinia:${store.$id}`

  // ── Hydrate from storage on store creation ──
  const saved = localStorage.getItem(key)
  if (saved) {
    store.$patch(JSON.parse(saved))  // restore saved state
  }

  // ── Subscribe to state changes — save on every mutation ──
  store.$subscribe((mutation, state) => {
    // Debounce localStorage writes in production (mutations can be frequent)
    localStorage.setItem(key, JSON.stringify(state))
  }, { detached: true })  // detached: keep subscription even after store disposed

  // ── Alternative: use $onAction to persist only after specific actions ──
  // store.$onAction(({ name, after }) => {
  //   if (name === 'addToCart') after(() => localStorage.setItem(key, JSON.stringify(store.$state)))
  // })
}
main.ts
import { createPinia } from 'pinia'
import { persistPlugin } from './plugins/persist'

const pinia = createPinia()
pinia.use(persistPlugin)  // register plugin — applies to all stores

app.use(pinia)

SSR Hydration — Request-Scoped State

ssr-hydration.ts
import { createPinia } from 'pinia'

// ── SSR: create a FRESH Pinia instance per request ──────
// Module-scoped Pinia leaks state across requests (security bug).
// Each request gets its own Pinia → stores are request-scoped.
export function createApp() {
  const pinia = createPinia()  // fresh per request

  // ── On server: serialize state into HTML for client hydration ──
  // After all onServerPrefetch hooks resolve:
  const state = pinia.state.value  // all store states
  // Inject into HTML: <script>window.__PINIA__ = ${JSON.stringify(state)}</script>

  // ── On client: hydrate from serialized state ──
  if (typeof window !== 'undefined' && window.__PINIA__) {
    pinia.state.value = window.__PINIA__  // replace initial state with server state
  }

  return { pinia }
}

💡 Tips & Tricks

tips.ts
import { storeToRefs } from 'pinia'
import { useUserStore } from './stores/user'

// ── 1. storeToRefs — destructure store without losing reactivity ──
// Direct destructuring breaks reactivity (same as reactive() destructuring).
const store = useUserStore()
const { user, isAuthenticated } = storeToRefs(store)  // ✅ refs, reactive
const { login, logout } = store                      // ✅ actions are stable, no ref needed

// ── 2. $reset — restore to initial state (Options API stores only) ──
// Setup stores don't have $reset by default — implement manually:
function reset() {
  user.value = null
  token.value = null
  loading.value = false
}

// ── 3. $subscribe — watch all state changes ──
store.$subscribe((mutation, state) => {
  // mutation.type: 'direct' | 'patch object' | 'patch function'
  console.log(mutation.type, state)
})

// ── 4. $onAction — hook into actions (before/after/error) ──
store.$onAction(({ name, args, after, onError }) => {
  console.log(`action ${name} started`, args)
  after((result) => console.log(`${name} succeeded`, result))
  onError((err) => console.error(`${name} failed`, err))
})

⚠️ Edge Cases & Gotchas

edge-cases.ts
// ── 1. Destructuring a store breaks reactivity ──
// const { user } = useUserStore()  → user is a snapshot, not reactive
// Fix: const { user } = storeToRefs(store)

// ── 2. $reset only works on Options API stores ──
// Setup (function) stores don't have $reset — Pinia can't know initial state.
// Implement your own reset() action in setup stores.

// ── 3. State must be serializable for persistence ──
// localStorage stores strings — Date, Map, Set, class instances don't survive.
// Use a custom replacer/reviver in JSON.stringify/parse, or avoid non-serializable state.

// ── 4. Cross-store calls inside setup() body risk circular deps ──
// Store A's setup calls useB(), Store B's setup calls useA() → infinite loop.
// Call useXxxStore() inside ACTIONS, not at store definition time.

// ── 5. SSR: module-scoped Pinia leaks state across requests ──
// createPinia() must be called per-request, not at module scope.
// Nuxt handles this automatically; custom SSR setups must do it manually.

// ── 6. Getters with parameters are NOT cached ──
// itemPrice(id) returns a function → recomputes every call.
// Only parameter-less getters are cached (like computed).

🧠 Spot the Bug

A component destructures a Pinia store and the UI stops updating when the store changes.

StoreDestructureBug.ts
import { useUserStore } from './stores/user'

const store = useUserStore()
const { user, isAuthenticated } = store  // ← destructured: no longer reactive
const { login } = store                  // ← actions are fine to destructure

// user is a plain object snapshot, not a ref.
// When store.user changes, this `user` variable doesn't update.
Answer

Destructuring a store copies the current values out of the reactive state. Like destructuring a reactive() object, the local variables are snapshots — they don't track future store changes.

Fix — use storeToRefs() for state/getters, destructure actions directly:

StoreDestructureFixed.ts
import { storeToRefs } from 'pinia'
import { useUserStore } from './stores/user'

const store = useUserStore()
const { user, isAuthenticated } = storeToRefs(store)  // refs — reactive
const { login } = store  // actions are stable functions, safe to destructure

The lesson: storeToRefs() converts store state and getters to refs (like toRefs() for reactive objects). Actions are plain functions and don't need wrapping — destructure them directly.