14.6 State Management with Pinia
Pinia is the official state management library for Vue 3. Designed as the successor to Vuex, Pinia fully embraces the Composition API, is TypeScript-friendly, and offers a much simpler API. It is the state management solution officially recommended by the Vue team.
Why Pinia?
State management is the pattern of keeping shared data across multiple components in a single central store. For small apps, props and emit are sufficient, but as component trees grow deeper or wider, "props drilling" becomes a problem.
App
├── Header (needs username)
├── Sidebar (needs cart count)
└── Main
└── ProductList
└── ProductCard (needs to add to cart)
When distant components need the same data, Pinia provides the solution.
Pinia vs Vuex Comparison
| Item | Vuex 4 | Pinia |
|---|---|---|
| API style | Options API-centric | Full Composition API support |
| Boilerplate | Heavy (mutations required) | Lightweight (no mutations) |
| TypeScript | Complex type configuration | Automatic type inference |
| DevTools | Supported | Supported (more detailed) |
| Module system | Namespaced modules | Independent stores |
| Bundle size | ~10KB | ~1.5KB |
| Vue official support | Legacy | Currently recommended |
Installation and Basic Setup
npm install pinia
// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.mount('#app')
Defining a Store — Three Approaches
Approach 1: Setup Store (Recommended)
Written exactly like a Composition API setup() function. The most flexible and TypeScript-friendly option.
// stores/counter.js
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
// state: declared with ref()
const count = ref(0)
const name = ref('Counter')
// getters: declared with computed()
const doubleCount = computed(() => count.value * 2)
const displayName = computed(() => `${name.value}: ${count.value}`)
// actions: declared as plain functions
function increment() {
count.value++
}
function decrement() {
count.value--
}
async function fetchCount() {
const response = await fetch('/api/count')
const data = await response.json()
count.value = data.count
}
// return everything you want to expose
return { count, name, doubleCount, displayName, increment, decrement, fetchCount }
})
Approach 2: Options Store
A familiar style for developers coming from Vuex.
// stores/user.js
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
// state
state: () => ({
userId: null,
username: '',
email: '',
isLoggedIn: false,
}),
// getters
getters: {
fullProfile: (state) => `${state.username} <${state.email}>`,
isAdmin: (state) => state.userId === 1,
},
// actions
actions: {
login(userData) {
this.userId = userData.id
this.username = userData.name
this.email = userData.email
this.isLoggedIn = true
},
logout() {
this.$reset() // reset state to initial values
},
async fetchUser(id) {
const res = await fetch(`/api/users/${id}`)
const user = await res.json()
this.login(user)
},
},
})
Approach 3: Mixed Pattern
Choose the style that fits your situation. For new projects, Setup Store is recommended.
Using a Store in Components
<!-- Counter.vue -->
<script setup>
import { useCounterStore } from '@/stores/counter'
import { storeToRefs } from 'pinia'
const counter = useCounterStore()
// Destructure while preserving reactivity: use storeToRefs
const { count, doubleCount, displayName } = storeToRefs(counter)
// Actions can be destructured directly (functions don't need reactivity)
const { increment, decrement } = counter
</script>
<template>
<div class="counter">
<h2>{{ displayName }}</h2>
<p>Current value: {{ count }}</p>
<p>Double value: {{ doubleCount }}</p>
<div class="buttons">
<button @click="decrement">-</button>
<button @click="increment">+</button>
</div>
</div>
</template>
Note: When destructuring a store, use
storeToRefs()to avoid losing reactivity. Actions (functions) can be destructured directly withoutstoreToRefs.
Real-World Example: Shopping Cart
Store Design
// stores/cart.js
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCartStore = defineStore('cart', () => {
// State
const items = ref([])
const isLoading = ref(false)
// Getters
const totalItems = computed(() =>
items.value.reduce((sum, item) => sum + item.quantity, 0)
)
const totalPrice = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
)
const formattedTotal = computed(() =>
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(totalPrice.value)
)
const isEmpty = computed(() => items.value.length === 0)
// Actions
function addItem(product) {
const existing = items.value.find((item) => item.id === product.id)
if (existing) {
existing.quantity += 1
} else {
items.value.push({
id: product.id,
name: product.name,
price: product.price,
image: product.image,
quantity: 1,
})
}
}
function removeItem(productId) {
const index = items.value.findIndex((item) => item.id === productId)
if (index !== -1) {
items.value.splice(index, 1)
}
}
function updateQuantity(productId, quantity) {
const item = items.value.find((item) => item.id === productId)
if (item) {
if (quantity <= 0) {
removeItem(productId)
} else {
item.quantity = quantity
}
}
}
function clearCart() {
items.value = []
}
async function checkout() {
if (isEmpty.value) return
isLoading.value = true
try {
const response = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
items: items.value,
total: totalPrice.value,
}),
})
if (!response.ok) throw new Error('Checkout failed')
const result = await response.json()
clearCart()
return result
} finally {
isLoading.value = false
}
}
return {
items,
isLoading,
totalItems,
totalPrice,
formattedTotal,
isEmpty,
addItem,
removeItem,
updateQuantity,
clearCart,
checkout,
}
})
Shopping Cart Component
<!-- components/ShoppingCart.vue -->
<script setup>
import { useCartStore } from '@/stores/cart'
import { storeToRefs } from 'pinia'
const cart = useCartStore()
const { items, isLoading, totalItems, formattedTotal, isEmpty } = storeToRefs(cart)
async function handleCheckout() {
try {
const result = await cart.checkout()
alert(`Order placed! Order ID: ${result.orderId}`)
} catch (error) {
alert('An error occurred during checkout.')
}
}
</script>
<template>
<div class="cart">
<h2>Cart ({{ totalItems }} items)</h2>
<div v-if="isEmpty" class="cart-empty">
<p>Your cart is empty.</p>
</div>
<ul v-else class="cart-items">
<li v-for="item in items" :key="item.id" class="cart-item">
<img :src="item.image" :alt="item.name" width="60" />
<div class="item-info">
<span class="item-name">{{ item.name }}</span>
<span class="item-price">${{ item.price.toFixed(2) }}</span>
</div>
<div class="quantity-control">
<button @click="cart.updateQuantity(item.id, item.quantity - 1)">-</button>
<span>{{ item.quantity }}</span>
<button @click="cart.updateQuantity(item.id, item.quantity + 1)">+</button>
</div>
<button class="remove-btn" @click="cart.removeItem(item.id)">Remove</button>
</li>
</ul>
<div class="cart-footer">
<div class="total">Total: {{ formattedTotal }}</div>
<button
class="checkout-btn"
:disabled="isEmpty || isLoading"
@click="handleCheckout"
>
{{ isLoading ? 'Processing...' : 'Checkout' }}
</button>
</div>
</div>
</template>
Product Card Component (Using the Same Store from Another Component)
<!-- components/ProductCard.vue -->
<script setup>
import { useCartStore } from '@/stores/cart'
import { storeToRefs } from 'pinia'
defineProps({
product: {
type: Object,
required: true,
},
})
const cart = useCartStore()
const { totalItems } = storeToRefs(cart)
function addToCart(product) {
cart.addItem(product)
// Shared store means ShoppingCart component updates immediately
}
</script>
<template>
<div class="product-card">
<img :src="product.image" :alt="product.name" />
<h3>{{ product.name }}</h3>
<p>${{ product.price.toFixed(2) }}</p>
<button @click="addToCart(product)">
Add to Cart ({{ totalItems }} in cart)
</button>
</div>
</template>
Cross-Store Communication
In Pinia, stores are independent but one store can use another store when needed.
// stores/order.js
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { useCartStore } from './cart'
import { useUserStore } from './user'
export const useOrderStore = defineStore('order', () => {
const orders = ref([])
const cartStore = useCartStore() // use another store
const userStore = useUserStore()
async function placeOrder() {
if (!userStore.isLoggedIn) {
throw new Error('You must be logged in')
}
const order = {
userId: userStore.userId,
items: [...cartStore.items],
total: cartStore.totalPrice,
createdAt: new Date().toISOString(),
}
const response = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(order),
})
const newOrder = await response.json()
orders.value.push(newOrder)
cartStore.clearCart() // clear cart after placing order
return newOrder
}
const orderHistory = computed(() =>
[...orders.value].sort(
(a, b) => new Date(b.createdAt) - new Date(a.createdAt)
)
)
return { orders, orderHistory, placeOrder }
})
Plugin: State Persistence
Persist state to localStorage so it survives page refreshes.
npm install pinia-plugin-persistedstate
// main.js
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
// stores/cart.js (add persist option)
export const useCartStore = defineStore('cart', () => {
// ... store code
}, {
persist: {
key: 'shopping-cart', // localStorage key
storage: localStorage, // sessionStorage also works
paths: ['items'], // only persist specific state (omit for all)
// serializer: { ... } // custom serializer
},
})
Pro Tips
Tip 1: $patch to Update Multiple State Properties at Once
// Individual updates (triggers reactivity multiple times)
store.name = 'New Name'
store.email = 'new@email.com'
store.isActive = true
// Using $patch (triggers reactivity only once)
store.$patch({
name: 'New Name',
email: 'new@email.com',
isActive: true,
})
// $patch with a function (for complex updates)
store.$patch((state) => {
state.items.push(newItem)
state.lastUpdated = new Date()
})
Tip 2: $subscribe to Watch State Changes
const cart = useCartStore()
// Automatically save to localStorage whenever state changes
cart.$subscribe((mutation, state) => {
console.log('Mutation type:', mutation.type) // 'direct' | 'patch object' | 'patch function'
console.log('Payload:', mutation.payload)
localStorage.setItem('cart-backup', JSON.stringify(state.items))
})
Tip 3: $onAction to Hook Into Actions
const store = useCartStore()
store.$onAction(({ name, store, args, after, onError }) => {
console.log(`Action "${name}" started`, args)
after((result) => {
console.log(`Action "${name}" completed:`, result)
})
onError((error) => {
console.error(`Action "${name}" failed:`, error)
})
})
Tip 4: Using with TypeScript
// stores/auth.ts
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
interface User {
id: number
name: string
email: string
role: 'admin' | 'user'
}
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null)
const token = ref<string | null>(null)
const isAuthenticated = computed(() => !!user.value && !!token.value)
const isAdmin = computed(() => user.value?.role === 'admin')
async function login(credentials: { email: string; password: string }): Promise<void> {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials),
})
if (!response.ok) throw new Error('Login failed')
const data: { user: User; token: string } = await response.json()
user.value = data.user
token.value = data.token
}
function logout(): void {
user.value = null
token.value = null
}
return { user, token, isAuthenticated, isAdmin, login, logout }
})
Tip 5: Combining Composables with Stores
// composables/useAuth.js
// A composable wrapping a store — provides a convenient interface for the view layer
import { useAuthStore } from '@/stores/auth'
import { useRouter } from 'vue-router'
import { storeToRefs } from 'pinia'
export function useAuth() {
const authStore = useAuthStore()
const router = useRouter()
const { user, isAuthenticated, isAdmin } = storeToRefs(authStore)
async function loginAndRedirect(credentials) {
await authStore.login(credentials)
await router.push('/dashboard')
}
async function logoutAndRedirect() {
authStore.logout()
await router.push('/login')
}
return {
user,
isAuthenticated,
isAdmin,
loginAndRedirect,
logoutAndRedirect,
}
}
Summary and Migration Guide
| Concept | Vuex | Pinia |
|---|---|---|
| State definition | state: () => ({}) | ref() or state: () => ({}) |
| Getter | getters: {} | computed() or getters: {} |
| Action | actions: {} | Plain function or actions: {} |
| Mutation | Required (sync only) | None (actions replace them) |
| Module system | modules: {} | Separate defineStore files |
| Store access | this.$store / useStore() | useXxxStore() |
| Namespacing | namespaced: true | Store ID acts as namespace |
When migrating from Vuex to Pinia:
- Remove mutations — merge them into actions
- Replace
useStore()withuseXxxStore() - Remove
store.commit()— call actions directly - Replace
store.dispatch()withstore.actionName()