Pinia: управління станом у Vue.js
Pinia є офіційною бібліотекою управління станом (state management) для Vue.js, яка дає компонентам доступ до спільного реактивного стану без передачі пропсів через дерево.
Теорія
TL;DR
- Store у Pinia працює як спільна дошка в офісі: будь-який компонент читає або оновлює дані, зміни одразу видно скрізь
- Немає мутацій, немає модулів - тільки реактивні refs, computeds і звичайні функції
- Рекомендований стиль - Composition API (setup-функція всередині
defineStore); store виглядає як звичайний composable - Використовуй Pinia коли 2+ непов'язаних компоненти потребують спільних даних; для всього іншого вистачає локального
ref() - Vue 2 проекти залишають на Vuex 4; Pinia орієнтована тільки на Vue 3
Базовий приклад
// stores/counter.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0) // стан
const double = computed(() => count.value * 2) // getter
function increment() { count.value++ } // action
return { count, double, increment }
})
// У будь-якому компоненті:
const store = useCounterStore()
store.increment()
console.log(store.double) // 2 - реактивне, оновлюється скрізьdefineStore приймає унікальний ID ('counter') і setup-функцію. Кожен компонент, що викликає useCounterStore(), отримує той самий реактивний екземпляр. Описуєш один раз - використовуєш де завгодно.
Ключова різниця з Vuex
Vuex розділяє зміни стану на мутації (синхронні, через store.commit) і actions (асинхронні, через store.dispatch). Pinia прибирає цей поділ повністю. Пишеш функцію, вона оновлює ref, компоненти перемальовуються. Це скорочує код вдвічі і дає TypeScript повну inference без зайвих оголошень типів. Stores виглядають як composables, які ти вже пишеш кожен день.
Два стилі оголошення store
Pinia підтримує два синтаксиси. Більшість нового коду використовує Composition API стиль:
// Composition API - рекомендований стиль
export const useUserStore = defineStore('user', () => {
const profile = ref<User | null>(null)
const isLoggedIn = computed(() => profile.value !== null)
async function login(email: string, password: string) {
profile.value = await api.login(email, password)
}
function logout() { profile.value = null }
return { profile, isLoggedIn, login, logout }
})Options API стиль також доступний для тих, хто звик до структури Vuex:
// Options API - працює, але TypeScript-inference трохи слабший
export const useUserStore = defineStore('user', {
state: () => ({ name: '', isLoggedIn: false }),
getters: {
initials: (state) => state.name.split(' ').map(n => n[0]).join('')
},
actions: {
async login(email: string, password: string) {
const user = await api.login(email, password)
this.name = user.name
this.isLoggedIn = true
},
logout() { this.$reset() }
}
})Обидва варіанти підтримуються довгостроково. Composition API версія простіше тестується і дає кращий autocomplete у редакторі.
Використання store в компонентах
<script setup>
import { storeToRefs } from 'pinia'
import { useCounterStore } from '@/stores/counter'
const store = useCounterStore()
// storeToRefs зберігає реактивність при деструктуризації стану і getters
const { count, double } = storeToRefs(store)
// Actions деструктуруємо напряму - їм storeToRefs не потрібен
const { increment } = store
</script>
<template>
<p>Count: {{ count }} / Double: {{ double }}</p>
<button @click="increment">+</button>
</template>storeToRefs - це той виклик, який робиш кожного разу при роботі зі store в компоненті. Він загортає кожну властивість стану і getter у ref, щоб деструктуризація не ламала реактивність. Actions - звичайні функції, їм обгортка не потрібна.
Як Pinia реалізує реактивність
При виклику defineStore Pinia створює реактивний проксі через Vue reactive(). Твої refs і computeds загортаються в синглтон, який зберігається в Map за ключем-ID store. Будь-який компонент, що викликає useCounterStore(), отримує той самий проксі. Vue effect system відстежує, які властивості читає кожен компонент, і при зміні значення ставить їх у чергу на перемальовування. DevTools підключаються до цього проксі і надають time-travel debugging та інспекцію патчів.
Pinia vs Vuex
| Функція | Pinia | Vuex 4 |
|---|---|---|
| Стиль API | Composition + Options | Тільки Options |
| Мутації | Не потрібні | Обов'язкові |
| TypeScript | Повна inference | Потребує ручного типування |
| Модулі | Плоскі store за ID | Вкладена структура модулів |
| DevTools | Time-travel + patches | Базове логування |
| Розмір бандлу | ~1.5 КБ | ~10 КБ |
| Підтримка Vue 2 | Через плагін | Нативно |
| Коли використовувати | Нові Vue 3 проекти | Міграція з Vue 2/3 з готовими модулями |
Багато команд обирають Pinia для всіх нових Vue 3 проектів і залишають Vuex тільки там, де міграція зачепила б занадто багато робочого коду.
Коли використовувати Pinia
- Стан авторизації між роутами: один
useUserStoreз getterisLoggedIn - Кошик e-commerce між завантаженнями сторінки: Pinia +
pinia-plugin-persistedstate - Тема оформлення або локаль на рівні всього застосунку: невеликий store, мінімальне налаштування
- Vue 2 проект на Vuex: залишай Vuex, міграція не термінова
- Один ізольований компонент без спільних даних: звичайного
ref()достатньо - Nuxt 3 застосунки: Pinia вбудована і є дефолтним рішенням
Типові помилки
- Деструктуризація стану без
storeToRefs
// Неправильно - count стає звичайним числом без реактивності
const { count } = useCounterStore()
// Правильно - count залишається Ref<number>, компонент реагує на зміни
const { count } = storeToRefs(useCounterStore())Компонент відображає початкове значення і більше не оновлюється. Це найпоширеніша помилка при першому знайомстві з Pinia.
- Пряма мутація стану поза actions
const store = useCounterStore()
store.count++ // Технічно спрацює, але DevTools не побачить зміну, $subscribe теж не спрацюєPinia не кидає помилку в цьому місці, на відміну від Vuex у strict mode. Але ти втрачаєш historію в DevTools і $subscribe callbacks не спрацюють коректно. Тримай зміни стану всередині actions.
- Забутий
awaitв async actions
// Неправильно - стан оновлюється вже після повернення action
async function fetchUser() {
fetch('/api/user').then(r => { user.value = r.json() })
}
// Правильно - стан оновлюється синхронно всередині action
async function fetchUser() {
const data = await fetch('/api/user').then(r => r.json())
user.value = data
}- SSR: розбіжність стану між сервером і клієнтом у Nuxt
В production Nuxt застосунках саме ця проблема найчастіше заскакує команди зненацька. Store виконується на сервері без localStorage, потім клієнт гідратується з іншими збереженими даними.
// Неправильно - сервер і клієнт можуть мати різні значення
const store = useUserStore()
store.profile // null на сервері, заповнено на клієнті з localStorage
// Правильно - гідратуємо тільки на стороні клієнта
if (process.client) store.$hydrate()Де зустрічається в реальних проектах
- Nuxt 3 e-commerce: стан кошика між сторінками, збереження через
pinia-plugin-persistedstate - Vuetify адмін-панелі: тема і налаштування користувача в спільному store
- Multi-step форми з PrimeVue: стан валідації кожного кроку живе в store, кожен крок читає з одного джерела
- Vitest unit-тести:
setActivePinia(createPinia())уbeforeEach, далі store використовується звичайно,vi.mockдля API-викликів
Питання для співбесіди
Q: Як Pinia підтримує реактивність між компонентами?
A: useStore() завжди повертає один і той самий проксі-екземпляр. Vue effect system відстежує, які властивості читає кожен компонент, і при зміні значення планує перемальовування.
Q: Що таке storeToRefs і коли він потрібен?
A: Він загортає кожну властивість стану і getter у ref, щоб деструктуризація не ламала реактивність. Actions - звичайні функції, їм обгортка не потрібна.
Q: Як два store можуть звертатись один до одного?
A: Виклич composable іншого store всередині action, не на верхньому рівні setup-функції. useCartStore може викликати useUserStore() всередині checkout(), щоб перевірити авторизацію.
Q: Як тестувати Pinia store у Vitest?
A: Виклич setActivePinia(createPinia()) у beforeEach. Використовуй store composable напряму і перевіряй стан. Для async - vi.mock.
Q: Яка пастка SSR у Nuxt зі setup-стилем store? (Senior)
A: Setup-функція виконується на сервері без доступу до DOM. Store, що читає localStorage або window, впаде з помилкою. Захищай через if (process.client) і використовуй $hydrate() після mount для синхронізації стану клієнта зі збереженими даними.
Q: Як мігрувати Vuex module до Pinia?
A: Перетвори модуль в один виклик defineStore. Мутації стають прямими оновленнями refs всередині actions. Getters стають computeds. Namespace модуля стає ID store.
Приклади
Store лічильника
// stores/counter.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const double = computed(() => count.value * 2)
function increment() { count.value++ }
function reset() { count.value = 0 }
return { count, double, increment, reset }
})
// Використання
const store = useCounterStore()
store.increment() // count = 1
store.increment() // count = 2
console.log(store.double) // 4
store.reset() // count = 0Будь-який компонент, що викликає useCounterStore(), бачить той самий count. Без пропсів, без подій, без глобальної шини подій.
Кошик e-commerce з async actions
// stores/cart.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useCartStore = defineStore('cart', () => {
const items = ref<{ id: number; name: string; price: number; qty: number }[]>([])
const total = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.qty, 0)
)
async function addItem(productId: number) {
const data = await fetch(`/api/products/${productId}`).then(r => r.json())
const existing = items.value.find(i => i.id === productId)
if (existing) {
existing.qty++
} else {
items.value.push({ ...data, qty: 1 })
}
}
function removeItem(id: number) {
items.value = items.value.filter(i => i.id !== id)
}
return { items, total, addItem, removeItem }
})
// CartView.vue
const cart = useCartStore()
await cart.addItem(42)
console.log(cart.total) // ціна товару 42, перераховано автоматичноtotal перераховується автоматично після завершення addItem. Нічого додатково повідомляти не треба.
Комунікація між store та SSR гідратація
// stores/cart.ts - звертається до user store всередині action
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useUserStore } from './user'
export const useCartStore = defineStore('cart', () => {
const items = ref([])
function checkout() {
const userStore = useUserStore() // безпечно викликати всередині action
if (!userStore.isLoggedIn) {
throw new Error('Потрібна авторизація')
}
// обробка оплати...
}
return { items, checkout }
})
// plugins/pinia-hydrate.ts (Nuxt 3)
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.hooks.hook('app:mounted', () => {
const userStore = useUserStore()
if (process.client) {
userStore.$hydrate() // синхронізуємо стан клієнта з localStorage
}
})
})Не викликай useUserStore() на верхньому рівні setup-функції іншого store. Тільки всередині actions - там гарантовано є Vue-контекст.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.