До появи парадигми React Server Components (RSC) та архітектури Next.js App Router клієнтський стейт-менеджмент (Redux, MobX, Zustand) займав домінантне положення у фронтенд-розробці. Команди були змушені дублювати в глобальні клієнтські сховища всі сутності без розбору: відповіді бази даних, DTO зовнішніх API, проміжний стан форм та локальні перемикачі інтерфейсу. Це призводило до лавиноподібного роздування JavaScript-бандла, проблем подвійного кешування та складнощів із синхронізацією даних.
У сучасній архітектурі Next.js фундаментальне правило стверджує: дані сервера мають залишатися на сервері. Вибірка даних усередині Server Components не потребує проміжних глобальних сторів для передачі деревом компонентів: компоненти отримують їх через типізовані пропси або використовують нативну мемоізацію запитів.
Проте завдання керування станом нікуди не зникло — воно структурувалося за чіткими рівнями відповідальності:
У додатку на Next.js стан суворо поділяється на чотири ізольовані категорії:
┌─────────────────────────────────────────────────────────┐
│ ДЖЕРЕЛА ДАНИХ │
└──────────────┬───────────────────────────┬──────────────┘
│ │
▼ ▼
┌──────────────────────────┐ ┌──────────────────────────┐
│ SERVER STATE │ │ URL STATE │
│ PostgreSQL / Redis / ORM│ │ ?category=tech&page=2 │
└──────────────┬───────────┘ └─────────────┬────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────┐
│ NEXT.JS APP ROUTER (Server Component) │
│ const { category } = await searchParams; │
│ const data = await db.query(category); │
└────────────────────────────┬────────────────────────────┘
│ props / initialData
▼
┌─────────────────────────────────────────────────────────┐
│ CLIENT HYDRATION & INTERACTION LAYER │
│ │
│ ┌────────────────────────┐ ┌───────────────────────┐ │
│ │ Zustand Store (UI) │ │ React 19 useOptimistic│ │
│ │ (Кошик, модалки, DnD) │ │ (Миттєві лайки/чат) │ │
│ └────────────────────────┘ └───────────────────────┘ │
└─────────────────────────────────────────────────────────┘Визначаємо природу даних: персистентна база даних чи тимчасовий стан UI.
Якщо стан впливає на шерінг або SEO (фільтри, пагінація) — використовуємо searchParams / nuqs.
Для миттєвої реакції на дії користувача (лайки, повідомлення) підключаємо React 19 useOptimistic.
Для складного клієнтського стану створюємо фабрику Zustand Per-Request Store.
Підключаємо клієнтські стори через AppProviders без перетворення RootLayout на 'use client'.
Команда розробила кошик на Zustand: оголосила export const useCartStore = create(...) прямо у файлі cart-store.ts. У локальному браузері все працює належним чином, проте після деплою під високим навантаженням користувачі почали періодично бачити товари чужих кошиків при оновленні сторінки. У чому причина вразливості?
searchParams та ізоляція через Suspense#На серверних сторінках читання параметрів адресного рядка є строго асинхронним:
import { Suspense } from 'react';
import { SearchFilterInput } from './_components/SearchFilterInput';
import { ProductList } from './_components/ProductList';
interface CatalogPageProps {
searchParams: Promise<{ q?: string; page?: string }>;
}
export default async function CatalogPage({ searchParams }: CatalogPageProps) {
const { q = '', page = '1' } = await searchParams;
return (
<main className="max-w-6xl mx-auto p-6 flex flex-col gap-6">
<h1 className="text-2xl font-bold">Каталог товарів</h1>
{/* Ізоляція клієнта в Suspense запобігає деоптимізації всієї сторінки */}
<Suspense fallback={<div className="h-10 bg-bg-surface animate-pulse rounded-lg" />}>
<SearchFilterInput initialQuery={q} />
</Suspense>
<ProductList query={q} page={parseInt(page, 10)} />
</main>
);
}На клієнті синхронізація useSearchParams() із useTransition забезпечує плавний рендеринг без блокування інтерфейсу:
'use client';
import { useSearchParams, usePathname, useRouter } from 'next/navigation';
import { useTransition } from 'react';
export function SearchFilterInput({ initialQuery }: { initialQuery: string }) {
const searchParams = useSearchParams();
const pathname = usePathname();
const { replace } = useRouter();
const [isPending, startTransition] = useTransition();
const handleSearch = (term: string) => {
const params = new URLSearchParams(searchParams);
if (term) {
params.set('q', term);
params.set('page', '1');
} else {
params.delete('q');
}
startTransition(() => {
replace(`${pathname}?${params.toString()}`);
});
};
return (
<div className="relative">
<input
defaultValue={initialQuery}
onChange={(e) => handleSearch(e.target.value)}
placeholder="Пошук у каталозі..."
className="w-full px-4 py-2 rounded-lg bg-bg-surface border border-border focus:border-accent-violet outline-none"
/>
{isPending && (
<span className="absolute right-3 top-2.5 text-xs text-accent-violet font-medium animate-pulse">
Пошук...
</span>
)}
</div>
);
}useOptimistic#Хук useOptimistic усуває відчутну затримку мережі: інтерфейс оновлюється миттєво при взаємодії та автоматично повертається до початкового стану у разі помилки.
'use client';
import { useOptimistic, useTransition } from 'react';
import { toggleLikeAction } from '../server/actions';
interface LikeButtonProps {
postId: string;
initialLikes: number;
initialIsLiked: boolean;
}
export function LikeButton({ postId, initialLikes, initialIsLiked }: LikeButtonProps) {
const [isPending, startTransition] = useTransition();
const [optimisticState, setOptimisticState] = useOptimistic(
{ likes: initialLikes, isLiked: initialIsLiked },
(state, nextLiked: boolean) => ({
likes: nextLiked ? state.likes + 1 : state.likes - 1,
isLiked: nextLiked,
})
);
const handleClick = () => {
const nextValue = !optimisticState.isLiked;
startTransition(async () => {
// 1. Миттєвий оптимістичний рендеринг
setOptimisticState(nextValue);
// 2. Виклик серверного екшену
await toggleLikeAction(postId, nextValue);
});
};
return (
<button
onClick={handleClick}
disabled={isPending}
className={`px-4 py-2 rounded-lg border font-medium transition-colors ${
optimisticState.isLiked
? 'bg-accent-violet/20 border-accent-violet text-accent-violet'
: 'bg-bg-surface border-border text-text-secondary'
}`}
>
❤️ {optimisticState.likes}
</button>
);
}layout.tsx (Donut Pattern)#Клієнтські провайдери контексту не повинні перетворювати кореневий layout на клієнтський компонент. Використовується ізольований шар обгортки:
'use client';
import { ReactNode } from 'react';
import { CartStoreProvider } from '@/entities/cart/ui/CartStoreProvider';
export function AppProviders({ children }: { children: ReactNode }) {
return (
<CartStoreProvider>
{children}
</CartStoreProvider>
);
}У src/app/layout.tsx серверний JSX без перешкод передається через пропс children:
import { AppProviders } from '@/shared/providers/AppProviders';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="uk">
<body className="bg-bg-primary text-text-primary">
<AppProviders>
{children}
</AppProviders>
</body>
</html>
);
}nuqs#У складних сценаріях фасетної фільтрації бібліотека nuqs забезпечує сувору типізацію та валідацію параметрів:
'use client';
import { useQueryState, parseAsStringLiteral } from 'nuqs';
const categories = ['all', 'electronics', 'clothing', 'books'] as const;
export function CategorySelect() {
const [category, setCategory] = useQueryState(
'category',
parseAsStringLiteral(categories).withDefault('all')
);
return (
<select
value={category}
onChange={(e) => setCategory(e.target.value as typeof category)}
className="px-3 py-2 rounded-lg bg-bg-surface border border-border"
>
<option value="all">Усі категорії</option>
<option value="electronics">Електроніка</option>
<option value="clothing">Одяг</option>
<option value="books">Книги</option>
</select>
);
}На сторінці каталогу клієнтський компонент SearchFilter викликає useSearchParams() для фільтрації товарів. Під час виконання pnpm run build Next.js видає попередження про Client-side Deopt, а статичний пререндеринг сторінки вимикається. Як зберегти статичну генерацію для цієї сторінки?
Якщо стор оголошено через export const useStore = create(...) на рівні модуля, у браузері він буде ізольованим, але на сервері в Node.js цей модуль кешується та повторно використовується між сотнями паралельних запитів! Якщо під час SSR дані сесії або кошика запишуться в такий синглтон, інший користувач отримає чужі дані у відповіді сервера.
Рішення: Створюйте екземпляр стора суворо через фабричну функцію createStore() всередині React Context зі збереженням посилання через useRef.
При виклику useSearchParams() всередині клієнтського компонента Next.js не може передбачити параметри під час збірки (next build). Якщо такий компонент не огорнуто в <Suspense>, весь батьківський маршрут примусово перемикається в динамічний рендеринг на клієнті (Client-side Deopt), знижуючи показники TTFB.
Рішення: Завжди ізолюйте клієнтські компоненти, що читають рядок запиту, всередині межі <Suspense fallback={<Skeleton />}>.
Мідлвара persist у Zustand зчитує стан з localStorage. Під час SSR сервер генерує HTML зі стартовим дефолтним станом (наприклад, theme: 'dark'), а браузер під час гідратації читає з локального сховища 'light'. React виявляє розбіжність атрибутів і викидає помилку Hydration Mismatch.
Рішення: Використовуйте useSyncExternalStore з резервним серверним снапшотом або відкладайте рендеринг чутливого до клієнта UI до завершення монтування.
Отримання сутностей на сервері з наступним копіюванням у клієнтський стор через useEffect(() => store.setProducts(products), []). Це призводить до надлишкового споживання пам'яті браузера, затримок гідратації та розсинхронізації даних. Дані мають залишатися в Server Components.