On this page

Pinia: global state management with defineStore, getters, and actions

14 min read TextCh. 4 — State and Data

What is Pinia?

Pinia is the official state management library for Vue 3. It replaces Vuex with a simpler, fully type-safe API that feels like writing composables. Pinia stores have full DevTools integration — you can inspect state, replay actions, and time-travel through mutations in the Vue DevTools browser extension.

Key features:

  • No mutations — state is modified directly in actions (no Vuex-style commit ceremony)
  • Full TypeScript support — types are inferred without boilerplate
  • Modular by design — each store is a separate file, no single giant module tree
  • SSR-compatible — works with Nuxt and Vite SSR
  • DevTools integration — timeline, state snapshot, hot module replacement

Installation

npm install pinia

Register Pinia in main.ts:

import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

const pinia = createPinia()
const app = createApp(App)

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

Defining a store

Pinia has two styles. The Setup store (recommended) uses the Composition API:

import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useCounterStore = defineStore('counter', () => {
  // State: ref() variables
  const count = ref(0)

  // Getters: computed() values
  const doubled = computed(() => count.value * 2)

  // Actions: plain functions
  function increment() {
    count.value++
  }

  function reset() {
    count.value = 0
  }

  return { count, doubled, increment, reset }
})

The Options store (alternative) mirrors Vuex's syntax:

export const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0 }),
  getters: {
    doubled: (state) => state.count * 2,
  },
  actions: {
    increment() { this.count++ },
    reset() { this.count = 0 },
  },
})

Both produce identical behavior. The Setup store is preferred for TypeScript projects.

Using a store in components

import { useCounterStore } from '@/stores/counter'

const counter = useCounterStore()

// Access state and getters directly
console.log(counter.count)   // reactive
console.log(counter.doubled) // reactive computed

// Call actions
counter.increment()
counter.reset()

Destructuring with storeToRefs

Direct destructuring loses reactivity for state and getters (same problem as reactive()):

import { storeToRefs } from 'pinia'

const counter = useCounterStore()

// WRONG — count and doubled are no longer reactive
const { count, doubled, increment } = counter

// CORRECT for state/getters
const { count, doubled } = storeToRefs(counter)
// Actions can be destructured directly (they are functions, not state)
const { increment, reset } = counter

Async actions

Actions can be async — just use await inside them:

export const useUserStore = defineStore('users', () => {
  const users = ref<User[]>([])
  const loading = ref(false)
  const error = ref<string | null>(null)

  async function fetchUsers() {
    loading.value = true
    error.value = null
    try {
      const res = await fetch('/api/users')
      if (!res.ok) throw new Error(`HTTP ${res.status}`)
      users.value = await res.json() as User[]
    } catch (err) {
      error.value = err instanceof Error ? err.message : 'Unknown error'
    } finally {
      loading.value = false
    }
  }

  return { users, loading, error, fetchUsers }
})

Subscribing to state changes

Use store.$subscribe() to watch store state changes (similar to watch):

const cart = useCartStore()

// Runs whenever cart state changes
cart.$subscribe((mutation, state) => {
  localStorage.setItem('cart', JSON.stringify(state.items))
})

Use store.$onAction() to hook into actions:

cart.$onAction(({ name, args, after, onError }) => {
  console.log(`Action "${name}" called with`, args)
  after((result) => console.log('Action completed', result))
  onError((error) => console.error('Action failed', error))
})

Resetting store state

Setup stores can implement a $reset() method manually:

export const useCartStore = defineStore('cart', () => {
  const items = ref<CartItem[]>([])
  const total = computed(() => items.value.reduce((s, i) => s + i.price, 0))

  function $reset() {
    items.value = []
  }

  return { items, total, $reset }
})

// Usage
const cart = useCartStore()
cart.$reset()

Options stores get $reset() for free.

Pinia plugins

Pinia's plugin system lets you extend every store with extra behavior. A common use case is persistence:

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

export function piniaLocalStorage({ store }: PiniaPluginContext) {
  // Load persisted state on initialization
  const saved = localStorage.getItem(store.$id)
  if (saved) {
    store.$patch(JSON.parse(saved))
  }

  // Save on every state change
  store.$subscribe(() => {
    localStorage.setItem(store.$id, JSON.stringify(store.$state))
  })
}

// main.ts
pinia.use(piniaLocalStorage)

For production use, prefer pinia-plugin-persistedstate which handles edge cases.

Cross-store communication

Stores can import and use other stores:

// stores/orders.ts
import { useCartStore } from './cart'
import { useUserStore } from './user'

export const useOrderStore = defineStore('orders', () => {
  async function checkout() {
    const cart = useCartStore()
    const user = useUserStore()

    if (!user.isLoggedIn) throw new Error('Must be logged in')
    if (cart.isEmpty) throw new Error('Cart is empty')

    const order = await placeOrder(cart.items, user.profile)
    cart.clearCart()
    return order
  }

  return { checkout }
})

Store structure and naming

Organize stores in src/stores/, one file per store:

src/
  stores/
    auth.ts       → useAuthStore
    cart.ts       → useCartStore
    products.ts   → useProductsStore
    ui.ts         → useUiStore

Naming convention: store files use kebab-case, exported functions use use + PascalCase + Store.

Practice

  1. Todo store: Create a useTodoStore with ref<Todo[]>([]) state, computed getters for completedCount and pendingCount, and actions to add, toggle, and remove todos. Connect it to a TodoList.vue component.
  2. Auth store with async action: Create a useAuthStore with user, loading, and error state. Implement a login(email, password) async action that calls a fake API and sets the user. Use Vue DevTools to inspect the store state.
  3. Persistence: Add $subscribe to the auth store to persist the user token to localStorage. On store initialization, restore the token and fetch the user profile.

In the next lesson, we will dive deep into forms and v-model — custom components, validation, and form state management.

Setup stores vs Options stores
Pinia supports two syntaxes. The Setup store (using ref, computed, and plain functions) is the recommended style — it integrates perfectly with TypeScript and feels identical to writing a composable. Use it for all new stores.
storeToRefs for destructuring
When destructuring a store, reactive state and getters lose reactivity unless you use storeToRefs(): const { items, total } = storeToRefs(cart). Actions (functions) can be destructured directly without storeToRefs.
Pinia is not a replacement for local state
Do not put everything in Pinia. Use local component state (ref, reactive) for data that only one component cares about. Pinia is for state that must be shared across multiple unrelated components or persisted across routes.
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

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

// Setup store — uses Composition API style
// ref() → state, computed() → getters, functions → actions
export const useCartStore = defineStore('cart', () => {
  // --- State ---
  const items = ref<CartItem[]>([])
  const couponCode = ref<string | null>(null)
  const discountPercent = ref(0)

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

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

  const total = computed(() =>
    subtotal.value * (1 - discountPercent.value / 100)
  )

  const isEmpty = computed(() => items.value.length === 0)

  // --- Actions ---
  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: number) {
    items.value = items.value.filter(i => i.id !== id)
  }

  function updateQuantity(id: number, quantity: number) {
    const item = items.value.find(i => i.id === id)
    if (item) item.quantity = Math.max(1, quantity)
  }

  function clearCart() {
    items.value = []
    couponCode.value = null
    discountPercent.value = 0
  }

  async function applyCoupon(code: string): Promise<boolean> {
    const res = await fetch(`/api/coupons/${code}`)
    if (!res.ok) return false
    const data = await res.json() as { discount: number }
    couponCode.value = code
    discountPercent.value = data.discount
    return true
  }

  return {
    items,
    couponCode,
    discountPercent,
    totalItems,
    subtotal,
    total,
    isEmpty,
    addItem,
    removeItem,
    updateQuantity,
    clearCart,
    applyCoupon,
  }
})