Skip to main content

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

typescript
// 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 everywhere

defineStore 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:

typescript
// 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:

typescript
// 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

vue
<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

FeaturePiniaVuex 4
API styleComposition + OptionsOptions only
MutationsNot neededRequired
TypeScriptFull inferenceManual typing
ModulesFlat stores per IDNested module tree
DevToolsTime-travel + patchesBasic logging
Bundle size~1.5 KB~10 KB
Vue 2 supportVia pluginNative
When to useVue 3 new projectsVue 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 useUserStore with an isLoggedIn getter
  • 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

  1. Destructuring state without storeToRefs
typescript
// 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.

  1. Mutating state directly outside actions
typescript
const store = useCounterStore() store.count++ // Technically works, but skips DevTools tracking and $subscribe callbacks

Pinia 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.

  1. Missing await in async actions
typescript
// 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 }
  1. 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.

typescript
// 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()) in beforeEach, then use stores normally with vi.mock for 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

typescript
// 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 = 0

Any component calling useCounterStore() sees the same count. No props, no events, no global event bus.

E-commerce cart with async actions

typescript
// 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 automatically

total recomputes automatically after addItem resolves. No manual notification needed anywhere in the tree.

Store-to-store communication

typescript
// 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 ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?