Table of Contents
Pinia is the officially recommended state management library for Vue 3. It offers a simpler, more intuitive API than Vuex while providing full TypeScript support out of the box. Let’s explore everything you need to know about Pinia.
Why Pinia Over Vuex?
| Feature | Pinia | Vuex 4 |
|---|---|---|
| TypeScript Support | First-class | Limited |
| Mutations | No (direct state changes) | Required |
| Modules | Flat stores | Nested modules |
| DevTools | Full support | Full support |
| Bundle Size | ~1kb | ~10kb |
| Composition API | Native | Plugin needed |
Installation and Setup
npm install pinia
// main.ts
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 Stores
Option Store Syntax
// stores/counter.ts
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
lastUpdated: null as Date | null,
}),
getters: {
doubleCount: (state) => state.count * 2,
// Getter with parameter
multiplyBy: (state) => {
return (multiplier: number) => state.count * multiplier
},
},
actions: {
increment() {
this.count++
this.lastUpdated = new Date()
},
async fetchCount() {
const response = await fetch('/api/count')
const data = await response.json()
this.count = data.count
},
},
})
Setup Store Syntax (Composition API)
// stores/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
interface User {
id: number
name: string
email: string
role: 'admin' | 'user'
}
export const useUserStore = defineStore('user', () => {
// State
const user = ref<User | null>(null)
const isLoading = ref(false)
const error = ref<string | null>(null)
// Getters
const isAuthenticated = computed(() => user.value !== null)
const isAdmin = computed(() => user.value?.role === 'admin')
const displayName = computed(() => user.value?.name ?? 'Guest')
// Actions
async function login(email: string, password: string) {
isLoading.value = true
error.value = null
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
if (!response.ok) {
throw new Error('Invalid credentials')
}
user.value = await response.json()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Login failed'
throw e
} finally {
isLoading.value = false
}
}
function logout() {
user.value = null
}
async function fetchProfile() {
if (!user.value) return
const response = await fetch('/api/user/profile')
const profile = await response.json()
user.value = { ...user.value, ...profile }
}
return {
// State
user,
isLoading,
error,
// Getters
isAuthenticated,
isAdmin,
displayName,
// Actions
login,
logout,
fetchProfile,
}
})
Using Stores in Components
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// Destructure reactive refs (maintains reactivity)
const { user, isLoading, isAuthenticated, displayName } = storeToRefs(userStore)
// Actions can be destructured directly
const { login, logout } = userStore
async function handleLogin() {
try {
await login('user@example.com', 'password')
} catch {
// Handle error
}
}
</script>
<template>
<div v-if="isLoading">Loading...</div>
<div v-else-if="isAuthenticated">
<p>Welcome, {{ displayName }}!</p>
<button @click="logout">Logout</button>
</div>
<div v-else>
<button @click="handleLogin">Login</button>
</div>
</template>
Store Composition
Stores can use other stores:
// stores/cart.ts
import { defineStore } from 'pinia'
import { useUserStore } from './user'
import { useProductStore } from './product'
interface CartItem {
productId: number
quantity: number
}
export const useCartStore = defineStore('cart', () => {
const userStore = useUserStore()
const productStore = useProductStore()
const items = ref<CartItem[]>([])
const cartTotal = computed(() => {
return items.value.reduce((total, item) => {
const product = productStore.getProductById(item.productId)
return total + (product?.price ?? 0) * item.quantity
}, 0)
})
const discountedTotal = computed(() => {
// Apply user-specific discount
const discount = userStore.isAdmin ? 0.2 : 0
return cartTotal.value * (1 - discount)
})
async function checkout() {
if (!userStore.isAuthenticated) {
throw new Error('Must be logged in to checkout')
}
const response = await fetch('/api/orders', {
method: 'POST',
body: JSON.stringify({
userId: userStore.user?.id,
items: items.value,
}),
})
if (response.ok) {
items.value = []
}
}
return {
items,
cartTotal,
discountedTotal,
checkout,
}
})
Plugins
Persistence Plugin
// plugins/persistence.ts
import { PiniaPluginContext } from 'pinia'
export function piniaPersistedState({ store }: PiniaPluginContext) {
// Load from localStorage
const stored = localStorage.getItem(`pinia-${store.$id}`)
if (stored) {
store.$patch(JSON.parse(stored))
}
// Save on changes
store.$subscribe((mutation, state) => {
localStorage.setItem(`pinia-${store.$id}`, JSON.stringify(state))
})
}
// main.ts
const pinia = createPinia()
pinia.use(piniaPersistedState)
Logger Plugin
// plugins/logger.ts
import { PiniaPluginContext } from 'pinia'
export function piniaLogger({ store }: PiniaPluginContext) {
store.$onAction(({ name, args, after, onError }) => {
const startTime = Date.now()
console.log(`🚀 Action "${name}" started with args:`, args)
after((result) => {
console.log(
`✅ Action "${name}" finished in ${Date.now() - startTime}ms`,
result
)
})
onError((error) => {
console.error(`❌ Action "${name}" failed:`, error)
})
})
}
Custom Properties Plugin
// plugins/api.ts
import { PiniaPluginContext } from 'pinia'
import { api } from '@/lib/api'
declare module 'pinia' {
export interface PiniaCustomProperties {
$api: typeof api
}
}
export function piniaApiPlugin({ store }: PiniaPluginContext) {
store.$api = api
}
// Usage in store
export const useProductStore = defineStore('product', {
actions: {
async fetchProducts() {
// Access $api from plugin
const products = await this.$api.get('/products')
this.products = products
},
},
})
Best Practices
1. Organize Stores by Feature
stores/
├── auth/
│ ├── index.ts # Main auth store
│ └── types.ts # Auth-related types
├── products/
│ ├── index.ts
│ └── types.ts
├── cart/
│ ├── index.ts
│ └── types.ts
└── index.ts # Re-export all stores
2. Use TypeScript Generics for Reusable Patterns
// stores/createAsyncStore.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export function createAsyncStore<T>(
id: string,
fetchFn: () => Promise<T[]>
) {
return defineStore(id, () => {
const items = ref<T[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
const isEmpty = computed(() => items.value.length === 0)
async function fetch() {
isLoading.value = true
error.value = null
try {
items.value = await fetchFn()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Fetch failed'
} finally {
isLoading.value = false
}
}
function reset() {
items.value = []
error.value = null
}
return {
items,
isLoading,
error,
isEmpty,
fetch,
reset,
}
})
}
// Usage
export const useProductStore = createAsyncStore('products', async () => {
const response = await fetch('/api/products')
return response.json()
})
3. Hydration for SSR
// stores/user.ts
export const useUserStore = defineStore('user', () => {
const user = ref<User | null>(null)
// SSR hydration
if (import.meta.env.SSR) {
// Server-side: will be serialized
} else {
// Client-side: check for hydrated state
const hydratedState = window.__PINIA_STATE__?.user
if (hydratedState) {
user.value = hydratedState.user
}
}
return { user }
})
4. Testing Stores
// stores/__tests__/counter.test.ts
import { setActivePinia, createPinia } from 'pinia'
import { describe, it, expect, beforeEach } from 'vitest'
import { useCounterStore } from '../counter'
describe('Counter Store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('increments count', () => {
const store = useCounterStore()
expect(store.count).toBe(0)
store.increment()
expect(store.count).toBe(1)
})
it('computes double count', () => {
const store = useCounterStore()
store.count = 5
expect(store.doubleCount).toBe(10)
})
})
DevTools Integration
Pinia integrates seamlessly with Vue DevTools:
- View all stores and their state
- Time-travel debugging
- Edit state directly
- Track actions and mutations
Conclusion
Pinia offers a modern, TypeScript-first approach to state management in Vue 3:
- Simple API: No mutations, just state and actions
- Type-safe: Full TypeScript support without workarounds
- Modular: Flat store structure, no nested modules
- Composable: Use stores inside other stores
- DevTools: Full debugging support
Migrate from Vuex or start fresh — Pinia makes state management a pleasure.
Using Pinia in production? Share your experience in the comments!
In-Article Ad
Dev Mode
Mahmoud DEVO
Senior Full-Stack Developer
I'm a passionate full-stack developer with 10+ years of experience building scalable web applications. I write about Vue.js, Node.js, PostgreSQL, and modern DevOps practices.
Enjoyed this article?
Subscribe to get more tech content delivered to your inbox.
Related Articles
Typage des props Vue.js avec TypeScript et PropType pour un code robuste
Maitrisez le typage explicite des props dans Vue 3 avec TypeScript. Utilisez PropType et les interfaces pour un code type-safe.
Vue 3 Composition API: 10 Best Practices for Clean Code
Master the Vue 3 Composition API with these essential best practices. Learn how to write maintainable, reusable, and performant Vue components.
Définition de l'état de magasin avec TypeScript et Vue.js
Apprenez à définir et typer l'état de votre store Vue avec TypeScript. Interfaces, génériques reactive() et mutations typées pour un code robuste.