Перейти к содержимому

15. Pinia: управление состоянием

Pinia — официальный state manager для Vue 3. Это как Vuex, но в 10 раз проще, с отличным TypeScript и потрясающими devtools. Автор Vue — Эван Ю — официально рекомендует Pinia вместо Vuex для новых проектов 🚀


Окно терминала
npm install pinia
main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const pinia = createPinia()
createApp(App)
.use(pinia)
.mount('#app')

Pinia предлагает два синтаксиса: Options Store (как Vuex/Options API) и Setup Store (как Composition API). Оба полноценные, выбирай по вкусу!

stores/counter.ts
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {
// state — реактивные данные (как data() в компоненте)
state: () => ({
count: 0,
name: 'Яша',
}),
// getters — вычисляемые свойства (как computed)
getters: {
doubleCount: (state) => state.count * 2,
// Getter использующий другой getter
doubleCountPlusTen(): number {
return this.doubleCount + 10
},
// Getter с аргументом (возвращает функцию)
countPlusFive: (state) => (multiplier: number) => {
return state.count * multiplier
},
},
// actions — методы (как methods, могут быть async)
actions: {
increment() {
this.count++
},
decrement() {
this.count = Math.max(0, this.count - 1)
},
async fetchCount() {
const response = await fetch('/api/count')
const data = await response.json()
this.count = data.count // Прямая мутация в action!
},
},
})
stores/counter.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useCounterStore = defineStore('counter', () => {
// state → ref/reactive
const count = ref(0)
const name = ref('Яша')
// getters → computed
const doubleCount = computed(() => count.value * 2)
const isNegative = computed(() => count.value < 0)
// actions → functions
function increment() {
count.value++
}
function decrement() {
count.value--
}
async function fetchCount() {
const response = await fetch('/api/count')
const data = await response.json()
count.value = data.count
}
// Обязательно возвращаем всё!
return {
count,
name,
doubleCount,
isNegative,
increment,
decrement,
fetchCount,
}
})

<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useCounterStore } from '@/stores/counter'
const store = useCounterStore()
// ❌ НЕЛЬЗЯ деструктурировать напрямую — потеряешь реактивность!
const { count, name } = store // count и name НЕ реактивны!
// ✅ Используй storeToRefs для реактивных свойств
const { count, name, doubleCount } = storeToRefs(store)
// Методы и actions можно деструктурировать напрямую (они функции)
const { increment, decrement, fetchCount } = store
</script>
<template>
<div>
<p>{{ count }} (двойной: {{ doubleCount }})</p>
<button @click="increment">+</button>
<button @click="decrement">-</button>
<!-- Можно вызывать и через store -->
<button @click="store.increment()">+</button>
</div>
</template>

<script setup lang="ts">
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// Обновление одного свойства
userStore.name = 'Катя' // Прямая мутация — работает!
// $patch — несколько свойств сразу
userStore.$patch({
name: 'Катя',
age: 25,
})
// $patch с функцией — для сложных мутаций
userStore.$patch((state) => {
state.items.push({ id: 4, name: 'Новый' })
state.hasChanges = true
// Всё это — одна транзакция в devtools!
})
</script>

<script setup lang="ts">
import { useFormStore } from '@/stores/form'
const formStore = useFormStore()
// Сброс к начальным значениям
function handleCancel() {
formStore.$reset()
}
</script>

⚠️ $reset() работает только для Options Store. В Setup Store нужно реализовать вручную или использовать плагин.


<script setup lang="ts">
import { onUnmounted } from 'vue'
import { useCartStore } from '@/stores/cart'
const cartStore = useCartStore()
// Подписываемся на все изменения стора
const unsubscribe = cartStore.$subscribe((mutation, state) => {
// mutation.type: 'direct' | 'patch object' | 'patch function'
console.log('Тип мутации:', mutation.type)
console.log('Новое состояние:', state)
// Автосохранение в localStorage!
localStorage.setItem('cart', JSON.stringify(state))
})
// Отписываемся при уничтожении компонента
onUnmounted(unsubscribe)
</script>

// Логирование всех actions
store.$onAction(({
name, // Имя action
store, // Экземпляр стора
args, // Аргументы action
after, // Хук после успешного выполнения
onError, // Хук при ошибке
}) => {
const start = Date.now()
console.log(\`Action "\${name}" начат с аргументами:\`, args)
after((result) => {
console.log(\`Action "\${name}" завершён за \${Date.now() - start}мс\`)
console.log('Результат:', result)
})
onError((error) => {
console.error(\`Action "\${name}" упал с ошибкой:\`, error)
})
})

stores/auth.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
interface User {
id: number
name: string
email: string
role: 'admin' | 'user'
avatar: string
}
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null)
const token = ref<string | null>(localStorage.getItem('token'))
const loading = ref(false)
const error = ref<string | null>(null)
const isLoggedIn = computed(() => !!user.value && !!token.value)
const isAdmin = computed(() => user.value?.role === 'admin')
const displayName = computed(() => user.value?.name ?? 'Гость')
async function login(email: string, password: string) {
loading.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('Неверный email или пароль')
}
const data = await response.json()
token.value = data.token
user.value = data.user
// Сохраняем токен
localStorage.setItem('token', data.token)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Ошибка входа'
throw e // Пробрасываем для обработки в компоненте
} finally {
loading.value = false
}
}
async function logout() {
user.value = null
token.value = null
localStorage.removeItem('token')
}
async function fetchMe() {
if (!token.value) return
try {
const response = await fetch('/api/me', {
headers: { Authorization: \`Bearer \${token.value}\` }
})
user.value = await response.json()
} catch {
await logout()
}
}
return {
user, token, loading, error,
isLoggedIn, isAdmin, displayName,
login, logout, fetchMe,
}
})

┌─────────────────┬──────────────────┬──────────────────┐
│ │ Vuex 4 │ Pinia │
├─────────────────┼──────────────────┼──────────────────┤
│ Синтаксис │ Сложный │ Простой │
│ TypeScript │ 😢 Плохо │ ✅ Отлично │
│ Mutations │ Обязательны │ ❌ Не нужны │
│ Модули │ Сложные │ Просто сторы │
│ Devtools │ ✅ │ ✅ Лучше │
│ Размер │ ~10kb │ ~2kb │
│ SSR │ ✅ │ ✅ │
│ Composables │ ❌ │ ✅ Setup Store │
└─────────────────┴──────────────────┴──────────────────┘