Skip to main content

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

Базовий приклад

typescript
// 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 стиль:

typescript
// 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:

typescript
// 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 в компонентах

vue
<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

ФункціяPiniaVuex 4
Стиль APIComposition + OptionsТільки Options
МутаціїНе потрібніОбов'язкові
TypeScriptПовна inferenceПотребує ручного типування
МодуліПлоскі store за IDВкладена структура модулів
DevToolsTime-travel + patchesБазове логування
Розмір бандлу~1.5 КБ~10 КБ
Підтримка Vue 2Через плагінНативно
Коли використовуватиНові Vue 3 проектиМіграція з Vue 2/3 з готовими модулями

Багато команд обирають Pinia для всіх нових Vue 3 проектів і залишають Vuex тільки там, де міграція зачепила б занадто багато робочого коду.

Коли використовувати Pinia

  • Стан авторизації між роутами: один useUserStore з getter isLoggedIn
  • Кошик e-commerce між завантаженнями сторінки: Pinia + pinia-plugin-persistedstate
  • Тема оформлення або локаль на рівні всього застосунку: невеликий store, мінімальне налаштування
  • Vue 2 проект на Vuex: залишай Vuex, міграція не термінова
  • Один ізольований компонент без спільних даних: звичайного ref() достатньо
  • Nuxt 3 застосунки: Pinia вбудована і є дефолтним рішенням

Типові помилки

  1. Деструктуризація стану без storeToRefs
typescript
// Неправильно - count стає звичайним числом без реактивності const { count } = useCounterStore() // Правильно - count залишається Ref<number>, компонент реагує на зміни const { count } = storeToRefs(useCounterStore())

Компонент відображає початкове значення і більше не оновлюється. Це найпоширеніша помилка при першому знайомстві з Pinia.

  1. Пряма мутація стану поза actions
typescript
const store = useCounterStore() store.count++ // Технічно спрацює, але DevTools не побачить зміну, $subscribe теж не спрацює

Pinia не кидає помилку в цьому місці, на відміну від Vuex у strict mode. Але ти втрачаєш historію в DevTools і $subscribe callbacks не спрацюють коректно. Тримай зміни стану всередині actions.

  1. Забутий await в async actions
typescript
// Неправильно - стан оновлюється вже після повернення 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 }
  1. SSR: розбіжність стану між сервером і клієнтом у Nuxt

В production Nuxt застосунках саме ця проблема найчастіше заскакує команди зненацька. Store виконується на сервері без localStorage, потім клієнт гідратується з іншими збереженими даними.

typescript
// Неправильно - сервер і клієнт можуть мати різні значення 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 лічильника

typescript
// 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

typescript
// 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 гідратація

typescript
// 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-контекст.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?