On this page
Pinia: global state management with defineStore, getters, and actions
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 piniaRegister 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 } = counterAsync 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 → useUiStoreNaming convention: store files use kebab-case, exported functions use use + PascalCase + Store.
Practice
- Todo store: Create a
useTodoStorewithref<Todo[]>([])state, computed getters forcompletedCountandpendingCount, and actions to add, toggle, and remove todos. Connect it to aTodoList.vuecomponent. - Auth store with async action: Create a
useAuthStorewithuser,loading, anderrorstate. Implement alogin(email, password)async action that calls a fake API and sets the user. Use Vue DevTools to inspect the store state. - Persistence: Add
$subscribeto the auth store to persist the user token tolocalStorage. 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.
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,
}
})
Sign in to track your progress