defineStore, state, getters, actions, storeToRefs, плагин персистентности и тестирование сторов
Pinia — официальный стейт-менеджер для Vue 3. Он проще Vuex, полностью типобезопасен и спроектирован специально под Composition API. Pinia рекомендуется командой Vue как замена Vuex.
В небольших приложениях данные передаются через props вниз и emits вверх. Но когда дерево компонентов разрастается, возникает «prop drilling» — данные приходится передавать через 3-5 уровней промежуточных компонентов, которые их даже не используют.
provide/inject частично решает эту проблему, но не даёт централизованного управления состоянием, DevTools-поддержки и возможности отлаживать изменения данных.
Pinia решает эти задачи: глобальное хранилище с реактивным состоянием, доступное из любого компонента, с полной поддержкой Vue DevTools и TypeScript.
Устанавливаем Pinia и регистрируем в приложении:
npm install pinia// src/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')Pinia поддерживает два способа определения стора: Options API стиль (похож на Vuex) и Setup API стиль (похож на <script setup>).
Options API стиль — знаком тем, кто работал с Vuex:
// stores/counter.js
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {
// state — начальное состояние (функция, чтобы работало в SSR)
state: () => ({
count: 0,
name: 'Счётчик'
}),
// getters — вычисляемые значения на основе state
getters: {
doubled: (state) => state.count * 2,
// Геттер может использовать другие геттеры через this
doubledPlusOne(): number {
return this.doubled + 1
}
},
// actions — синхронные и асинхронные методы
actions: {
increment() {
this.count++
},
setName(name) {
this.name = name
},
async fetchInitialCount() {
const response = await fetch('/api/counter')
const data = await response.json()
this.count = data.count
}
}
})Setup API стиль — более гибкий, ближе к Composition API:
// stores/user.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useUserStore = defineStore('user', () => {
// ref() → state
const user = ref(null)
const isLoading = ref(false)
const error = ref(null)
// computed() → getters
const isLoggedIn = computed(() => !!user.value)
const fullName = computed(() =>
user.value ? `${user.value.firstName} ${user.value.lastName}` : ''
)
// function → actions
async function login(credentials) {
isLoading.value = true
error.value = null
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials)
})
if (!response.ok) throw new Error('Неверные данные')
user.value = await response.json()
} catch (e) {
error.value = e.message
} finally {
isLoading.value = false
}
}
function logout() {
user.value = null
}
// Обязательно возвращаем всё, что нужно компонентам
return { user, isLoading, error, isLoggedIn, fullName, login, logout }
})Setup стиль предпочтителен для сложных сторов — он даёт полный доступ к Composition API: watch, watchEffect, composables.
Стор подключается в компоненте через соответствующий хук. Важный момент: нельзя деструктурировать стор напрямую — потеряется реактивность:
<template>
<div>
<!-- Используем свойства стора -->
<p>Счётчик: {{ counter.count }} (удвоено: {{ counter.doubled }})</p>
<button @click="counter.increment()">+1</button>
<!-- Из user store -->
<div v-if="userStore.isLoggedIn">
<p>Привет, {{ userStore.fullName }}!</p>
<button @click="userStore.logout()">Выйти</button>
</div>
</div>
</template>
<script setup>
import { useCounterStore } from '@/stores/counter'
import { useUserStore } from '@/stores/user'
const counter = useCounterStore()
const userStore = useUserStore()
// НЕПРАВИЛЬНО — деструктуризация ломает реактивность:
// const { count, increment } = useCounterStore() // count не реактивен!
</script>storeToRefs позволяет деструктурировать state и getters стора, сохраняя реактивность. Actions можно деструктурировать напрямую (они не реактивны):
<script setup>
import { storeToRefs } from 'pinia'
import { useCounterStore } from '@/stores/counter'
import { useUserStore } from '@/stores/user'
const counterStore = useCounterStore()
const userStore = useUserStore()
// storeToRefs для state и getters — сохраняет реактивность
const { count, doubled, name } = storeToRefs(counterStore)
const { user, isLoggedIn, fullName, isLoading } = storeToRefs(userStore)
// Actions деструктурируем напрямую — они не реактивны
const { increment, setName } = counterStore
const { login, logout } = userStore
</script>
<template>
<!-- Теперь можно использовать напрямую -->
<p>{{ count }} ({{ doubled }})</p>
<button @click="increment">+1</button>
<p v-if="isLoggedIn">{{ fullName }}</p>
</template>Сторы могут использовать друг друга. В Setup стиле это делается через обычный импорт:
// stores/cart.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useUserStore } from './user'
export const useCartStore = defineStore('cart', () => {
const items = ref([])
const total = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
)
const itemCount = computed(() =>
items.value.reduce((sum, item) => sum + item.quantity, 0)
)
function addItem(product) {
// Используем другой стор
const userStore = useUserStore()
if (!userStore.isLoggedIn) {
throw new Error('Необходима авторизация')
}
const existing = items.value.find(i => i.id === product.id)
if (existing) {
existing.quantity++
} else {
items.value.push({ ...product, quantity: 1 })
}
}
function removeItem(productId) {
items.value = items.value.filter(i => i.id !== productId)
}
async function checkout() {
const userStore = useUserStore()
const response = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId: userStore.user.id,
items: items.value
})
})
if (response.ok) {
items.value = []
}
}
return { items, total, itemCount, addItem, removeItem, checkout }
})Pinia предоставляет метод $patch для атомарного обновления нескольких полей состояния:
<script setup>
import { useCounterStore } from '@/stores/counter'
const store = useCounterStore()
// Обновление объектом
store.$patch({
count: 10,
name: 'Новое имя'
})
// Обновление через функцию — удобно для массивов и сложной логики
store.$patch((state) => {
state.items.push({ id: 3, name: 'Новый элемент' })
state.count++
})
// Полный сброс к начальному состоянию
store.$reset()
</script>Pinia позволяет подписываться на изменения state и actions:
import { useCounterStore } from '@/stores/counter'
const store = useCounterStore()
// Подписка на изменения state
const unsubscribe = store.$subscribe((mutation, state) => {
console.log('State изменился:', mutation.type, state)
// Сохраняем в localStorage при каждом изменении
localStorage.setItem('counter', JSON.stringify(state))
})
// Подписка на actions
store.$onAction(({ name, store, args, after, onError }) => {
console.log(`Action "${name}" вызвана с аргументами:`, args)
after((result) => {
console.log(`Action "${name}" завершилась:`, result)
})
onError((error) => {
console.error(`Action "${name}" завершилась с ошибкой:`, error)
})
})
// Отписка при уничтожении компонента
onUnmounted(() => unsubscribe())Плагин pinia-plugin-persistedstate автоматически сохраняет и восстанавливает состояние из localStorage или sessionStorage:
npm install pinia-plugin-persistedstate// main.js
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)// stores/auth.js
export const useAuthStore = defineStore('auth', {
state: () => ({
user: null,
accessToken: null
}),
// persist: true — сохраняет всё состояние
persist: {
// Ключ в localStorage
key: 'auth',
// Только эти поля
pick: ['accessToken'],
// Или использовать sessionStorage
storage: sessionStorage
}
})Pinia легко тестируется — достаточно создать тестовое окружение с createPinia():
// stores/counter.test.js
import { setActivePinia, createPinia } from 'pinia'
import { useCounterStore } from '@/stores/counter'
import { describe, it, expect, beforeEach } from 'vitest'
describe('Counter Store', () => {
beforeEach(() => {
// Создаём свежий pinia для каждого теста
setActivePinia(createPinia())
})
it('начальное значение count = 0', () => {
const store = useCounterStore()
expect(store.count).toBe(0)
})
it('increment увеличивает count', () => {
const store = useCounterStore()
store.increment()
store.increment()
expect(store.count).toBe(2)
})
it('doubled возвращает удвоенное значение', () => {
const store = useCounterStore()
store.count = 5
expect(store.doubled).toBe(10)
})
it('можно замокать action', async () => {
const store = useCounterStore()
// Мокаем fetch для тестирования async action
global.fetch = vi.fn().mockResolvedValue({
json: () => Promise.resolve({ count: 42 })
})
await store.fetchInitialCount()
expect(store.count).toBe(42)
})
})Pinia — элегантный и мощный стейт-менеджер, который органично вписывается в экосистему Vue 3. Setup API стиль даёт полную свободу в организации логики, storeToRefs решает проблему реактивности при деструктуризации, а $patch позволяет атомарно обновлять несколько полей. Плагин персистентности добавляет сохранение состояния буквально в несколько строк.
Вопросы ещё не добавлены
Вопросы для этой подтемы ещё не добавлены.