Як керувати станом в Nuxt 3?
Управління станом у Nuxt 3 базується на двох інструментах: useState для простих спільних значень без жодного налаштування і Pinia для сторів з діями, getters та підтримкою DevTools.
Теорія
TL;DR
useState- це спільний холодильник в офісі: всі бачать одне значення, SSR-гідрація працює автоматично- Pinia - повноцінна кухня з кількома кухарями, рецептами і вікном у процес (DevTools)
- Головна різниця:
useStateсам серіалізує стан для SSR; Pinia дає дії, computed getters, плагіни і масштабується до 50+ сторів - Менше 5 примітивних значень без логіки?
useState. Авторизація, кошик, API-запити? Pinia.
Швидкий приклад
// 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.
// Неправильно: сервер бачить 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, отримує свою копію. До того ж вона скидається при навігації.
// Неправильно: не спільний, скидається при переході
const count = ref(0);
// Правильно: спільний між компонентами і навігаціями
const count = useState('count', () => 0);3. Пропуск ініціалізації Pinia для SSR
Модуль @pinia/nuxt бере на себе все налаштування. Без нього в nuxt.config.ts стори будуть порожніми після гідрації.
// 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
// 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
// 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 = 20total автоматично синхронізується з items через computed getter - рахувати вручну не потрібно. Для SSR кошик починається порожнім на сервері і гідрується на клієнті після завантаження даних користувача.
Просунутий рівень: асинхронний стан і SSR mismatch
// 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, і клієнт зчитує його напряму.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.