Skip to main content
Advertisement

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

ItemVuex 4Pinia
API styleOptions API-centricFull Composition API support
BoilerplateHeavy (mutations required)Lightweight (no mutations)
TypeScriptComplex type configurationAutomatic type inference
DevToolsSupportedSupported (more detailed)
Module systemNamespaced modulesIndependent stores
Bundle size~10KB~1.5KB
Vue official supportLegacyCurrently 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

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 without storeToRefs.


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

ConceptVuexPinia
State definitionstate: () => ({})ref() or state: () => ({})
Gettergetters: {}computed() or getters: {}
Actionactions: {}Plain function or actions: {}
MutationRequired (sync only)None (actions replace them)
Module systemmodules: {}Separate defineStore files
Store accessthis.$store / useStore()useXxxStore()
Namespacingnamespaced: trueStore ID acts as namespace

When migrating from Vuex to Pinia:

  1. Remove mutations — merge them into actions
  2. Replace useStore() with useXxxStore()
  3. Remove store.commit() — call actions directly
  4. Replace store.dispatch() with store.actionName()
Advertisement