Традиционный React Context API не предназначен для высокочастотных обновлений данных: изменение единичного поля в объекте состояния провоцирует каскадный ререндеринг каждого компонента-потребителя в поддереве. Архитектура Zustand v5 выносит хранилище за пределы дерева виртуального DOM (Virtual DOM) и организует подписку на базе примитива useSyncExternalStore. В сочетании с паттерном слайсов (Slice Pattern) и оптимизатором useShallow, это гарантирует стабильную сложность ререндеринга $O(1)$ строго для изменившихся узлов интерфейса.
В крупных интерфейсах (SaaS-дэшборды, e-commerce витрины, коллаборативные редакторы) неэффективный менеджмент клиентского состояния становится первопричиной деградации метрик отклика:
Context.Provider, изменение количества товара в корзине перерисовывает шапку профиля, боковую навигацию и списки товаров. React 19 тратит кванты времени основного потока (Main Thread) на бессмысленное согласование (Reconciliation) неизменившихся узлов.localStorage (персистенция корзины, темы оформления) сервер Next.js или Remix рендерит начальный HTML без доступа к браузерному API. Наивная гидратация клиентского стора приводит к фатальной ошибке React Hydration failed because the initial UI does not match the server-rendered HTML.Zustand v5 решает эти вызовы на уровне базовых примитивов: библиотека весит менее 1.5 кБ, не требует оборачивания приложения в провайдеры по умолчанию, даёт строгую типизацию TypeScript без циклических ссылок и обеспечивает изоляцию гидратации.
Хранилище Zustand существует как независимый JavaScript-синглтон (или фабричный инстанс) вне контекста жизненного цикла компонентов React. Связь между внешним стором и деревом рендеринга осуществляется через нативный браузерный хук React useSyncExternalStore.
[ External Zustand Store (Clojure State Singleton) ]
│
├── getState() ──────> Синхронное чтение снимка
├── setState() ──────> Мутация состояния + уведомление подписчиков
└── subscribe() ─────> Регистрация слушателей (Listeners Set)
│
▼
[ React 19: useSyncExternalStore ]
│
├── Прогон селектора: selector(state)
├── Сравнение: useShallow(prev, next)
│ ├─ Равны ──> Пропуск ререндера
│ └─ Изменились ──> Триггер ререндера
▼
[ Target Component ONLY ]Когда компонент вызывает хук useRootStore(useShallow(state => ({ items: state.items, total: state.total }))):
useSyncExternalStore регистрирует коллбэк компонента в коллекции слушателей стора.Object.assign) нового состояния.useShallow проверяет равенство каждого ключа (Object.is(prev[key], next[key])).Определение изолированных интерфейсов срезов (Auth, Cart, UI) и комбинированного типа RootStore без взаимных циклических зависимостей.
Реализация функций-фабрик слайсов с доступом к сеттеру set, геттеру get и мутаторам middleware (devtools, persist).
Композиция слайсов в единый стор, подключение Redux DevTools, версионирование схемы персистенции в localStorage.
Интеграция атомарных селекторов в компоненты React с предотвращением избыточных ререндеров при деструктуризации.
Создание кастомного хука useHydratedStore для исключения ошибок несовпадения разметки в Next.js App Router.
Компонент вызывает типизированный экшен: useRootStore.getState().addToCart(product)
Экшен выполняет валидацию, вычисляет дельту и вызывает функцию set((state) => ({ ... }))
DevTools логирует экшен, Persist сериализует срез в LocalStorage с учетом whitelist/blacklist
React опрашивает селекторы подписанных компонентов через нативный механизм Concurrent Mode
Поверхностная проверка ключей: если поля селектора не изменились ссылочно, ререндер пресекается
Перерисовываются только те микро-компоненты, чьи атомарные данные были фактически модифицированы
Разработчик написал селектор в компоненте шапки: const { user, isAuthenticated } = useRootStore(state => ({ user: state.user, isAuthenticated: state.isAuthenticated })). Почему компонент перерисовывается при добавлении товара в корзину, хотя данные пользователя не менялись?
При разбиении хранилища на срезы с использованием middleware devtools и persist, какая сигнатура StateCreator гарантирует корректный вывод типов сеттера set и доступ к соседним срезам через get?
src/store/types.ts#Полный контракт интерфейсов состояния и действий для модульной декомпозиции.
/**
* src/store/types.ts
* Строгая типизация контрактов предметных срезов хранилища Zustand v5.
*/
export interface UserProfile {
id: string
email: string
fullName: string
role: 'admin' | 'customer' | 'manager'
}
export interface CartItem {
id: string
title: string
price: number
quantity: number
}
// ── 1. Срез аутентификации (AuthSlice) ──
export interface AuthState {
user: UserProfile | null
token: string | null
isAuthenticated: boolean
isAuthLoading: boolean
}
export interface AuthActions {
login: (email: string, pass: string) => Promise<void>
logout: () => void
setUserProfile: (profile: UserProfile) => void
}
export type AuthSlice = AuthState & AuthActions
// ── 2. Срез корзины покупок (CartSlice) ──
export interface CartState {
items: CartItem[]
discountCode: string | null
}
export interface CartActions {
addItem: (item: Omit<CartItem, 'quantity'>, qty?: number) => void
removeItem: (itemId: string) => void
updateQuantity: (itemId: string, delta: number) => void
applyDiscount: (code: string) => void
clearCart: () => void
// Вычисляемые геттеры
getCartTotal: () => number
getItemsCount: () => number
}
export type CartSlice = CartState & CartActions
// ── 3. Срез интерфейса (UiSlice) ──
export interface UiState {
theme: 'light' | 'dark' | 'system'
isCartDrawerOpen: boolean
notification: { id: string; message: string; type: 'info' | 'error' | 'success' } | null
}
export interface UiActions {
setTheme: (theme: UiState['theme']) => void
toggleCartDrawer: (open?: boolean) => void
showNotification: (message: string, type?: UiState['notification']['type']) => void
dismissNotification: () => void
}
export type UiSlice = UiState & UiActions
// ── 4. Корневое объединенное состояние (RootStore) ──
export type RootStore = AuthSlice & CartSlice & UiSlice
// Типовой кортеж для StateCreator при использовании devtools и persist
export type RootStateCreator<Slice> = (
set: (
partial: RootStore | Partial<RootStore> | ((state: RootStore) => RootStore | Partial<RootStore>),
replace?: boolean | undefined,
actionName?: string
) => void,
get: () => RootStore,
api: unknown
) => Slicesrc/store/slices/createCartSlice.ts#Пример изолированного слайса корзины с бизнес-логикой и доступом к глобальному состоянию.
import { StateCreator } from 'zustand'
import { RootStore, CartSlice, CartItem } from '../types'
/**
* Фабрика среза корзины (CartSlice).
* Использует полный RootStore для возможности взаимодействия со срезом Auth/UI.
*/
export const createCartSlice: StateCreator<
RootStore,
[['zustand/devtools', never]],
[],
CartSlice
> = (set, get) => ({
// Начальное состояние
items: [],
discountCode: null,
// Экшены
addItem: (product, qty = 1) => {
set(
(state) => {
const existingIndex = state.items.findIndex((item) => item.id === product.id)
if (existingIndex > -1) {
const updatedItems = [...state.items]
updatedItems[existingIndex] = {
...updatedItems[existingIndex],
quantity: updatedItems[existingIndex].quantity + qty,
}
return { items: updatedItems }
}
return {
items: [...state.items, { ...product, quantity: qty }],
}
},
false,
'cart/addItem'
)
// Сайд-эффект в UI: автоматическое открытие шторки корзины
get().toggleCartDrawer(true)
},
removeItem: (itemId) => {
set(
(state) => ({
items: state.items.filter((item) => item.id !== itemId),
}),
false,
'cart/removeItem'
)
},
updateQuantity: (itemId, delta) => {
set(
(state) => {
const updated = state.items
.map((item) => {
if (item.id === itemId) {
const newQty = item.quantity + delta
return newQty > 0 ? { ...item, quantity: newQty } : null
}
return item
})
.filter((item): item is CartItem => item !== null)
return { items: updated }
},
false,
'cart/updateQuantity'
)
},
applyDiscount: (code) => {
set({ discountCode: code.trim().toUpperCase() }, false, 'cart/applyDiscount')
get().showNotification(`Промокод ${code} успешно применен`, 'success')
},
clearCart: () => {
set({ items: [], discountCode: null }, false, 'cart/clearCart')
},
// Вычисляемые селекторы-хелперы
getCartTotal: () => {
const { items, discountCode } = get()
const baseTotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0)
// Скидка 10% для промокода AGENTICLAB
if (discountCode === 'AGENTICLAB') {
return Math.round(baseTotal * 0.9 * 100) / 100
}
return baseTotal
},
getItemsCount: () => {
return get().items.reduce((total, item) => total + item.quantity, 0)
},
})src/store/useRootStore.ts#Централизованная сборка хранилища с middleware DevTools, Persist и безопасной конфигурацией хранилища.
import { create } from 'zustand'
import { devtools, persist, createJSONStorage } from 'zustand/middleware'
import { RootStore } from './types'
import { createCartSlice } from './slices/createCartSlice'
import { StateCreator } from 'zustand'
// ── Вспомогательный срез AuthSlice ──
const createAuthSlice: StateCreator<
RootStore,
[['zustand/devtools', never]],
[],
RootStore
> = (set) => ({
user: null,
token: null,
isAuthenticated: false,
isAuthLoading: false,
login: async (email, _pass) => {
set({ isAuthLoading: true }, false, 'auth/loginStart')
try {
// Имитация асинхронного сетевого запроса
await new Promise((resolve) => setTimeout(resolve, 600))
set(
{
user: { id: 'usr_1', email, fullName: 'Алексей Смирнов', role: 'customer' },
token: 'jwt_mock_token_agenticlab',
isAuthenticated: true,
isAuthLoading: false,
},
false,
'auth/loginSuccess'
)
} catch {
set({ isAuthLoading: false }, false, 'auth/loginError')
}
},
logout: () => {
set(
{ user: null, token: null, isAuthenticated: false },
false,
'auth/logout'
)
},
setUserProfile: (profile) => {
set({ user: profile }, false, 'auth/setUserProfile')
},
} as any)
// ── Вспомогательный срез UiSlice ──
const createUiSlice: StateCreator<
RootStore,
[['zustand/devtools', never]],
[],
RootStore
> = (set) => ({
theme: 'system',
isCartDrawerOpen: false,
notification: null,
setTheme: (theme) => set({ theme }, false, 'ui/setTheme'),
toggleCartDrawer: (open) =>
set(
(state) => ({
isCartDrawerOpen: open !== undefined ? open : !state.isCartDrawerOpen,
}),
false,
'ui/toggleCartDrawer'
),
showNotification: (message, type = 'info') =>
set(
{ notification: { id: Math.random().toString(), message, type } },
false,
'ui/showNotification'
),
dismissNotification: () => set({ notification: null }, false, 'ui/dismissNotification'),
} as any)
/**
* Инициализация единого корневого хранилища useRootStore.
* Подключает Redux DevTools в dev-режиме и persist в localStorage.
*/
export const useRootStore = create<RootStore>()(
devtools(
persist(
(...a) => ({
...createAuthSlice(...(a as any)),
...createCartSlice(...(a as any)),
...createUiSlice(...(a as any)),
}),
{
name: 'agenticlab-application-storage',
storage: createJSONStorage(() => localStorage),
// Сохраняем только устойчивые данные: корзину и токен, исключая эфемерный UI
partialize: (state) => ({
items: state.items,
discountCode: state.discountCode,
token: state.token,
theme: state.theme,
}),
version: 1,
// Миграция схем данных при обновлении версии стора
migrate: (persistedState: any, version: number) => {
if (version === 0) {
// Пример миграции: добавление discountCode, если его не было
persistedState.discountCode = null
}
return persistedState as RootStore
},
}
),
{
name: 'AgenticLab Root Store',
enabled: process.env.NODE_ENV !== 'production',
}
)
)
// Экспорт прямого доступа к API стора вне React компонентов
export const { getState, setState, subscribe } = useRootStoresrc/hooks/useHydratedStore.ts#Решение проблемы SSR Hydration Mismatch в Next.js и пример UI-компонента корзины с useShallow.
'use client'
import { useState, useEffect } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { useRootStore } from '@/store/useRootStore'
/**
* Кастомный хук для безопасного чтения персистентного состояния в Next.js App Router (SSR).
* Предотвращает Hydration Mismatch за счет задержки чтения клиента до маунта.
*/
export function useHydratedStore<T>(selector: (state: ReturnType<typeof useRootStore.getState>) => T): T | undefined {
const [hydrated, setHydrated] = useState(false)
const storeValue = useRootStore(selector)
useEffect(() => {
setHydrated(true)
}, [])
return hydrated ? storeValue : undefined
}
/**
* Продакшн-компонент бейджа корзины в шапке сайта.
* Демонстрирует использование useShallow для исключения паразитных ререндеров.
*/
export function CartHeaderWidget() {
// Селектор возвращает объект из двух полей.
// БЕЗ useShallow возврат { count, total } вызывал бы ререндер виджета при ЛЮБОМ экшене стора.
const { totalItems, cartTotal, toggleDrawer } = useRootStore(
useShallow((state) => ({
totalItems: state.getItemsCount(),
cartTotal: state.getCartTotal(),
toggleDrawer: state.toggleCartDrawer,
}))
)
return (
<button
type="button"
onClick={() => toggleDrawer(true)}
className="relative flex items-center gap-2 px-3 py-1.5 rounded-lg bg-surface border border-border hover:border-primary/50 transition-colors"
aria-label={`Корзина: ${totalItems} товаров на сумму ${cartTotal} рублей`}
>
<svg
className="w-5 h-5 text-text-primary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z"
/>
</svg>
<span className="font-semibold text-sm text-text-primary">
{cartTotal.toLocaleString('ru-RU')} ₽
</span>
{totalItems > 0 && (
<span className="absolute -top-1.5 -right-1.5 flex items-center justify-center min-w-5 h-5 px-1 text-xs font-bold text-white bg-primary rounded-full animate-in zoom-in-75">
{totalItems}
</span>
)}
</button>
)
}useRootStore() без селектора#// АНТИПАТТЕРН: подписка на ВЕСЬ стор сразу!
// Компонент будет перерисовываться при ЛЮБОМ изменении любого поля во всем приложении!
const { user, items } = useRootStore()
// ПРАВИЛЬНО: атомарная подписка с useShallow
const { user, items } = useRootStore(
useShallow((state) => ({ user: state.user, items: state.items }))
)Zustand использует поверхностное сравнение объектов (shallow merge). Прямое изменение массива или вложенного объекта не регистрируется как смена ссылки:
// АНТИПАТТЕРН: мутация исходного массива в стейте
updateItem: (id, name) => set((state) => {
const item = state.items.find(i => i.id === id)
if (item) item.name = name // ОШИБКА: ссылка на массив state.items не изменилась!
return { items: state.items }
})
// ПРАВИЛЬНО: иммутабельный spread или map
updateItem: (id, name) => set((state) => ({
items: state.items.map(item => item.id === id ? { ...item, name } : item)
}))Если компонент рендерит данные из persist стора напрямую в первом кадре, сервер отрендерит null, а браузер подставит сохраненное значение из localStorage. Результат — расхождение контрольных сумм гидратации React. Решение — использовать флаг hasHydrated или кастомный хук useHydratedStore.