В экосистеме современного фронтенда эпохи React 19 произошла фундаментальная демаркация: серверный стейт (Server Cache) делегирован специализированным инструментам (TanStack Query, Server Actions, RSC), а клиентский стейт (Client State) сфокусирован исключительно на интерактивном поведении UI, локальных сессиях, черновиках форм и межкомпонентной синхронизации.
Традиционные подходы (Redux с избыточным бойлерплейтом или React Context с каскадными ререндерами подписчиков) уступили место двум доминирующим парадигмам:
useSyncExternalStore.Использование устаревших подходов к клиентскому состоянию порождает критические дефекты производительности и масштабирования:
Provider принудительно ререндерит абсолютно все компоненты, вызвавшие useContext, даже если их интересует единственное неизменное поле огромного объекта.useSyncExternalStore внешние сторы приводят к отображению визуально некорректных срезов интерфейса.Zustand и Jotai весят от 1 до 3 КБ, полностью совместимы с React 19 Concurrent Mode и предоставляют строгую изоляцию ререндеров.
Различие между Zustand и Jotai лежит в топологии памяти и механике связывания зависимостей:
(state) => state.selectedField. Для исключения ререндеров при извлечении объектов применяется проверка поверхностного равенства (Shallow Equality через useShallow).// Топология Zustand: Централизованный стор + Селектор
const userName = useRootStore((state) => state.auth.user.name);
// Топология Jotai: Атомарный граф зависимостей
const [cartItems] = useAtom(cartItemsAtom); // Базовый узел
const [totalPrice] = useAtom(totalPriceAtom); // Автоматически вычисляемый производный узелДанные БД с сетевой инвалидацией передаются в TanStack Query или Server Actions. В клиентский стейт помещаются только локальные UI-параметры, сессии и черновики.
Для глобальных панелей, авторизации и мультимодульных настроек выбирается Zustand. Для независимых ячеек, графов, конструкторов и сотен полей — Jotai.
В Zustand создаются предметные слайсы через StateCreator. В Jotai разделяются базовые примитивные атомы и чистые производные атомы вычислений.
Применение useShallow для сложных селекторов в Zustand. В задачах с 60+ FPS (графики, скролл) — подписки через subscribeWithSelector.
Обеспечение детерминированной инициализации persist-хранилищ с безопасной отложенной гидратацией на клиенте для исключения Hydration Warning.
В чем заключается фундаментальное различие в механизме рендеринга при обновлении поля формы между централизованным хранилищем Zustand и графом Jotai?
localStorage через atomWithStorage с изоляцией контекста внутри дерева.В масштабируемых приложениях хранить все действия и состояние в одном файле недопустимо. Паттерн Slices позволяет создавать независимые изолированные фабрики состояния с объединением в единый корневой стор.
Для корректной типизации слайсов используется вспомогательный тип StateCreator из ядра Zustand:
import { StateCreator } from 'zustand';
import { RootStore } from '../rootStore';
export interface UserSession {
readonly id: string;
readonly email: string;
readonly role: 'admin' | 'member' | 'guest';
}
export interface AuthSlice {
session: UserSession | null;
isAuthenticated: boolean;
setSession: (session: UserSession | null) => void;
logout: () => void;
}
export const createAuthSlice: StateCreator<
RootStore,
[['zustand/devtools', never]],
[],
AuthSlice
> = (set) => ({
session: null,
isAuthenticated: false,
setSession: (session) =>
set(
{ session, isAuthenticated: Boolean(session) },
false,
'auth/setSession'
),
logout: () =>
set(
{ session: null, isAuthenticated: false },
false,
'auth/logout'
),
});import { StateCreator } from 'zustand';
import { RootStore } from '../rootStore';
export type ThemeMode = 'dark' | 'light' | 'system';
export interface UISlice {
theme: ThemeMode;
sidebarOpen: boolean;
toggleSidebar: () => void;
setTheme: (theme: ThemeMode) => void;
}
export const createUISlice: StateCreator<
RootStore,
[['zustand/devtools', never]],
[],
UISlice
> = (set) => ({
theme: 'dark',
sidebarOpen: true,
toggleSidebar: () =>
set(
(state) => ({ sidebarOpen: !state.sidebarOpen }),
false,
'ui/toggleSidebar'
),
setTheme: (theme) =>
set({ theme }, false, 'ui/setTheme'),
});import { create } from 'zustand';
import { devtools, subscribeWithSelector } from 'zustand/middleware';
import { AuthSlice, createAuthSlice } from './slices/authSlice';
import { UISlice, createUISlice } from './slices/uiSlice';
export type RootStore = AuthSlice & UISlice;
export const useRootStore = create<RootStore>()(
devtools(
subscribeWithSelector((...args) => ({
...createAuthSlice(...args),
...createUISlice(...args),
})),
{ name: 'AgenticLab_GlobalStore', enabled: process.env.NODE_ENV !== 'production' }
)
);Если компонент извлекает несколько полей через объект, возврат нового объектного литерала при каждом запуске селектора приводит к бесконечным ререндерам:
// ❌ АНТИПАТТЕРН: Объект { session, theme } имеет новую ссылку на каждый тик стора!
const { session, theme } = useRootStore((state) => ({
session: state.session,
theme: state.theme,
}));
// ✅ ПРАВИЛЬНО: useShallow сравнивает поля объекта по значению (shallow equality)
import { useShallow } from 'zustand/react/shallow';
const { session, theme } = useRootStore(
useShallow((state) => ({
session: state.session,
theme: state.theme,
}))
);Для задач с критической частотой обновления (анимация курсора, FPS-монитор, слайдер воспроизведения) React-рендер полностью обходится с помощью Transient Updates:
import { useEffect, useRef } from 'react';
import { useRootStore } from '../store/rootStore';
export function CursorTracker() {
const dotRef = useRef<HTMLDivElement>(null);
useEffect(() => {
// Прямая подписка на мутацию стора без вызова React Reconciliation
const unsubscribe = useRootStore.subscribe(
(state) => state.sidebarOpen,
(isOpen) => {
if (dotRef.current) {
dotRef.current.style.transform = isOpen
? 'translateX(260px)'
: 'translateX(0px)';
}
},
{ fireImmediately: true }
);
return unsubscribe;
}, []);
return (
<div
ref={dotRef}
className="fixed bottom-4 left-4 w-4 h-4 rounded-full bg-accent-cyan transition-transform duration-200 pointer-events-none"
/>
);
}В модели Jotai атом объявляется вне компонентов и хранит только описание (config), а само значение изолировано внутри инстанса хранилища:
import { atom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
export interface CartItem {
readonly id: string;
readonly title: string;
readonly unitPrice: number;
readonly quantity: number;
}
// 1. Примитивный сохраняемый атом (LocalStorage с SSR-безопасным fallback)
export const cartItemsAtom = atomWithStorage<CartItem[]>(
'agenticlab_cart',
[],
undefined,
{ getOnInit: true }
);
// 2. Примитивный атом промокода
export const promoCodeAtom = atom<string>('');
// 3. Производный Read-Only атом промежуточной суммы (Derived Compute)
export const subtotalAtom = atom<number>((get) => {
const items = get(cartItemsAtom);
return items.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0);
});
// 4. Асинхронный производный атом скидки (интегрируется с React 19 Suspense)
export const discountAtom = atom<Promise<number>>(async (get) => {
const promo = get(promoCodeAtom).trim().toUpperCase();
const subtotal = get(subtotalAtom);
if (!promo || subtotal === 0) return 0;
const response = await fetch(`/api/discount?code=${encodeURIComponent(promo)}`);
if (!response.ok) return 0;
const data = (await response.json()) as { percentage: number };
return (subtotal * data.percentage) / 100;
});
// 5. Производный Read-Write атом действий над корзиной
export const cartActionsAtom = atom(
null,
(get, set, action: { type: 'ADD'; item: CartItem } | { type: 'REMOVE'; id: string }) => {
const currentItems = get(cartItemsAtom);
if (action.type === 'ADD') {
const existingIndex = currentItems.findIndex((i) => i.id === action.item.id);
if (existingIndex > -1) {
const next = [...currentItems];
next[existingIndex] = {
...next[existingIndex],
quantity: next[existingIndex].quantity + action.item.quantity,
};
set(cartItemsAtom, next);
} else {
set(cartItemsAtom, [...currentItems, action.item]);
}
} else if (action.type === 'REMOVE') {
set(cartItemsAtom, currentItems.filter((i) => i.id !== action.id));
}
}
);Пользователь инициирует событие: вызов set() в Zustand или set(atom, value) в Jotai.
Zustand производит слияние состояния. Jotai обходит ориентированный ациклический граф (DAG) и помечает 'грязными' только зависимые узлы.
useShallow в Zustand предотвращает уведомление компонента, если поля идентичны. Jotai уведомляет только слушателей изменившегося узла.
useSyncExternalStore передает обновленный снимок в React Fiber, исключая разрыв данных (No Tearing) в Concurrent Mode.
Почему компонент const { user, theme } = useRootStore(s => ({ user: s.user, theme: s.theme })) будет ререндериться при изменении несвязанного поля s.isSidebarOpen, если не использовать useShallow?
import { create, StateCreator } from 'zustand';
import { devtools, persist, createJSONStorage } from 'zustand/middleware';
import { useShallow } from 'zustand/react/shallow';
// Слайс настроек безопасности и сессии
export interface SessionState {
token: string | null;
expiresAt: number | null;
activeTenantId: string | null;
setAuthToken: (token: string, expiresInSeconds: number) => void;
switchTenant: (tenantId: string) => void;
clearSession: () => void;
}
const createSessionSlice: StateCreator<
EnterpriseStore,
[['zustand/devtools', never], ['zustand/persist', unknown]],
[],
SessionState
> = (set) => ({
token: null,
expiresAt: null,
activeTenantId: null,
setAuthToken: (token, expiresInSeconds) =>
set(
{
token,
expiresAt: Date.now() + expiresInSeconds * 1000,
},
false,
'session/setAuthToken'
),
switchTenant: (activeTenantId) =>
set({ activeTenantId }, false, 'session/switchTenant'),
clearSession: () =>
set(
{ token: null, expiresAt: null, activeTenantId: null },
false,
'session/clearSession'
),
});
export type EnterpriseStore = SessionState;
export const useEnterpriseStoreBase = create<EnterpriseStore>()(
devtools(
persist(
(...args) => ({
...createSessionSlice(...args),
}),
{
name: 'agenticlab_enterprise_session',
storage: createJSONStorage(() => sessionStorage),
partialize: (state) => ({
token: state.token,
expiresAt: state.expiresAt,
activeTenantId: state.activeTenantId,
}),
}
),
{ name: 'EnterpriseStore' }
)
);
/**
* Обертка для типобезопасной выборки с автоматической защитой от ререндеров.
*/
export function useEnterpriseStore<T>(
selector: (state: EnterpriseStore) => T,
equalityFn?: (a: T, b: T) => boolean
): T {
return useEnterpriseStoreBase(selector, equalityFn);
}
/**
* Специализированный хук для безопасного извлечения среза сессии
*/
export function useCurrentSession() {
return useEnterpriseStoreBase(
useShallow((state) => ({
isAuthorized: Boolean(state.token && state.expiresAt && state.expiresAt > Date.now()),
activeTenantId: state.activeTenantId,
setAuthToken: state.setAuthToken,
clearSession: state.clearSession,
}))
);
}Селектор, возвращающий новый объект при каждом вызове ((state) => ({ a: state.a, b: state.b })), разрушает проверку по ссылочному равенству ===. В результате компонент будет ререндериться при любом изменении в сторе, даже если поля a и b не менялись.
// ❌ АНТИПАТТЕРН: Ререндерится при любом апдейте в store
const { user, role } = useRootStore((state) => ({
user: state.user,
role: state.role,
}));
// ✅ ПРАВИЛЬНО: Обертка в useShallow для поэлементного сравнения первого уровня
import { useShallow } from 'zustand/react/shallow';
const { user, role } = useRootStore(
useShallow((state) => ({
user: state.user,
role: state.role,
}))
);Вызов функции atom() в теле React-компонента создает новый объект описателя атома на каждом цикле рендеринга. Старое состояние безвозвратно теряется, ссылки сбрасываются, а в памяти накапливаются осиротевшие узлы подписок.
// ❌ АНТИПАТТЕРН: Новый атом создается при каждом рендере компонента!
function BadSearchInput() {
const localQueryAtom = atom(''); // УТЕЧКА ПАМЯТИ: Каждый рендер — новый атом
const [query, setQuery] = useAtom(localQueryAtom);
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}
// ✅ ПРАВИЛЬНО: Атом объявляется статически в области видимости модуля
const searchQueryAtom = atom('');
function GoodSearchInput() {
const [query, setQuery] = useAtom(searchQueryAtom);
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}При серверном рендеринге sessionStorage или localStorage недоступны. Если клиентский компонент сразу прочитает значение из сохраненного хранилища, HTML сервера и клиента разойдутся, вызвав критическую ошибку гидратации.
// ❌ АНТИПАТТЕРН: Чтение persisted стейта до завершения гидратации
export function UserProfileHeader() {
const token = useEnterpriseStore((s) => s.token);
// На сервере token === null, на клиенте token === 'xyz' -> Hydration Error!
return <div>{token ? 'Личный кабинет' : 'Гость'}</div>;
}
// ✅ ПРАВИЛЬНО: Изоляция чтения до завершения монтирования
export function UserProfileHeaderSafe() {
const [isMounted, setIsMounted] = useState(false);
const token = useEnterpriseStore((s) => s.token);
useEffect(() => {
setIsMounted(true);
}, []);
if (!isMounted) {
return <div>Загрузка профиля...</div>;
}
return <div>{token ? 'Личный кабинет' : 'Гость'}</div>;
}Прямое изменение вложенных массивов или объектов приводит к тому, что ссылки на состояние остаются прежними, селекторы не замечают мутации, и интерфейс замирает без обновления.
// ❌ АНТИПАТТЕРН: Мутация исходного массива в памяти
const useStoreBad = create((set) => ({
todos: [{ id: '1', done: false }],
toggleTodo: (id: string) => set((state: any) => {
state.todos[0].done = true; // Ссылка на массив не изменилась! Ререндера не будет!
return { todos: state.todos };
}),
}));
// ✅ ПРАВИЛЬНО: Иммутабельное создание нового массива
const useStoreGood = create((set) => ({
todos: [{ id: '1', done: false }],
toggleTodo: (id: string) =>
set((state) => ({
todos: state.todos.map((todo) =>
todo.id === id ? { ...todo, done: !todo.done } : todo
),
})),
}));