Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Pinia: управління станом у Vue.js». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Pinia** є офіційною бібліотекою управління станом (state management) для Vue 3. Store визначають через `defineStore`, стан описують як refs, getters - як computeds, і викликають той самий composable у будь-якому компоненті. ```typescript const useCounterStore = defineStore('counter', () => { const count = ref(0) const double = computed(() => count.value * 2) function increment() { count.value++ } return { count, double, increment } }) ``` **Головне:** при деструктуризації стану і getters використовуй `storeToRefs()`, інакше реактивність не збережеться.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**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 | Функція | 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` з 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. 2. **Пряма мутація стану поза actions** ```typescript const store = useCounterStore() store.count++ // Технічно спрацює, але DevTools не побачить зміну, $subscribe теж не спрацює ``` Pinia не кидає помилку в цьому місці, на відміну від Vuex у strict mode. Але ти втрачаєш historію в DevTools і `$subscribe` callbacks не спрацюють коректно. Тримай зміни стану всередині actions. 3. **Забутий `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 } ``` 4. **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-контекст.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.