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
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.
TypeScript Utility Types: The Complete Guide
Master TypeScript's built-in utility types and learn to create your own. Covers Partial, Pick, Omit, Record, and advanced custom types.
Building a Production-Ready REST API with Node.js and TypeScript
Learn to build scalable REST APIs with Node.js, Express, TypeScript, and PostgreSQL. Includes authentication, validation, and error handling.