В екосистемі сучасного фронтенду епохи 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
),
})),
}));