10 KiB
10 KiB
State Management with Pinia
Basic Store Setup
// stores/counter.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
// Setup Stores (Composition API style) - RECOMMENDED
export const useCounterStore = defineStore('counter', () => {
// State
const count = ref(0)
const name = ref('Counter')
// Getters (computed)
const doubleCount = computed(() => count.value * 2)
const isEven = computed(() => count.value % 2 === 0)
// Actions
function increment() {
count.value++
}
function decrement() {
count.value--
}
function reset() {
count.value = 0
}
async function incrementAsync() {
await new Promise(resolve => setTimeout(resolve, 1000))
count.value++
}
return {
// State
count,
name,
// Getters
doubleCount,
isEven,
// Actions
increment,
decrement,
reset,
incrementAsync
}
})
// Usage in component
<script setup lang="ts">
import { useCounterStore } from '@/stores/counter'
import { storeToRefs } from 'pinia'
const counter = useCounterStore()
// Use storeToRefs to maintain reactivity when destructuring
const { count, doubleCount, isEven } = storeToRefs(counter)
// Actions can be destructured directly (they don't need refs)
const { increment, decrement } = counter
</script>
<template>
<div>
<p>Count: {{ count }}</p>
<p>Double: {{ doubleCount }}</p>
<p>Is Even: {{ isEven }}</p>
<button @click="increment">+</button>
<button @click="decrement">-</button>
</div>
</template>
Options Store (Alternative Style)
// stores/user.ts
import { defineStore } from 'pinia'
interface User {
id: number
name: string
email: string
}
interface UserState {
user: User | null
users: User[]
loading: boolean
}
export const useUserStore = defineStore('user', {
// State
state: (): UserState => ({
user: null,
users: [],
loading: false
}),
// Getters
getters: {
isLoggedIn: (state) => state.user !== null,
userCount: (state) => state.users.length,
// Getter with parameters
getUserById: (state) => {
return (userId: number) => state.users.find(u => u.id === userId)
},
// Getter accessing other getters
activeUserCount(): number {
return this.users.filter(u => u.isActive).length
}
},
// Actions
actions: {
async fetchUsers() {
this.loading = true
try {
const response = await fetch('/api/users')
this.users = await response.json()
} catch (error) {
console.error('Failed to fetch users:', error)
} finally {
this.loading = false
}
},
async login(email: string, password: string) {
this.loading = true
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
})
this.user = await response.json()
} catch (error) {
console.error('Login failed:', error)
throw error
} finally {
this.loading = false
}
},
logout() {
this.user = null
},
// Action calling another action
async refreshUserData() {
if (this.user) {
await this.fetchUsers()
}
}
}
})
Store with TypeScript
// stores/todos.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
interface Todo {
id: number
title: string
completed: boolean
createdAt: Date
}
type TodoFilter = 'all' | 'active' | 'completed'
export const useTodoStore = defineStore('todos', () => {
// State
const todos = ref<Todo[]>([])
const filter = ref<TodoFilter>('all')
const loading = ref(false)
const error = ref<string | null>(null)
// Getters
const filteredTodos = computed(() => {
switch (filter.value) {
case 'active':
return todos.value.filter(t => !t.completed)
case 'completed':
return todos.value.filter(t => t.completed)
default:
return todos.value
}
})
const completedCount = computed(() =>
todos.value.filter(t => t.completed).length
)
const activeCount = computed(() =>
todos.value.filter(t => !t.completed).length
)
// Actions
async function fetchTodos() {
loading.value = true
error.value = null
try {
const response = await fetch('/api/todos')
if (!response.ok) throw new Error('Failed to fetch todos')
todos.value = await response.json()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Unknown error'
} finally {
loading.value = false
}
}
function addTodo(title: string) {
const newTodo: Todo = {
id: Date.now(),
title,
completed: false,
createdAt: new Date()
}
todos.value.push(newTodo)
}
function toggleTodo(id: number) {
const todo = todos.value.find(t => t.id === id)
if (todo) {
todo.completed = !todo.completed
}
}
function deleteTodo(id: number) {
const index = todos.value.findIndex(t => t.id === id)
if (index > -1) {
todos.value.splice(index, 1)
}
}
function setFilter(newFilter: TodoFilter) {
filter.value = newFilter
}
function clearCompleted() {
todos.value = todos.value.filter(t => !t.completed)
}
return {
// State
todos,
filter,
loading,
error,
// Getters
filteredTodos,
completedCount,
activeCount,
// Actions
fetchTodos,
addTodo,
toggleTodo,
deleteTodo,
setFilter,
clearCompleted
}
})
Accessing Other Stores
// stores/cart.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useUserStore } from './user'
import { useProductStore } from './product'
interface CartItem {
productId: number
quantity: number
}
export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([])
const userStore = useUserStore()
const productStore = useProductStore()
const total = computed(() => {
return items.value.reduce((sum, item) => {
const product = productStore.getProductById(item.productId)
return sum + (product?.price || 0) * item.quantity
}, 0)
})
function addItem(productId: number, quantity = 1) {
const existingItem = items.value.find(i => i.productId === productId)
if (existingItem) {
existingItem.quantity += quantity
} else {
items.value.push({ productId, quantity })
}
}
async function checkout() {
if (!userStore.isLoggedIn) {
throw new Error('User must be logged in to checkout')
}
// Checkout logic
const order = {
userId: userStore.user?.id,
items: items.value,
total: total.value
}
// Make API call
await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(order)
})
items.value = []
}
return { items, total, addItem, checkout }
})
Store Plugins
// plugins/pinia-logger.ts
import { PiniaPluginContext } from 'pinia'
export function piniaLogger({ store }: PiniaPluginContext) {
store.$subscribe((mutation, state) => {
console.log(`[${store.$id}]:`, mutation.type, mutation.payload)
console.log('New state:', state)
})
}
// main.ts
import { createPinia } from 'pinia'
import { piniaLogger } from './plugins/pinia-logger'
const pinia = createPinia()
pinia.use(piniaLogger)
app.use(pinia)
Persistence Plugin
// Install: npm install pinia-plugin-persistedstate
// main.ts
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
// stores/settings.ts
export const useSettingsStore = defineStore('settings', () => {
const theme = ref<'light' | 'dark'>('light')
const language = ref('en')
function setTheme(newTheme: 'light' | 'dark') {
theme.value = newTheme
}
return { theme, language, setTheme }
}, {
persist: true // Auto-persist to localStorage
})
// Advanced persistence
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(null)
const user = ref<User | null>(null)
return { token, user }
}, {
persist: {
key: 'auth-storage',
storage: sessionStorage,
paths: ['token'] // Only persist token, not user
}
})
Store Testing
// stores/__tests__/counter.spec.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 counter = useCounterStore()
expect(counter.count).toBe(0)
counter.increment()
expect(counter.count).toBe(1)
})
it('doubles count', () => {
const counter = useCounterStore()
counter.count = 5
expect(counter.doubleCount).toBe(10)
})
it('resets count', () => {
const counter = useCounterStore()
counter.count = 10
counter.reset()
expect(counter.count).toBe(0)
})
})
Quick Reference
| Pattern | Use Case |
|---|---|
| Setup stores | Composition API style (recommended) |
| Options stores | Traditional Vuex-like syntax |
storeToRefs() |
Maintain reactivity when destructuring |
store.$subscribe() |
Watch for state changes |
store.$patch() |
Batch state updates |
store.$reset() |
Reset state to initial |
| Plugins | Add global functionality (logger, persistence) |
| Accessing stores | Use other stores in actions |
| Testing | Use setActivePinia() for isolated tests |