До внедрения парадигмы 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="ru">
<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), уничтожая SEO и увеличивая время до первого байта (TTFB).
Решение: Всегда изолируйте клиентские компоненты, читающие параметры адресной строки, внутри границы <Suspense fallback={<Skeleton />}>.
Библиотека Zustand позволяет синхронизировать стор с браузерным хранилищем через мидлвару persist. Во время SSR сервер читает дефолтное начальное состояние (например, theme: 'dark'), а браузер на этапе гидратации считывает из localStorage значение 'light'. React обнаруживает расхождение атрибутов HTML и DOM и выбрасывает ошибку Hydration Mismatch.
Решение: Используйте useSyncExternalStore или откладывайте рендеринг чувствительного к клиенту UI до момента завершения гидратации (эффект монтирования).
Загрузка сущностей на сервере с последующей передачей их в клиентский стор через useEffect(() => store.setProducts(products), []). Это приводит к двойному потреблению оперативной памяти браузера, задержкам гидратации и потере актуальности данных при повторных переходах. Данные должны оставаться в Server Components.