Pinia is Vue's official state library, replacing Vuex. It is not Vuex-with-a-new-API — its architecture is fundamentally flatter, fully type-inferred without manual module augmentation, and designed around the same primitives (ref, computed) that power components.


Pinia Architecture vs Vuex

Vuex enforces a single global store with nested, optionally-namespaced modules — cross-module access requires string-based module paths (this.$store.state.inventory.products) that TypeScript cannot verify without hand-written module augmentation. Pinia stores are independent, flat units; each store is its own fully-typed unit imported directly, with no root store object gluing them together.

CriteriaVuex 4Pinia
StructureSingle root store, nested namespaced modulesIndependent flat stores, each its own module
TypeScript supportRequires manual augmentation of ComponentCustomProperties; module state/getters are weakly typed by defaultFully inferred end-to-end from store definitions with zero manual augmentation
MutationsRequired — the only way to synchronously change state, for devtools time-travelRemoved entirely — actions mutate state directly; Pinia devtools tracks patches without needing mutations
Cross-store/module accessString-based module paths, or mapState/mapGetters helpers with namespacing boilerplateDirect import and function call — useOtherStore() inside any store or component
Bundle size~10kb~1.5kb (min+gzip), tree-shakeable per store
Devtools time-travelRequires synchronous mutations to snapshot reliablyWorks via reactive state patches, no mutation restriction needed

Option Stores vs Setup Stores

Pinia supports two definition styles. Option Stores mirror the Options API (separate state/getters/actions objects); Setup Stores are written exactly like a <script setup> composable, returning whatever should be public. They are functionally equivalent, but Setup Stores compose better with the rest of the Composition API ecosystem.

CriteriaOption StoreSetup Store
Syntax{ state, getters, actions } objectFunction body using ref/computed/functions, like a composable
Private stateNot possible — everything in state is exposedTrivial — a local ref simply isn't returned from the function
Composable reuse inside a storeAwkward — composables don't map cleanly onto the options object shapeNatural — call any composable directly inside the store function body
Watchers inside store definitionRequires a $subscribe-like pattern or a lifecycle-adjacent workaroundPlain watch/watchEffect calls work exactly as in a component
Learning curve for teams from VuexLower — visually closer to Vuex's shapeSlightly higher — requires Composition API fluency first
Recommended defaultSmall stores, teams mid-migration from VuexNew code, especially anything needing private state or composable reuse
stores/counterOptionStyle.tstypescript
import { defineStore } from 'pinia'

// Option Store — everything in `state` is public; no way to hide internal-only fields.
export const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0 }),
  getters: {
    doubled: (state) => state.count * 2
  },
  actions: {
    increment() {
      this.count++
    }
  }
})
stores/counterSetupStyle.tstypescript
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

// Setup Store — identical runtime behavior, but _internalAuditLog stays private
// simply by not being included in the returned object.
export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)
  const _internalAuditLog: number[] = [] // never exposed — genuinely private

  const doubled = computed(() => count.value * 2)

  function increment(): void {
    _internalAuditLog.push(count.value)
    count.value++
  }

  return { count, doubled, increment }
})

storeToRefs: Destructuring Without Losing Reactivity

A Pinia store instance is itself a reactive object, but plain destructuring (const { count } = useCounterStore()) extracts the current primitive value once and disconnects it from future updates — the same pitfall as destructuring a reactive() object. storeToRefs converts each state/getter property into an individual ref first, preserving the live binding.

CounterDisplay.vuevue
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useCounterStore } from '@/stores/counterSetupStyle'

const store = useCounterStore()

// WRONG: const { count, doubled } = store
// This reads the current value once and never updates — count would be frozen at 0.

// RIGHT: storeToRefs wraps each reactive property in a ref that tracks the store's
// internal state, so destructuring becomes safe.
const { count, doubled } = storeToRefs(store)

// Actions are plain functions, not reactive state — destructure them directly,
// storeToRefs is unnecessary (and would be a no-op) for methods.
const { increment } = store
</script>

<template>
  <button type="button" @click="increment">{{ count }} (doubled: {{ doubled }})</button>
</template>

Cross-Store Communication

Because Pinia stores are just functions, one store can call another store's composable directly inside its own setup function — no registry, no namespacing string, and the call is fully typed since it's a normal function call.

stores/cartStore.tstypescript
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useAuthStore } from './authStore'

export const useCartStore = defineStore('cart', () => {
  const items = ref<{ sku: string; qty: number; price: number }[]>([])

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

  function checkout(): void {
    // Direct cross-store call — fully typed, no string-based lookup.
    const auth = useAuthStore()
    if (!auth.isAuthenticated) {
      throw new Error('Cannot checkout without an authenticated user')
    }
    // proceed with checkout using auth.currentUser
  }

  return { items, subtotal, checkout }
})

Custom Pinia Plugins

A Pinia plugin is a function registered via pinia.use() that receives a context object for every store as it's created, letting you inject shared properties or subscribe to every store's actions/state changes uniformly — the mechanism behind persistence, analytics, and undo/redo libraries in the ecosystem.

plugins/persistencePlugin.tstypescript
import type { PiniaPluginContext } from 'pinia'

interface PersistOptions {
  key?: string
  paths?: string[] // dot-paths of state fields to persist; omit to persist everything
}

// Module augmentation: lets store definitions opt in via defineStore(id, setup, { persist: {...} })
declare module 'pinia' {
  export interface DefineStoreOptionsBase<S, Store> {
    persist?: PersistOptions | boolean
  }
}

export function persistencePlugin(context: PiniaPluginContext): void {
  const { store, options } = context
  const persistOpts = options.persist
  if (!persistOpts) return

  const storageKey = typeof persistOpts === 'object' && persistOpts.key ? persistOpts.key : `pinia-${store.$id}`

  const saved = localStorage.getItem(storageKey)
  if (saved) {
    try {
      store.$patch(JSON.parse(saved))
    } catch {
      // Corrupted or schema-mismatched payload — ignore and start fresh rather than crash the store.
      localStorage.removeItem(storageKey)
    }
  }

  store.$subscribe((_mutation, state) => {
    localStorage.setItem(storageKey, JSON.stringify(state))
  }, { detached: true }) // detached: keep persisting even if the component that first used this store unmounts
}
plugins/analyticsPlugin.tstypescript
import type { PiniaPluginContext } from 'pinia'

export function analyticsPlugin({ store }: PiniaPluginContext): void {
  store.$onAction(({ name, args, after, onError }) => {
    const startedAt = performance.now()

    after((result) => {
      const durationMs = performance.now() - startedAt
      console.info(`[analytics] ${store.$id}.${name}`, { args, durationMs, result })
    })

    onError((error) => {
      console.error(`[analytics] ${store.$id}.${name} failed`, { args, error })
    })
  })
}
main.ts (plugin registration excerpt)typescript
import { createPinia } from 'pinia'
import { persistencePlugin } from './plugins/persistencePlugin'
import { analyticsPlugin } from './plugins/analyticsPlugin'

const pinia = createPinia()
pinia.use(persistencePlugin)
pinia.use(analyticsPlugin)

Practical Example: Checkout & Billing State Machine

A multi-step checkout is a natural fit for interacting Setup Stores: a checkoutStore owning step progression and validation gating, and a billingStore owning payment method state, with the checkout store reading billing validity to decide whether the user may advance.

stores/billingStore.tstypescript
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export interface BillingDetails {
  cardholderName: string
  cardNumberLast4: string
  expiryMonth: number
  expiryYear: number
}

export const useBillingStore = defineStore('billing', () => {
  const details = ref<BillingDetails | null>(null)

  const isValid = computed(() => {
    if (!details.value) return false
    const { cardholderName, cardNumberLast4, expiryMonth, expiryYear } = details.value
    const now = new Date()
    const notExpired =
      expiryYear > now.getFullYear() ||
      (expiryYear === now.getFullYear() && expiryMonth >= now.getMonth() + 1)
    return cardholderName.trim().length > 0 && cardNumberLast4.length === 4 && notExpired
  })

  function setBillingDetails(payload: BillingDetails): void {
    details.value = payload
  }

  return { details, isValid, setBillingDetails }
}, { persist: { key: 'billing-details' } })
stores/checkoutStore.tstypescript
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useBillingStore } from './billingStore'

export type CheckoutStep = 'cart' | 'shipping' | 'billing' | 'review' | 'confirmed'

const STEP_ORDER: CheckoutStep[] = ['cart', 'shipping', 'billing', 'review', 'confirmed']

export const useCheckoutStore = defineStore('checkout', () => {
  const currentStep = ref<CheckoutStep>('cart')
  const shippingAddress = ref<string | null>(null)

  const currentStepIndex = computed(() => STEP_ORDER.indexOf(currentStep.value))

  function canAdvanceFrom(step: CheckoutStep): boolean {
    const billing = useBillingStore() // cross-store read, resolved fresh on each call
    switch (step) {
      case 'cart':
        return true
      case 'shipping':
        return shippingAddress.value !== null
      case 'billing':
        return billing.isValid
      case 'review':
        return true
      default:
        return false
    }
  }

  function advance(): void {
    if (!canAdvanceFrom(currentStep.value)) {
      throw new Error(`Cannot advance from step "${currentStep.value}": validation failed`)
    }
    const nextIndex = currentStepIndex.value + 1
    const next = STEP_ORDER[nextIndex]
    if (next) currentStep.value = next
  }

  function setShippingAddress(address: string): void {
    shippingAddress.value = address
  }

  return { currentStep, shippingAddress, currentStepIndex, canAdvanceFrom, advance, setShippingAddress }
})
CheckoutWizard.vuevue
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useCheckoutStore } from '@/stores/checkoutStore'
import { useBillingStore } from '@/stores/billingStore'

const checkout = useCheckoutStore()
const billing = useBillingStore()

const { currentStep } = storeToRefs(checkout)
const { isValid: isBillingValid } = storeToRefs(billing)

function handleNext(): void {
  try {
    checkout.advance()
  } catch (err) {
    console.error(err)
  }
}
</script>

<template>
  <div class="checkout-wizard">
    <ol class="checkout-wizard__steps">
      <li v-for="step in ['cart', 'shipping', 'billing', 'review', 'confirmed']" :key="step"
        :class="{ 'checkout-wizard__step--active': step === currentStep }">
        {{ step }}
      </li>
    </ol>

    <p v-if="currentStep === 'billing' && !isBillingValid" class="checkout-wizard__warning">
      Enter valid billing details before continuing.
    </p>

    <button type="button" @click="handleNext" :disabled="!checkout.canAdvanceFrom(currentStep)">
      Continue
    </button>
  </div>
</template>

<style scoped>
.checkout-wizard__steps { display: flex; gap: 1rem; list-style: none; padding: 0; }
.checkout-wizard__step--active { font-weight: 700; color: #16a34a; }
.checkout-wizard__warning { color: #b45309; background: #fef3c7; padding: 0.5rem; border-radius: 4px; }
</style>

Module 4 Checkpoint

  1. Q1. Why can a Setup Store keep state genuinely private, while an Option Store cannot?

  2. Q2. What goes wrong if you write `const { count } = useCounterStore()` instead of using storeToRefs?