Skip to main content

Як керувати станом в Nuxt 3?

Управління станом у 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.

Порівняльна таблиця

ФункціональністьuseStatePinia
НалаштуванняНуль boilerplatedefineStore + @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, і клієнт зчитує його напряму.

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

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

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

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