Skip to main content
Technology & EngineeringVue258 lines

Pinia

Pinia state management for Vue 3 including store design, actions, getters, plugins, and SSR hydration

Quick Summary18 lines
You are an expert in Pinia, the official state management library for Vue 3, covering store architecture, TypeScript patterns, and integration with Nuxt.

## Key Points

- **Store** — a reactive entity holding state, getters (computed), and actions (methods)
- **Setup stores** — define stores using Composition API syntax (`ref`, `computed`, functions)
- **Option stores** — define stores using an options object (`state`, `getters`, `actions`)
- **Plugins** — extend every store with shared logic (persistence, logging, etc.)
- **`storeToRefs`** — destructure a store while preserving reactivity for state and getters
1. **Prefer setup stores** — they offer better TypeScript inference and are closer to the Composition API you already use in components.
2. **One concern per store** — keep stores focused (auth, cart, ui). Compose stores when you need cross-cutting logic.
3. **Use `storeToRefs` for destructuring** — never destructure state or getters directly from the store object; reactivity will be lost.
4. **Actions for async and mutations** — keep all state mutations inside actions for traceability in devtools.
5. **Use `$reset()` for option stores** — option stores get a free `$reset()` method; for setup stores, implement your own reset function.
6. **Avoid storing derived data** — use getters (computed) instead of duplicating data in state.
7. **Use `$subscribe` sparingly** — it fires on every state change; debounce if you are persisting to storage.
skilldb get vue-skills/PiniaFull skill: 258 lines
Paste into your CLAUDE.md or agent config

Pinia State Management — Vue.js

You are an expert in Pinia, the official state management library for Vue 3, covering store architecture, TypeScript patterns, and integration with Nuxt.

Core Philosophy

Pinia is designed around the principle that state management should feel like a natural extension of the Composition API, not a separate paradigm with its own vocabulary. A Pinia store is just a function that returns reactive state, computed getters, and methods — the same primitives you already use inside components. This low conceptual overhead means developers spend time solving business problems rather than learning a state management framework.

Each store should own a single, well-defined domain: authentication, shopping cart, user preferences. This encourages thinking about state in terms of bounded contexts rather than a monolithic global store. When stores need to coordinate, they compose by calling each other directly within actions, keeping the dependency graph explicit and traceable. The absence of mutations (unlike Vuex) is a deliberate simplification — actions are the single entry point for all state changes, and devtools track them automatically.

Pinia's design also acknowledges that not all state belongs in a store. Component-local state should remain local. Stores are for state that genuinely needs to be shared across multiple components or that must persist across navigations. Reaching for a store by default when a simple ref inside a composable would suffice is a sign of over-engineering.

Anti-Patterns

  • Destructuring Stores Without storeToRefs — writing const { count } = useCounterStore() and expecting reactivity, when this actually gives you a plain non-reactive snapshot. Always use storeToRefs() for state and getters.

  • Circular Store Dependencies at Definition Time — store A calling useStoreB() at the top level while store B calls useStoreA() at the top level, creating an infinite loop. Call dependent stores inside actions, not during store initialization.

  • Storing Derived Data as State — maintaining a filteredList in state when it should be a getter derived from list and filter. Duplicating derived data leads to stale values and synchronization bugs.

  • Treating Every Piece of State as Global — creating stores for form state, UI toggles, or component-specific data that only one component uses. This adds indirection without benefit. Keep local state local.

  • Forgetting SSR Hydration Boundaries — modifying store state in a client-only plugin without accounting for server-rendered HTML, causing hydration mismatches that produce visual glitches and console warnings.

Overview

Pinia is Vue's recommended store library, replacing Vuex. It provides a simple API based on the Composition API, full TypeScript support, devtools integration, and SSR compatibility. Each store is an independent reactive module with state, getters, and actions.

Core Concepts

  • Store — a reactive entity holding state, getters (computed), and actions (methods)
  • Setup stores — define stores using Composition API syntax (ref, computed, functions)
  • Option stores — define stores using an options object (state, getters, actions)
  • Plugins — extend every store with shared logic (persistence, logging, etc.)
  • storeToRefs — destructure a store while preserving reactivity for state and getters

Implementation Patterns

Setup Store (Recommended)

// stores/useCartStore.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

interface CartItem {
  id: string
  name: string
  price: number
  quantity: number
}

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

  const totalItems = computed(() =>
    items.value.reduce((sum, item) => sum + item.quantity, 0)
  )

  const totalPrice = computed(() =>
    items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
  )

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

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

  function clear() {
    items.value = []
  }

  return { items, totalItems, totalPrice, addItem, removeItem, clear }
})

Option Store

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

interface User {
  id: string
  name: string
  email: string
}

export const useUserStore = defineStore('user', {
  state: () => ({
    user: null as User | null,
    token: null as string | null,
  }),

  getters: {
    isAuthenticated: (state) => !!state.token,
    displayName: (state) => state.user?.name ?? 'Guest',
  },

  actions: {
    async login(email: string, password: string) {
      const res = await fetch('/api/auth/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email, password }),
      })
      const data = await res.json()
      this.token = data.token
      this.user = data.user
    },

    logout() {
      this.token = null
      this.user = null
    },
  },
})

Using a Store in Components

<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useCartStore } from '@/stores/useCartStore'

const cart = useCartStore()

// Destructure state and getters as refs (reactive)
const { items, totalItems, totalPrice } = storeToRefs(cart)

// Actions can be destructured directly (they are plain functions)
const { addItem, removeItem, clear } = cart
</script>

<template>
  <div>
    <p>{{ totalItems }} items — ${{ totalPrice.toFixed(2) }}</p>
    <ul>
      <li v-for="item in items" :key="item.id">
        {{ item.name }} x{{ item.quantity }}
        <button @click="removeItem(item.id)">Remove</button>
      </li>
    </ul>
    <button @click="clear">Clear cart</button>
  </div>
</template>

Store Composition (Store Using Another Store)

// stores/useCheckoutStore.ts
import { defineStore } from 'pinia'
import { computed } from 'vue'
import { useCartStore } from './useCartStore'
import { useUserStore } from './useUserStore'

export const useCheckoutStore = defineStore('checkout', () => {
  const cart = useCartStore()
  const user = useUserStore()

  const canCheckout = computed(() =>
    user.isAuthenticated && cart.totalItems > 0
  )

  async function placeOrder() {
    if (!canCheckout.value) throw new Error('Cannot checkout')

    await fetch('/api/orders', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${user.token}`,
      },
      body: JSON.stringify({ items: cart.items }),
    })

    cart.clear()
  }

  return { canCheckout, placeOrder }
})

Persistence Plugin

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

export function piniaPersistedState({ store }: PiniaPluginContext) {
  const key = `pinia-${store.$id}`

  // Hydrate from localStorage
  const saved = localStorage.getItem(key)
  if (saved) {
    store.$patch(JSON.parse(saved))
  }

  // Persist on every mutation
  store.$subscribe((_mutation, state) => {
    localStorage.setItem(key, JSON.stringify(state))
  })
}

// main.ts
import { createPinia } from 'pinia'
import { piniaPersistedState } from './plugins/piniaPersistedState'

const pinia = createPinia()
pinia.use(piniaPersistedState)

Pinia with Nuxt 3

// Nuxt auto-imports from stores/ directory when using @pinia/nuxt module
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@pinia/nuxt'],
  pinia: {
    storesDirs: ['./stores/**'],
  },
})

Best Practices

  1. Prefer setup stores — they offer better TypeScript inference and are closer to the Composition API you already use in components.
  2. One concern per store — keep stores focused (auth, cart, ui). Compose stores when you need cross-cutting logic.
  3. Use storeToRefs for destructuring — never destructure state or getters directly from the store object; reactivity will be lost.
  4. Actions for async and mutations — keep all state mutations inside actions for traceability in devtools.
  5. Use $reset() for option stores — option stores get a free $reset() method; for setup stores, implement your own reset function.
  6. Avoid storing derived data — use getters (computed) instead of duplicating data in state.
  7. Use $subscribe sparingly — it fires on every state change; debounce if you are persisting to storage.

Common Pitfalls

  • Destructuring without storeToRefsconst { count } = useCounterStore() gives you a plain non-reactive value. Use storeToRefs() for state and getters.
  • Using stores before Pinia is installed — calling useXStore() before app.use(pinia) throws. In Nuxt, the @pinia/nuxt module handles this.
  • Circular store dependencies — store A using store B which uses store A causes infinite loops. Call the dependent store inside an action, not at the store's top level.
  • SSR hydration mismatch — if you modify store state in a plugin that only runs on the client, the server-rendered HTML will not match. Guard client-only logic with environment checks.
  • Forgetting setup stores have no $reset — unlike option stores, setup stores do not auto-generate $reset(). You must expose your own reset function.

Install this skill directly: skilldb add vue-skills

Get CLI access →