Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Як керувати станом в Nuxt 3?». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Управління станом у Nuxt 3** будується на `useState` для простих спільних значень з автоматичною SSR-гідрацією і Pinia для сторів з діями та DevTools. ```ts const theme = useState('theme', () => 'light'); // SSR-безпечний, глобально спільний ``` **Ключове:** `useState` для 1-5 примітивів, Pinia для авторизації, кошика і API-логіки.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**Управління станом у Nuxt 3** базується на двох інструментах: `useState` для простих спільних значень без жодного налаштування і Pinia для сторів з діями, getters та підтримкою DevTools. ## Теорія ### TL;DR - `useState` - це спільний холодильник в офісі: всі бачать одне значення, SSR-гідрація працює автоматично - Pinia - повноцінна кухня з кількома кухарями, рецептами і вікном у процес (DevTools) - Головна різниця: `useState` сам серіалізує стан для SSR; Pinia дає дії, computed getters, плагіни і масштабується до 50+ сторів - Менше 5 примітивних значень без логіки? `useState`. Авторизація, кошик, API-запити? Pinia. ### Швидкий приклад ```vue // composables/useTheme.ts export const useTheme = () => { const theme = useState('theme', () => 'light'); // SSR-безпечний, глобально спільний const toggle = () => { theme.value = theme.value === 'light' ? 'dark' : 'light'; }; return { theme, toggle }; }; // pages/index.vue const { theme, toggle } = useTheme(); // theme.value === 'light' на сервері і клієнті - без розбіжностей ``` Рядковий ключ `'theme'` і робить значення спільним. Два компоненти, які викликають `useTheme()`, отримують один і той самий ref, а не два окремих. Цей нюанс губить більшість розробників з першого разу. ### Ключова різниця `useState` реєструє реактивний ref у runtime-контексті Nitro під час SSR. Значення автоматично серіалізується в HTML-payload і гідрується на клієнті без додаткових налаштувань. Pinia будує стори через Vue's `effectScope` для кожного стору, що дає ізольовану реактивність, асинхронні дії та computed getters. Але Pinia потребує `@pinia/nuxt` у модулях і продуманої ініціалізації сторів на сервері. ### Коли що використовувати - Тема, локаль, простий UI-прапорець: `useState`. Один рядок - готово. - Локальні дані компонента, які нікому більше не потрібні: `ref` або `reactive`. - Авторизація, кошик, форми з API-запитами: Pinia з діями. - Фільтровані списки, обчислені підсумки: Pinia computed getters. - Стан у middleware або плагінах: `useState` або Nuxt runtime config. ### Порівняльна таблиця | Функціональність | useState | Pinia | |---|---|---| | **Налаштування** | Нуль boilerplate | `defineStore` + `@pinia/nuxt` у модулях | | **SSR-безпека** | Автоматична серіалізація і гідрація | Підтримується через `useNuxtApp().$pinia` | | **Реактивність** | Vue ref під капотом | Повна Vue-реактивність (ref, computed, дії) | | **DevTools** | Немає | Pinia DevTools з time travel | | **Масштаб** | 1-5 примітивних значень | Необмежена кількість сторів, плагіни, персистентність | | **Використовувати для** | Лічильники, теми, локаль у невеликих додатках | Авторизація, CRUD, e-commerce у production | ### Як це працює під капотом Під час SSR `useState` реєструє ref у runtime-контексті Nitro. При рендері сторінки Nitro серіалізує значення ref у HTML-payload. Клієнт зчитує це значення і гідрується без повторного запиту. Сервер і клієнт завжди збігаються. Pinia працює інакше. Кожен стор запускається всередині Vue's `effectScope`, що дає ізольовану реактивність. Nuxt автоматично імпортує `$pinia` зі своєї runtime-конфігурації. Стан стору серіалізується в `useNuxtApp().payload` на сервері і відновлюється на клієнті. ### Типові помилки **1. Зміна `useState` тільки в `onMounted`** Сервер рендерить початкове значення. Клієнт змінює його після монтування. Vue кидає помилку hydration mismatch. ```js // Неправильно: сервер бачить 0, клієнт стрибає до 5 const count = useState('count', () => 0); onMounted(() => { count.value = 5; }); // Правильно: отримуємо значення під час SSR const { data: count } = useAsyncData('count', () => $fetch('/api/count')); ``` На Reddit у r/Nuxt ця скарга зустрічається щотижня: "лічильник на сервері 0, на клієнті 5." Рішення завжди одне - `useAsyncData`. **2. Використання `ref` замість `useState` для спільних значень** Звичайний `ref` існує всередині одного екземпляра composable. Кожен компонент, що викликає composable, отримує свою копію. До того ж вона скидається при навігації. ```js // Неправильно: не спільний, скидається при переході const count = ref(0); // Правильно: спільний між компонентами і навігаціями const count = useState('count', () => 0); ``` **3. Пропуск ініціалізації Pinia для SSR** Модуль `@pinia/nuxt` бере на себе все налаштування. Без нього в `nuxt.config.ts` стори будуть порожніми після гідрації. ```js // nuxt.config.ts export default defineNuxtConfig({ modules: ['@pinia/nuxt'] }); ``` **4. Глибоко вкладені об'єкти в `useState`** Чисто серіалізується тільки верхній рівень. Вкладені структури можуть давати часткову серіалізацію. Розгорни структуру даних або перенеси її в Pinia з `toRaw()` там, де потрібно. ### Де зустрічається в реальних проектах - Shopsys Nuxt e-commerce модуль: Pinia для кошика й замовлень, `useState` для теми і локалі - Документація nuxt.com (Nuxt Content v2): `useState` для фільтрів пошуку - Sidebase Nuxt Auth: Pinia-стори для сесій, `useState` для UI-прапорців - Strapi + Nuxt decoupled CMS: Pinia для entities з API ### Типові питання на співбесіді **Q:** Що спричиняє SSR hydration mismatch з `useState`? **A:** Сервер серіалізує початкове значення `useState` в HTML-payload. Якщо клієнт змінює його до завершення гідрації (наприклад, в `onMounted`), Vue кидає помилку розбіжності. Вирішується через `useAsyncData` або читання з `useNuxtApp().payload` на сервері. **Q:** Коли Pinia перевершує `useState`? **A:** Коли є більше трьох пов'язаних значень, потрібні асинхронні дії для координації API-запитів, або важливий доступ до DevTools. Pinia без проблем масштабується до 50+ сторів у production. **Q:** Як зберігати стан Pinia між оновленнями сторінки? **A:** Додай `@pinia-plugin-persistedstate/nuxt` у модулі і постав `persist: true` у визначенні стору. Серіалізує в cookies або localStorage, сумісний з SSR. **Q:** Яка різниця між `useState` і `useNuxtApp().payload`? **A:** Payload - статичний, існує тільки під час SSR на один запит. `useState` - реактивний, можна змінювати на клієнті після гідрації. **Q:** Як розмежувати стан без глобальних витоків в island-компонентах? **A:** Додавай унікальний префікс до ключів `useState`, наприклад `'cart-' + userId`. Для Pinia - використовуй composables з `shallowRef` всередині island-компонентів і уникай глобальних сторів у блоках `<ClientOnly>`. ## Приклади ### Базовий: спільний лічильник через useState ```ts // composables/useCounter.ts export const useCounter = () => { const count = useState('counter', () => 0); const increment = () => count.value++; const decrement = () => count.value--; return { count, increment, decrement }; }; // ComponentA.vue та ComponentB.vue - один виклик, один ref const { count, increment } = useCounter(); // Increment у ComponentA одразу оновлює count у ComponentB ``` Обидва компоненти використовують один ref завдяки ключу `'counter'`. Заміни на звичайний `ref(0)` - і вони стануть незалежними копіями. ### Середній рівень: кошик у e-commerce через Pinia ```ts // stores/cart.ts export const useCartStore = defineStore('cart', () => { const items = ref([]); const total = computed(() => items.value.reduce((sum, item) => sum + item.price, 0) ); async function addItem(product) { items.value.push(product); await $fetch('/api/cart', { method: 'POST', body: { productId: product.id } }); } return { items, total, addItem }; }); // components/Cart.vue const cart = useCartStore(); await cart.addItem({ id: 1, name: 'Сорочка', price: 20 }); // cart.items = [{ id: 1, name: 'Сорочка', price: 20 }] // cart.total = 20 ``` `total` автоматично синхронізується з `items` через computed getter - рахувати вручну не потрібно. Для SSR кошик починається порожнім на сервері і гідрується на клієнті після завантаження даних користувача. ### Просунутий рівень: асинхронний стан і SSR mismatch ```ts // composables/useAsyncUser.ts // Неправильно - async init в onMounted виконується тільки на клієнті export const useAsyncUserWrong = () => { const user = useState('user', () => null); onMounted(async () => { user.value = await $fetch('/api/user'); // Сервер відправляє null, клієнт оновлює - mismatch }); return { user }; }; // Правильно - SSR-безпечний підхід через useAsyncData // pages/profile.vue const { data: user } = await useAsyncData('user', () => $fetch('/api/user')); // Запит виконується на сервері, серіалізується в payload, гідрується на клієнті - без помилок ``` Неправильна версія ставить асинхронну логіку в `onMounted`, який є тільки клієнтським. Сервер відправляє `null`, клієнт оновлюється до реального користувача - Vue логує попередження про mismatch. `useAsyncData` виконується на сервері, кладе результат в HTML-payload, і клієнт зчитує його напряму.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.