В экосистеме TypeScript управление состоянием через Zustand завоевало доминирующие позиции благодаря легковесной архитектуре на базе замыканий и подписок через useSyncExternalStore. Однако при переходе от тривиальных примеров к enterprise-архитектуре инженеры сталкиваются с критической сложностью: типизацией цепочек промежуточного ПО (Middleware Pipeline) и модульной композицией независимых фрагментов состояния (Slice Pattern).
Исторически функция create в Zustand имела перегрузку create<T>(...). При использовании такой сигнатуры компилятор TypeScript фиксировал тип состояния T, но полностью терял способность автоматически выводить типы мутаций, вносимых middleware (например, добавление методов setState в Immer или методов управления персистентностью в persist).
Для решения этой проблемы ядро Zustand v4+ и v5 использует каррированный паттерн объявления create<T>()(...). Первый вызов фиксирует целевой интерфейс состояния, а второй — связывает цепочку мутаторов на уровне системы типов (Type-Level Mutators):
+-------------------------------------------------------------------------+
| МЕХАНИЗМ КАРРИРОВАНИЯ И МУТАЦИИ ТИПОВ В ZUSTAND |
+-------------------------------------------------------------------------+
| [create<RootStore>()] |
| |--> Фиксирует базовый тип хранилища T = RootStore |
| |--> Возвращает функцию высшего порядка с поддержкой Mutator Tuple |
+-------------------------------------------------------------------------+
|
v
| [(...args) => devtools(persist(immer((set, get) => ({ ... }))))] |
| | |
| |-- Mutator 1: 'zustand/devtools' -> добавляет action name в set() |
| |-- Mutator 2: 'zustand/persist' -> добавляет api.persist |
| `-- Mutator 3: 'zustand/immer' -> преобразует draft в set() |
+-------------------------------------------------------------------------+
|
v
+-------------------------------------------------------------------------+
| Итоговый тип хука: UseBoundStore<Write<StoreApi<T>, StoreMutators>> |
| (Полный автокомплит методов стора, actions и middleware API) |
+-------------------------------------------------------------------------+Внутреннее устройство типизации Zustand опирается на тип StateCreator, оперирующий кортежами мутаторов:
this.Взаимодействие слайсов через StateCreator:
+-------------------------------------------------------------+
| RootStore |
| (AuthSlice & CartSlice & NotificationSlice) |
+-------------------------------------------------------------+
^ ^
| |
StateCreator<...> StateCreator<...>
| |
+--------------------+ +--------------------+
| AuthSlice | | CartSlice |
| - user: User | | - items: Item[] |
| - login(): void | | - checkout(): void|
| | | (может читать |
| | | auth через get())|
+--------------------+ +--------------------+Опишите интерфейсы AuthSlice, CartSlice и объедините их в общий RootStore = AuthSlice & CartSlice.
Реализуйте каждый слайс в отдельном файле с типизацией через StateCreator<RootStore, Mutators, [], Slice>.
Объедините слайсы в rootStore.ts, обернув цепочкой devtools(persist(immer(...))).
Задайте функцию partialize для сохранения в LocalStorage только необходимых полей состояния.
Используйте селекторы с гранулярными полями или useShallow для исключения паразитных ререндеров.
Почему для корректной работы Immer и Devtools в Zustand v4/v5 необходим каррированный вызов create<T>()(...), а не одиночный create<T>(...)?
localStorage через persist с шифрованием и регистрацией событий в Redux DevTools.immer middleware для мутаций глубоко вложенных деревьев слоев и геометрических узлов с контролем истории (Undo/Redo).useShallow для исключения каскадной перерисовки дерева интерфейса.Каждый слайс объявляется как отдельная изолированная функция, типизированная через StateCreator. Если слайс использует middleware (например, devtools или immer), это должно быть отражено во втором аргументе StateCreator:
import { StateCreator } from 'zustand';
import { RootStore } from '../rootStore';
export interface UserProfile {
readonly id: string;
readonly email: string;
readonly role: 'admin' | 'operator' | 'viewer';
}
export interface AuthSlice {
readonly user: UserProfile | null;
readonly token: string | null;
readonly isAuthenticated: boolean;
readonly login: (token: string, profile: UserProfile) => void;
readonly logout: () => void;
}
/**
* Слайс аутентификации.
* Использует типы RootStore для возможности взаимодействия с соседними слайсами.
*/
export const createAuthSlice: StateCreator<
RootStore,
[['zustand/devtools', never], ['zustand/immer', never]],
[],
AuthSlice
> = (set) => ({
user: null,
token: null,
isAuthenticated: false,
login: (token, profile) =>
set(
(state) => {
state.user = profile;
state.token = token;
state.isAuthenticated = true;
},
false,
'auth/login'
),
logout: () =>
set(
(state) => {
state.user = null;
state.token = null;
state.isAuthenticated = false;
},
false,
'auth/logout'
),
});get()#Слайс корзины демонстрирует, как безопасно читать состояние авторизации из соседнего слайса без циклических зависимостей:
import { StateCreator } from 'zustand';
import { RootStore } from '../rootStore';
export interface CartItem {
readonly id: string;
readonly priceCents: number;
readonly quantity: number;
}
export interface CartSlice {
readonly items: ReadonlyArray<CartItem>;
readonly addItem: (item: CartItem) => void;
readonly clearCart: () => void;
readonly checkout: () => Promise<boolean>;
}
export const createCartSlice: StateCreator<
RootStore,
[['zustand/devtools', never], ['zustand/immer', never]],
[],
CartSlice
> = (set, get) => ({
items: [],
addItem: (newItem) =>
set(
(state) => {
const existing = state.items.find((i) => i.id === newItem.id);
if (existing) {
existing.quantity += newItem.quantity;
} else {
state.items.push(newItem);
}
},
false,
'cart/addItem'
),
clearCart: () =>
set(
(state) => {
state.items = [];
},
false,
'cart/clearCart'
),
checkout: async () => {
// Безопасное чтение данных из соседнего слайса authSlice через get()
const { isAuthenticated, token } = get();
if (!isAuthenticated || !token) {
throw new Error('[CheckoutError] Операция невозможна для неавторизованного пользователя');
}
const { items } = get();
if (items.length === 0) {
return false;
}
// Имитация сетевого запроса оформления
return true;
},
});create#Объединение слайсов с подключением devtools, persist и immer требует строгого соблюдения порядка оберток:
import { create } from 'zustand';
import { devtools, persist, createJSONStorage } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
import { AuthSlice, createAuthSlice } from './slices/authSlice';
import { CartSlice, createCartSlice } from './slices/cartSlice';
export type RootStore = AuthSlice & CartSlice;
/**
* Каррированный вызов create<RootStore>()(...) обеспечивает:
* 1. Сохранение типов всех вложенных слайсов.
* 2. Корректную передачу типов мутатора immer (draft) в set.
* 3. Автодополнение имен actions для devtools.
*/
export const useRootStore = create<RootStore>()(
devtools(
persist(
immer((...a) => ({
...createAuthSlice(...a),
...createCartSlice(...a),
})),
{
name: 'agenticlab_enterprise_store',
storage: createJSONStorage(() => localStorage),
// Сохраняем в локальное хранилище только авторизацию и корзину
partialize: (state) => ({
token: state.token,
items: state.items,
}),
}
),
{ name: 'RootStore', enabled: process.env.NODE_ENV !== 'production' }
)
);'use client';
import React from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useRootStore } from '../store/rootStore';
export function HeaderUserBadge() {
// Выборка нескольких полей с защитой от лишних ререндеров
const { user, isAuthenticated, logout } = useRootStore(
useShallow((state) => ({
user: state.user,
isAuthenticated: state.isAuthenticated,
logout: state.logout,
}))
);
if (!isAuthenticated || !user) {
return <span className="text-xs font-mono text-text-muted">Гость</span>;
}
return (
<div className="flex items-center gap-3">
<span className="text-sm font-semibold text-accent-cyan">{user.email}</span>
<span className="text-xs px-2 py-0.5 rounded bg-bg-elevated border border-border text-text-muted">
{user.role}
</span>
<button
onClick={logout}
className="text-xs text-danger hover:underline font-mono"
>
Выход
</button>
</div>
);
}Первый вызов каррированной функции create фиксирует тип T = RootStore в TypeScript, создавая изолированный контекст вывода.
Обертки devtools -> persist -> immer поочередно добавляют мета-типы мутаций в кортеж Mutator Tuple компилятора.
Благодаря накопленному кортежу мутаторов первый аргумент set распознается как Immer Draft<RootStore>, разрешая прямые мутации.
Компилятор генерирует тип useRootStore, предоставляющий автокомплит всех полей стора и утилиты useRootStore.getState().
import { create, StateCreator, StoreMutatorIdentifier } from 'zustand';
// Описание кастомного мутатора для системы типов Zustand
type Logger = <
T,
Mps extends [StoreMutatorIdentifier, unknown][] = [],
Mcs extends [StoreMutatorIdentifier, unknown][] = []
>(
f: StateCreator<T, Mps, Mcs>,
name?: string
) => StateCreator<T, Mps, Mcs>;
type LoggerImpl = <T>(
f: StateCreator<T, [], []>,
name?: string
) => StateCreator<T, [], []>;
const loggerImpl: LoggerImpl = (f, name) => (set, get, store) => {
const loggedSet: typeof set = (...args) => {
if (process.env.NODE_ENV !== 'production') {
console.group(`[Zustand Action: ${name ?? 'Anonymous'}]`);
console.log('Previous State:', get());
}
set(...args);
if (process.env.NODE_ENV !== 'production') {
console.log('Next State:', get());
console.groupEnd();
}
};
store.setState = loggedSet;
return f(loggedSet, get, store);
};
export const logger = loggerImpl as unknown as Logger;
// Пример создания изолированного хранилища настроек
export interface SettingsState {
readonly theme: 'dark' | 'light' | 'system';
readonly language: string;
readonly setTheme: (theme: 'dark' | 'light' | 'system') => void;
}
export const useSettingsStore = create<SettingsState>()(
logger(
(set) => ({
theme: 'system',
language: 'ru',
setTheme: (theme) => set({ theme }),
}),
'SettingsStore'
)
);Почему возврат нового объекта литерала (s) => ({ a: s.a, b: s.b }) из хука Zustand без useShallow приводит к постоянным лишним ререндерам?
При вызове create<MyState>((set) => immer(...)) компилятор считает, что set принимает обычный объект, и ругается на попытку мутировать state.property = value, так как тип draft не был выведен.
// ❌ АНТИПАТТЕРН: Некаррированный вызов ломает Immer
const useBadStore = create<MyState>(
immer((set) => ({
count: 0,
inc: () => set((state) => { state.count += 1; }), // ОШИБКА КОМПИЛЯЦИИ!
}))
);
// ✅ ПРАВИЛЬНО: Каррированный вызов с пустыми скобками
const useGoodStore = create<MyState>()(
immer((set) => ({
count: 0,
inc: () => set((state) => { state.count += 1; }), // Работает идеально!
}))
);Если порядок мутаторов в кортеже слайса не совпадает с реальным порядком их применения в корневом rootStore.ts, компилятор выдаст ошибку несовместимости типов аргументов set.
// ❌ АНТИПАТТЕРН: Несоответствие порядка devtools и immer
export const createBadSlice: StateCreator<
RootStore,
[['zustand/immer', never], ['zustand/devtools', never]], // Перепутан порядок!
[],
MySlice
> = ...Селектор, возвращающий новый литерал объекта { a: state.a, b: state.b } на каждый вызов, приводит к тому, что Object.is возвращает false, вызывая непрерывные повторные рендеры компонента.
// ❌ АНТИПАТТЕРН: Ререндер компонента при ЛЮБОМ изменении хранилища
const { a, b } = useRootStore((s) => ({ a: s.a, b: s.b }));
// ✅ ПРАВИЛЬНО: Обертка в useShallow
const { a, b } = useRootStore(useShallow((s) => ({ a: s.a, b: s.b })));Если authSlice.ts импортирует тип CartSlice, а cartSlice.ts импортирует AuthSlice напрямую, TypeScript может зациклиться при генерации .d.ts файлов. Все слайсы должны ссылаться только на общий RootStore.
// ✅ ПРАВИЛЬНО: Слайс типизируется через RootStore, объявленный в корневом файле
export const createAuthSlice: StateCreator<RootStore, ...> = ...