Skip to content

State Management

This guide codifies how we structure and use Pinia to manage client-side state in Vue 3 applications, keeping conventions consistent across modules.

Scope: Application/client state only. Server/API state is managed by the Query/Mutation layer. Don't store API lists/detail payloads in Pinia unless you have a compelling client-state reason.

Convention

  • One store per module/domain:
    • Path: src/stores/<MODULE>/<module>.store.ts
    • Name: use<Module>Store; Pinia id: <module>Store in camelCase (e.g., cartStore, hook useCartStore).
  • Register the Pinia id in PiniaStoreId enum (src/config/pinia.ts); enum members use PascalCase and string values use camelCase.
ts
// src/config/pinia.ts
export enum PiniaStoreId {
  Cart = 'cartStore',
  Auth = 'authStore',
  Prefs = 'prefsStore',
  Products = 'productsStore',
}
  • Keep it cohesive: state + getters + actions that belong together.
  • Extract heavy logic into pure functions in src/services/<MODULE>/…, then call from store actions.
  • Use Setup Stores by default (Composition API) for maximum flexibility.
  • Typing: declare explicit state interfaces or type aliases (exported, prefixed with State*) and return a typed API surface.
    • Shared/domain shapes live in src/models/ (e.g., User, Product).
    • Store-only interfaces stay colocated with the store file; skip global stores/models/ barrels.
  • Keep actions pure: allow async side-effects, but delegate core transformations to pure service functions.
  • No index barrels in stores/—import stores directly.
  • Compose stores thoughtfully: call other stores from actions/getters and avoid circular reads in setup().
ts
// from another store's action
import { useCartStore } from '@/stores/cart/cart.store'

function checkout() {
  const cart = useCartStore()
  if (!cart.hasItems) return
  // avoid circular reads: don't call this store back from cart's setup()
}

Template (TypeScript, Setup Store)

ts
// src/stores/cart/cart.store.ts
import { defineStore } from 'pinia'
import { PiniaStoreId } from '@/config/pinia'

export interface CartItemState { id: number; name: string; qty: number }

export const useCartStore = defineStore(PiniaStoreId.Cart, () => {
  // state
  const items = ref<CartItemState[]>([])
  const isOpen = ref(false)

  // getters (computed)
  const count = computed(() => items.value.length)
  const hasItems = computed(() => count.value > 0)

  // actions
  function add(item: CartItemState) { items.value.push(item) }
  function clear() { items.value = [] }
  function toggle() { isOpen.value = !isOpen.value }

  return { items, isOpen, count, hasItems, add, clear, toggle }
})

Using in Components

In components, don't destructure the store directly (breaks reactivity). Use storeToRefs(store) for state/getters; destructure actions from the store itself.

ts
import { storeToRefs } from 'pinia'
const cart = useCartStore()
const { items, hasItems, count } = storeToRefs(cart) // reactive
const { add, clear } = cart // actions OK to destructure

Setup

  • Centralize Pinia creation and plugin registration in src/stores/index.ts, then install it from main.ts.
ts
// src/stores/index.ts
import { createPinia } from 'pinia'
import persist from 'pinia-plugin-persistedstate'

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

export default pinia
ts
// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
import store from '@/stores'

const app = createApp(App)

app.use(store)
app.mount('#app')

Persistence

  • Persist only what's safe and necessary (e.g., UI preferences, cart, feature toggles).
  • Never persist secrets, tokens, or PII — see Anti-patterns.
  • Use pinia-plugin-persistedstate to persist and rehydrate automatically.
ts
// src/stores/prefs/prefs.store.ts
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { PiniaStoreId } from '@/config/pinia'

export type ThemeState = 'light' | 'dark'

export const usePrefsStore = defineStore(PiniaStoreId.Prefs, () => {
  const theme = ref<ThemeState>('light')
  return { theme }
}, { persist: { paths: ['theme'], storage: localStorage } })

Subscriptions, actions & patching

  • Use store.$subscribe to react to state changes (e.g., manual persistence, analytics).
  • Use store.$onAction to observe/trace actions and errors.
  • Prefer store.$patch() for partial updates / batched mutations.
  • Both return unsubscribe functions—tie them to onUnmounted (or equivalent) to avoid leaks.
ts
import { onUnmounted } from 'vue'

const stop = cart.$onAction(({ name, after, onError }) => {
  const start = performance.now()
  after(() => console.debug(`[cart] ${name} in ${performance.now() - start}ms`))
  onError((err) => console.error(`[cart] ${name} error`, err))
})

onUnmounted(stop)
ts
// batch partial updates
cart.$patch({ isOpen: false })

// or with a function for complex updates
cart.$patch((state) => {
  state.items.push({ id: 99, name: 'Mouse', qty: 1 })
})

Testing

Test store logic via actions/getters; mock services instead of Pinia internals.

ts
// CartStore.spec.ts
import { beforeEach, expect, test } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useCartStore } from './cart.store'

beforeEach(() => {
  setActivePinia(createPinia())
})

test('adds items', () => {
  const cart = useCartStore()
  cart.add({ id: 1, name: 'Keyboard', qty: 1 })

  expect(cart.count).toBe(1)
  expect(cart.items[0].name).toBe('Keyboard')
})

Decision table (quick)

ScenarioUse
Toggle UI, wizard step, drawer openPinia store
Persisted user preference (theme, lang)Pinia store + persistedstate plugin
Entity list/detail, pagination, filtersQuery/Mutation layer
Cache-aware invalidation & refetchQuery/Mutation layer
Complex transformation of payloadsService functions called by store

What goes in Pinia (vs the Query/Mutation layer)

  • Pinia (client state): UI flags (modals, menus), draft inputs, client-only preferences, cross-component state, ephemeral session info.
  • Query/Mutation layer (server state): entity lists/details, paginated/filtered resources, mutations with cache invalidation.

Anti-patterns to avoid

  • Persisting secrets, auth tokens, or PII in Pinia/browser storage—delegate to secure storage flows.
ts
import { PiniaStoreId } from '@/config/pinia'

// ❌ BAD: Pinia persistence containing tokens
export const useAuthStore = defineStore(PiniaStoreId.Auth, () => {
  const accessToken = ref('')

  function signIn(payload: SignInResponse) {
    accessToken.value = payload.accessToken
  }

  return { accessToken, signIn }
}, { persist: true })

// ✅ GOOD: server handles secure storage
export const useAuthStore = defineStore(PiniaStoreId.Auth, () => {
  const profile = ref<UserProfile | null>(null)

  async function signIn(credentials: Credentials) {
    const data = await authService.signIn(credentials) // backend sets httpOnly cookie
    profile.value = data.profile
  }

  return { profile, signIn }
})
  • Mirroring server collections in Pinia; keep canonical data in the Query/Mutation layer and derive client state from it.
ts
import { PiniaStoreId } from '@/config/pinia'

// ❌ BAD: duplicating server data in Pinia
export const useProductsStore = defineStore(PiniaStoreId.Products, () => {
  const products = ref<Product[]>([])

  async function load() {
    products.value = await api.fetchProducts()
  }

  return { products, load }
})

// ✅ GOOD: Pinia stores view state only
export const useProductsStore = defineStore(PiniaStoreId.Products, () => {
  const selectedProductId = ref<number | null>(null)
  const productsQuery = useProductsQuery()

  const selectedProduct = computed(() =>
    productsQuery.data.value?.find((product) => product.id === selectedProductId.value) ?? null
  )

  return { selectedProductId, selectedProduct }
})
  • Injecting router instances or global singletons into store state—pass them through actions or services instead.
ts
import { PiniaStoreId } from '@/config/pinia'

// ❌ BAD: storing router in Pinia
export const useAuthStore = defineStore(PiniaStoreId.Auth, () => {
  const router = useRouter()

  function signOut() {
    router.push('/login')
  }

  return { signOut }
})

// ✅ GOOD: accept dependencies in calls
export const useAuthStore = defineStore(PiniaStoreId.Auth, () => {
  function signOut(router: Router) {
    router.push('/login')
  }

  return { signOut }
})

Rationale

  • Vue 3 setup stores keep state colocated with behavior while staying testable and type-safe.
  • Separating client and server state responsibilities prevents duplicated caches and clarifies data ownership.
  • Strong typing, service extraction, and testing patterns reduce regression risk as modules scale.