Vue.js Pinia State Management
Pinia is the official state management library for Vue.js, designed to be simple, intuitive, and type-safe. It’s the recommended replacement for Vuex and works seamlessly with both the Composition API and Options API.
What is Pinia?
Pinia is a state management library that provides:
- Intuitive API design
- Excellent TypeScript support
- Devtools integration
- Hot module replacement support
- No mutations needed
- Code splitting support
Installation
Install Pinia in your Vue.js project:
npm install pinia
# or
yarn add pinia
# or
pnpm add piniaBasic Setup
First, create a Pinia instance and add it to your Vue app:
// 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')Creating a Store
Stores are defined using defineStore(). Here’s a basic counter store:
// stores/counter.js
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
name: 'Counter'
}),
getters: {
doubleCount: (state) => state.count * 2,
// Can accept other getters as arguments
tripleCount: () => this.doubleCount * 3
},
actions: {
increment() {
this.count++
},
decrement() {
this.count--
},
reset() {
this.count = 0
},
// Async actions
async fetchCount() {
try {
const response = await fetch('/api/count')
const data = await response.json()
this.count = data.count
} catch (error) {
console.error('Failed to fetch count:', error)
}
}
}
})Using Stores in Components
Composition API Usage
<template>
<div>
<h2>{{ counter.name }}</h2>
<p>Count: {{ counter.count }}</p>
<p>Double: {{ counter.doubleCount }}</p>
<button @click="counter.increment()">+</button>
<button @click="counter.decrement()">-</button>
<button @click="counter.reset()">Reset</button>
<button @click="counter.fetchCount()">Fetch</button>
</div>
</template>
<script setup>
import { useCounterStore } from '@/stores/counter'
const counter = useCounterStore()
</script>Options API Usage
<template>
<div>
<h2>{{ name }}</h2>
<p>Count: {{ count }}</p>
<p>Double: {{ doubleCount }}</p>
<button @click="increment">+</button>
<button @click="decrement">-</button>
</div>
</template>
<script>
import { useCounterStore } from '@/stores/counter'
import { mapState, mapActions } from 'pinia'
export default {
computed: {
...mapState(useCounterStore, ['count', 'name', 'doubleCount'])
},
methods: {
...mapActions(useCounterStore, ['increment', 'decrement'])
}
}
</script>Store Composition
Setup Store Syntax (Composition API Style)
// stores/user.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useUserStore = defineStore('user', () => {
// State
const user = ref(null)
const isLoggedIn = computed(() => !!user.value)
// Getters
const displayName = computed(() => {
return user.value ? `${user.value.firstName} ${user.value.lastName}` : 'Guest'
})
// Actions
function login(userData) {
user.value = userData
}
function logout() {
user.value = null
}
async function fetchUser(userId) {
try {
const response = await fetch(`/api/users/${userId}`)
const userData = await response.json()
login(userData)
} catch (error) {
console.error('Failed to fetch user:', error)
}
}
return {
// State
user,
// Getters
isLoggedIn,
displayName,
// Actions
login,
logout,
fetchUser
}
})Store with TypeScript
Pinia provides excellent TypeScript support:
// stores/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
interface User {
id: number
firstName: string
lastName: string
email: string
}
export const useUserStore = defineStore('user', () => {
const user = ref<User | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
const isLoggedIn = computed(() => !!user.value)
const fullName = computed(() => {
return user.value ? `${user.value.firstName} ${user.value.lastName}` : ''
})
async function login(credentials: { email: string; password: string }) {
loading.value = true
error.value = null
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials)
})
if (!response.ok) {
throw new Error('Login failed')
}
const userData: User = await response.json()
user.value = userData
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unknown error'
} finally {
loading.value = false
}
}
function logout() {
user.value = null
error.value = null
}
return {
user,
loading,
error,
isLoggedIn,
fullName,
login,
logout
}
})Store Plugins
Create plugins to extend store functionality:
// plugins/logger.js
export function loggerPlugin({ store }) {
store.$subscribe((mutation, state) => {
console.log(`Store "${store.$id}" changed:`, {
mutation,
newState: state
})
})
}
// plugins/persistence.js
export function persistencePlugin({ store, options }) {
const key = `pinia-${store.$id}`
// Load state from localStorage
const savedState = localStorage.getItem(key)
if (savedState) {
store.$patch(JSON.parse(savedState))
}
// Save state to localStorage
store.$subscribe((mutation, state) => {
localStorage.setItem(key, JSON.stringify(state))
})
}Use plugins when creating the Pinia instance:
// main.js
import { createPinia } from 'pinia'
import { loggerPlugin, persistencePlugin } from './plugins'
const pinia = createPinia()
pinia.use(loggerPlugin)
pinia.use(persistencePlugin)Store Composition and Modules
Organize related stores in modules:
// stores/auth.js
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem('token'))
const user = ref(null)
const isAuthenticated = computed(() => !!token.value)
function setToken(newToken) {
token.value = newToken
localStorage.setItem('token', newToken)
}
function clearToken() {
token.value = null
user.value = null
localStorage.removeItem('token')
}
return {
token,
user,
isAuthenticated,
setToken,
clearToken
}
})
// stores/products.js
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useProductsStore = defineStore('products', () => {
const products = ref([])
const loading = ref(false)
const error = ref(null)
async function fetchProducts() {
loading.value = true
error.value = null
try {
const response = await fetch('/api/products')
products.value = await response.json()
} catch (err) {
error.value = err.message
} finally {
loading.value = false
}
}
return {
products,
loading,
error,
fetchProducts
}
})Advanced Patterns
Store Composables
Create composables that work with multiple stores:
// composables/useShoppingCart.js
import { computed } from 'vue'
import { useProductsStore } from '@/stores/products'
import { useAuthStore } from '@/stores/auth'
import { useCartStore } from '@/stores/cart'
export function useShoppingCart() {
const productsStore = useProductsStore()
const authStore = useAuthStore()
const cartStore = useCartStore()
const cartItems = computed(() => {
return cartStore.items.map(item => ({
...item,
product: productsStore.products.find(p => p.id === item.productId)
}))
})
const cartTotal = computed(() => {
return cartItems.value.reduce((total, item) => {
return total + (item.product?.price || 0) * item.quantity
}, 0)
})
const canCheckout = computed(() => {
return authStore.isAuthenticated && cartItems.value.length > 0
})
async function addToCart(productId, quantity = 1) {
if (!authStore.isAuthenticated) {
throw new Error('Must be logged in to add items to cart')
}
cartStore.addItem(productId, quantity)
}
return {
cartItems,
cartTotal,
canCheckout,
addToCart
}
}Dynamic Stores
Create stores dynamically:
// stores/dynamic.js
import { defineStore } from 'pinia'
export function createDynamicStore(storeId, initialState = {}) {
return defineStore(storeId, {
state: () => ({
...initialState,
lastUpdated: null
}),
actions: {
updateData(newData) {
Object.assign(this.$state, newData)
this.lastUpdated = new Date().toISOString()
}
}
})
}
// Usage
const userStore = createDynamicStore('user', { name: '', email: '' })
const settingsStore = createDynamicStore('settings', { theme: 'light' })Testing Stores
Test your Pinia stores:
// tests/stores/counter.test.js
import { setActivePinia, createPinia } from 'pinia'
import { useCounterStore } from '@/stores/counter'
describe('Counter Store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('increments count', () => {
const counter = useCounterStore()
expect(counter.count).toBe(0)
counter.increment()
expect(counter.count).toBe(1)
})
it('computes double count', () => {
const counter = useCounterStore()
counter.count = 5
expect(counter.doubleCount).toBe(10)
})
it('resets to initial state', () => {
const counter = useCounterStore()
counter.count = 10
counter.reset()
expect(counter.count).toBe(0)
})
})Best Practices
- Use setup syntax for better TypeScript support and composability
- Keep stores focused on specific domains
- Use composables to combine multiple stores
- Prefer actions over direct state mutations
- Handle loading and error states in async operations
- Use TypeScript for better development experience
- Test your stores separately from components
Migration from Vuex
If you’re migrating from Vuex, here’s a quick comparison:
| Vuex | Pinia |
|---|---|
state | state (or ref() in setup) |
getters | getters (or computed() in setup) |
mutations | Actions (no mutations needed) |
actions | actions (can directly modify state) |
modules | Separate store files |
mapState, mapGetters | Direct store access or composables |
External Resources:
Related Tutorials: