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.
| Criteria | Vuex 4 | Pinia |
|---|---|---|
| Structure | Single root store, nested namespaced modules | Independent flat stores, each its own module |
| TypeScript support | Requires manual augmentation of ComponentCustomProperties; module state/getters are weakly typed by default | Fully inferred end-to-end from store definitions with zero manual augmentation |
| Mutations | Required — the only way to synchronously change state, for devtools time-travel | Removed entirely — actions mutate state directly; Pinia devtools tracks patches without needing mutations |
| Cross-store/module access | String-based module paths, or mapState/mapGetters helpers with namespacing boilerplate | Direct import and function call — useOtherStore() inside any store or component |
| Bundle size | ~10kb | ~1.5kb (min+gzip), tree-shakeable per store |
| Devtools time-travel | Requires synchronous mutations to snapshot reliably | Works 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.
| Criteria | Option Store | Setup Store |
|---|---|---|
| Syntax | { state, getters, actions } object | Function body using ref/computed/functions, like a composable |
| Private state | Not possible — everything in state is exposed | Trivial — a local ref simply isn't returned from the function |
| Composable reuse inside a store | Awkward — composables don't map cleanly onto the options object shape | Natural — call any composable directly inside the store function body |
| Watchers inside store definition | Requires a $subscribe-like pattern or a lifecycle-adjacent workaround | Plain watch/watchEffect calls work exactly as in a component |
| Learning curve for teams from Vuex | Lower — visually closer to Vuex's shape | Slightly higher — requires Composition API fluency first |
| Recommended default | Small stores, teams mid-migration from Vuex | New code, especially anything needing private state or composable reuse |
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++
}
}
})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.
<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.
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.
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
}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 })
})
})
}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.
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' } })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 }
})<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
Q1. Why can a Setup Store keep state genuinely private, while an Option Store cannot?
Q2. What goes wrong if you write `const { count } = useCounterStore()` instead of using storeToRefs?