Pinia: state management in Vue.js
Pinia is Vue 3's official state management library, a store system that lets any component read and update shared reactive state without passing props through the component tree.
Theory
TL;DR
- Pinia stores work like a shared whiteboard in an office: any component reads or updates the data, changes show up everywhere immediately
- No mutations, no modules - just reactive refs, computeds, and plain functions
- Composition API style (setup function inside
defineStore) is the recommended approach; stores feel like regular composables - Use Pinia when 2+ unrelated components need the same data; local
ref()covers everything else - Vue 2 projects should stay on Vuex 4; Pinia targets Vue 3 only
Quick example
// stores/counter.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0) // state
const double = computed(() => count.value * 2) // getter
function increment() { count.value++ } // action
return { count, double, increment }
})
// In any component:
const store = useCounterStore()
store.increment()
console.log(store.double) // 2 - reactive, updates everywheredefineStore takes a unique ID ('counter') and a setup function. Every component calling useCounterStore() gets the same reactive instance back. Define once, use anywhere.
Key difference from Vuex
Vuex splits state changes into mutations (synchronous, committed with store.commit) and actions (async, dispatched with store.dispatch). Pinia drops that split entirely. You write a function, it updates a ref, components re-render. That cuts boilerplate in half and gives TypeScript full inference without extra type declarations. Stores look like composables you already write every day.
Two store styles
Pinia supports two syntaxes. Most new code uses the Composition API style:
// Composition API style - recommended
export const useUserStore = defineStore('user', () => {
const profile = ref<User | null>(null)
const isLoggedIn = computed(() => profile.value !== null)
async function login(email: string, password: string) {
profile.value = await api.login(email, password)
}
function logout() { profile.value = null }
return { profile, isLoggedIn, login, logout }
})The Options API style is also valid, closer to the Vuex mental model:
// Options API style - works, slightly weaker TypeScript inference
export const useUserStore = defineStore('user', {
state: () => ({ name: '', isLoggedIn: false }),
getters: {
initials: (state) => state.name.split(' ').map(n => n[0]).join('')
},
actions: {
async login(email: string, password: string) {
const user = await api.login(email, password)
this.name = user.name
this.isLoggedIn = true
},
logout() { this.$reset() }
}
})Both styles are supported long-term. The Composition API version is easier to test and gives better editor autocomplete.
Using a store in components
<script setup>
import { storeToRefs } from 'pinia'
import { useCounterStore } from '@/stores/counter'
const store = useCounterStore()
// storeToRefs keeps state and getters reactive when you destructure
const { count, double } = storeToRefs(store)
// Actions are plain functions, no wrapper needed
const { increment } = store
</script>
<template>
<p>Count: {{ count }} / Double: {{ double }}</p>
<button @click="increment">+</button>
</template>storeToRefs is the one API you call every single time you use a store in a component. It wraps each state property and getter in a ref so destructuring does not break reactivity. Actions are plain functions, so they skip the wrapper.
How Pinia handles reactivity
When you call defineStore, Pinia creates a reactive proxy via Vue's reactive(). Your refs and computeds are wrapped in a singleton stored in a Map, keyed by the store ID. Any component that calls useCounterStore() gets that same proxy back. Vue's effect system tracks which properties each component reads, so writes automatically schedule re-renders. DevTools connect to this proxy to enable time-travel debugging and patch inspection.
Pinia vs Vuex
| Feature | Pinia | Vuex 4 |
|---|---|---|
| API style | Composition + Options | Options only |
| Mutations | Not needed | Required |
| TypeScript | Full inference | Manual typing |
| Modules | Flat stores per ID | Nested module tree |
| DevTools | Time-travel + patches | Basic logging |
| Bundle size | ~1.5 KB | ~10 KB |
| Vue 2 support | Via plugin | Native |
| When to use | Vue 3 new projects | Vue 2/3 migration with existing modules |
Many teams default to Pinia for any new Vue 3 project and only keep Vuex where a migration would touch too much working code.
When to use Pinia
- Auth state shared across routes: one
useUserStorewith anisLoggedIngetter - Shopping cart persisted across page loads: Pinia +
pinia-plugin-persistedstate - App-wide theme or locale: small store, almost no setup
- Vue 2 codebase already on Vuex: keep Vuex, migration is not urgent
- Single isolated component with no shared data: plain
ref()is enough - Nuxt 3 apps: Pinia is built in and the default recommendation
Common mistakes
- Destructuring state without
storeToRefs
// Wrong - count becomes a plain number, no longer reactive
const { count } = useCounterStore()
// Right - count stays a Ref<number>, component updates on changes
const { count } = storeToRefs(useCounterStore())The component renders once with the initial value and never updates again. This is the single most common Pinia mistake.
- Mutating state directly outside actions
const store = useCounterStore()
store.count++ // Technically works, but skips DevTools tracking and $subscribe callbacksPinia does not throw here, unlike Vuex in strict mode. But you lose change history in DevTools and any $subscribe listeners will not fire. Keep mutations inside store actions.
- Missing
awaitin async actions
// Wrong - state updates after the action has already returned
async function fetchUser() {
fetch('/api/user').then(r => { user.value = r.json() })
}
// Right - state updates synchronously relative to the awaiting caller
async function fetchUser() {
const data = await fetch('/api/user').then(r => r.json())
user.value = data
}- SSR hydration mismatch in Nuxt
In production Nuxt apps, this is the issue that catches teams most off guard. The store runs on the server without localStorage, then the client hydrates with different persisted data.
// Wrong - server and client may have different values
const store = useUserStore()
store.profile // null on server, populated on client from localStorage
// Right - hydrate only on the client side
if (process.client) store.$hydrate()Real-world usage
- Nuxt 3 e-commerce: cart state across pages, saved with
pinia-plugin-persistedstate - Vuetify admin dashboards: theme and user preferences in a shared store
- Multi-step forms with PrimeVue: validation state per step lives in a store, each step reads from the same source
- Vitest unit tests:
setActivePinia(createPinia())inbeforeEach, then use stores normally withvi.mockfor async calls
Follow-up questions
Q: How does Pinia keep state reactive across components?
A: useStore() returns the same proxy instance every time. Vue's effect system tracks which properties each component reads and triggers re-renders on writes.
Q: What is storeToRefs and when do you need it?
A: It wraps each state property and getter in a ref so destructuring preserves reactivity. Actions are plain functions and do not need the wrapper.
Q: How do two stores reference each other?
A: Call the other store's composable inside an action, not at the top level of the setup function. useCartStore can call useUserStore() inside checkout() to check login status safely.
Q: How do you test a Pinia store with Vitest?
A: Call setActivePinia(createPinia()) in beforeEach. Use the store composable directly and assert state changes. Use vi.mock for API dependencies.
Q: What is the SSR gotcha with setup-style stores in Nuxt? (Senior level)
A: The setup function runs on the server without DOM access. Any store that reads localStorage or window will throw. Guard reads with if (process.client) and use $hydrate() after mount to sync client state with persisted data.
Q: How would you migrate a Vuex module to Pinia?
A: Flatten the module into one defineStore call. Convert mutations to direct ref updates inside actions. Getters become computeds. The module namespace becomes the store ID.
Examples
Counter store
// stores/counter.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const double = computed(() => count.value * 2)
function increment() { count.value++ }
function reset() { count.value = 0 }
return { count, double, increment, reset }
})
// Usage
const store = useCounterStore()
store.increment() // count = 1
store.increment() // count = 2
console.log(store.double) // 4
store.reset() // count = 0Any component calling useCounterStore() sees the same count. No props, no events, no global event bus.
E-commerce cart with async actions
// stores/cart.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useCartStore = defineStore('cart', () => {
const items = ref<{ id: number; name: string; price: number; qty: number }[]>([])
const total = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.qty, 0)
)
async function addItem(productId: number) {
const data = await fetch(`/api/products/${productId}`).then(r => r.json())
const existing = items.value.find(i => i.id === productId)
if (existing) {
existing.qty++
} else {
items.value.push({ ...data, qty: 1 })
}
}
function removeItem(id: number) {
items.value = items.value.filter(i => i.id !== id)
}
return { items, total, addItem, removeItem }
})
// CartView.vue
const cart = useCartStore()
await cart.addItem(42)
console.log(cart.total) // price of item 42, recomputed automaticallytotal recomputes automatically after addItem resolves. No manual notification needed anywhere in the tree.
Store-to-store communication
// stores/cart.ts - calls useUserStore inside an action
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useUserStore } from './user'
export const useCartStore = defineStore('cart', () => {
const items = ref([])
function checkout() {
const userStore = useUserStore() // safe to call inside an action
if (!userStore.isLoggedIn) {
throw new Error('Login required')
}
// process payment...
}
return { items, checkout }
})
// plugins/pinia-hydrate.ts (Nuxt 3)
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.hooks.hook('app:mounted', () => {
const userStore = useUserStore()
if (process.client) {
userStore.$hydrate() // sync client state with persisted localStorage data
}
})
})Never call useUserStore() at the top level of another store's setup function. Only call it inside actions, where a Vue context is guaranteed to exist.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.