Коротко: Полное практическое руководство по типизации Zustand с TypeScript: правильная настройка middleware persist, immer, devtools и паттерна slices без ошибок.
Zustand стал стандартом стейт-менеджмента во многих React-проектах благодаря минималистичному API, отсутствию бойлерплейта и независимости от React Context. Однако при переходе от базовых примеров к реальным задачам — сохранению стейта, безопасным мутациям вложенных структур и отладке через Redux DevTools — разработчики регулярно сталкиваются с ошибками компилятора TypeScript.
Проблема обычно заключается не в самих типах библиотек, а в механизме вывода дженериков при вложенных вызовах middleware и разделении стора на слайсы (slices). Разберем, как выстроить полностью типобезопасную архитектуру Zustand-стора с использованием persist, immer и devtools.
Фундамент типизации: почему важен каррированный синтаксис create<T>()
При объявлении базового стора без middleware компилятор TypeScript может самостоятельно вывести типы из возвращаемого объекта. Но как только добавляются middleware, автоматический вывод типов усложняется из-за ограничений системы типов языка при частичной передаче generic-параметров.
Разница между create<T>(...) и create<T>()(...)
Исторически во многих руководствах встречался синтаксис create<State>((set) => (...)). В современных версиях Zustand для TypeScript стандартом является каррированная форма:
import { create } from 'zustand';
interface UserState {
name: string;
age: number;
setAge: (age: number) => void;
}
// ❌ Не рекомендуется при использовании middleware:
// create<UserState>((set) => ({ ... }))
// ✅ Правильно: каррированный вызов
export const useUserStore = create<UserState>()((set) => ({
name: 'Тимофей',
age: 30,
setAge: (age) => set({ age }),
}));
Двойные скобки create<T>()(...) — это архитектурное решение. Первый вызов принимает дженерик T (тип состояния и экшенов), а второй — саму функцию создания стора (StateCreator). Это позволяет TypeScript сначала зафиксировать тип состояния, а затем корректно прокинуть его через цепочки трансформаций типов, которые выполняют middleware.
Типизация middleware persist (сохранение состояния)
Middleware persist синхронизирует состояние стора с внешними хранилищами (localStorage, sessionStorage, AsyncStorage или IndexedDB).
Базовая настройка с createJSONStorage
Для типобезопасного сохранения используется хелпер createJSONStorage.
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
interface SettingsState {
theme: 'light' | 'dark' | 'system';
notifications: boolean;
setTheme: (theme: 'light' | 'dark' | 'system') => void;
toggleNotifications: () => void;
}
export const useSettingsStore = create<SettingsState>()(
persist(
(set) => ({
theme: 'system',
notifications: true,
setTheme: (theme) => set({ theme }),
toggleNotifications: () => set((state) => ({ notifications: !state.notifications })),
}),
{
name: 'app-settings-storage', // уникальный ключ в хранилище
storage: createJSONStorage(() => localStorage),
}
)
);
Опции partialize, version и типизация миграций (migrate)
В реальных приложениях в хранилище нужно сохранять только данные, исключая функции-экшены или временные флаги загрузки. Для этого используются опции partialize и migrate.
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
interface AuthState {
token: string | null;
refreshToken: string | null;
isLoading: boolean;
setTokens: (token: string, refreshToken: string) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
token: null,
refreshToken: null,
isLoading: false,
setTokens: (token, refreshToken) => set({ token, refreshToken }),
logout: () => set({ token: null, refreshToken: null }),
}),
{
name: 'auth-storage',
storage: createJSONStorage(() => localStorage),
// Сохраняем только токены, исключая экшены и флаг загрузки
partialize: (state) => ({
token: state.token,
refreshToken: state.refreshToken,
}),
version: 2,
// migrate типизируется с проверкой предыдущих версий состояния
migrate: (persistedState: unknown, version: number) => {
if (version === 1) {
// Обработка перехода с версии 1 на версию 2
const oldState = persistedState as { token: string };
return {
token: oldState.token,
refreshToken: null,
};
}
return persistedState as AuthState;
},
}
)
);
Доступ к API хранилища (useStore.persist)
При использовании persist объект стора расширяется специальными методами:
useSettingsStore.persist.rehydrate()— принудительная повторная загрузка из хранилища;useSettingsStore.persist.hasHydrated()— проверка завершения гидратации (актуально для SSR в Next.js);useSettingsStore.persist.clearStorage()— удаление сохраненных данных.
Благодаря каррированному вызову create<T>()(...) TypeScript автоматически видит свойство .persist на созданном хуке без необходимости вручную расширять интерфейс стора.
Типизация middleware immer (мутабельные обновления)
Middleware immer позволяет работать с глубоко вложенным состоянием через псевдомутации (на базе Proxy-драфтов).
Перед использованием необходимо установить саму библиотеку immer:
npm install immer
Подключение immer и сигнатура set
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
interface Project {
id: string;
title: string;
tasks: { id: string; done: boolean; text: string }[];
}
interface WorkspaceState {
projects: Record<string, Project>;
toggleTask: (projectId: string, taskId: string) => void;
addProject: (project: Project) => void;
}
export const useWorkspaceStore = create<WorkspaceState>()(
immer((set) => ({
projects: {},
toggleTask: (projectId, taskId) =>
set((state) => {
// Прямая мутация без ручного копирования через spread-операторы
const task = state.projects[projectId]?.tasks.find((t) => t.id === taskId);
if (task) {
task.done = !task.done;
}
}),
addProject: (project) =>
set((state) => {
state.projects[project.id] = project;
}),
}))
);
При подключении immer тип аргумента внутри set меняется: функция принимает Draft<WorkspaceState>. Главное правило TypeScript здесь: коллбэк мутации не должен ничего возвращать. Если случайно написать set((state) => state.projects[id] = project) с неявным возвратом значения присваивания, компилятор выдаст ошибку.
Подключение devtools для отладки
Middleware devtools интегрирует стор с расширением Redux DevTools в браузере.
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
interface CounterState {
count: number;
increment: () => void;
decrement: (by: number) => void;
}
export const useCounterStore = create<CounterState>()(
devtools(
(set) => ({
count: 0,
// Третий аргумент set — имя экшена для отображения в Redux DevTools
increment: () => set((state) => ({ count: state.count + 1 }), false, 'counter/increment'),
decrement: (by) => set((state) => ({ count: state.count - by }), false, { type: 'counter/decrement', by }),
}),
{
name: 'CounterStore', // Название инстанса стора в DevTools
enabled: process.env.NODE_ENV !== 'production', // Отключение в проде
}
)
);
Когда devtools оборачивает функцию создания стора, сигнатура set расширяется. Она принимает дополнительные аргументы: replace (флаг полной замены стейта) и action (строка или объект действия для панели DevTools).
Комбинирование нескольких middleware (persist + immer + devtools)
При комбинации middleware решающее значение имеет порядок оборачивания. Типовой и рекомендуемый порядок снаружи внутрь: devtools -> persist -> immer.
devtools(persist(immer((set, get) => ...)))
Такая последовательность гарантирует:
devtoolsфиксирует все изменения и передает понятные имена экшенов;persistсохраняет уже обновленный и валидный сериализуемый стейт;immerмодифицирует внутреннюю логику вызоваset, предоставляя черновик (draft).
Готовый типобезопасный пример составного стора
import { create } from 'zustand';
import { devtools, persist, createJSONStorage } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
interface FilterItem {
field: string;
value: string;
}
interface TableUIState {
page: number;
filters: FilterItem[];
setPage: (page: number) => void;
addFilter: (filter: FilterItem) => void;
resetFilters: () => void;
}
export const useTableUIStore = create<TableUIState>()(
devtools(
persist(
immer((set) => ({
page: 1,
filters: [],
setPage: (page) =>
set(
(state) => {
state.page = page;
},
false,
'table/setPage'
),
addFilter: (filter) =>
set(
(state) => {
state.filters.push(filter);
},
false,
'table/addFilter'
),
resetFilters: () =>
set(
(state) => {
state.filters = [];
state.page = 1;
},
false,
'table/resetFilters'
),
})),
{
name: 'table-ui-storage',
storage: createJSONStorage(() => sessionStorage),
partialize: (state) => ({ filters: state.filters }),
}
),
{ name: 'TableUIStore' }
)
);
Масштабирование: типизация паттерна Slices со сложными middleware
Когда состояние приложения разрастается, единый стор делят на независимые модули — слайсы (slices). При использовании middleware каждый слайс должен быть типизирован через дженерик StateCreator.
Разбор дженерика StateCreator
Тип StateCreator принимает следующие ключевые аргументы:
T— общий тип корневого состояния (объединение всех слайсов);Mis(Middleware Insertions) — мутаторы, добавляемые внешними middleware (кортеж типов);Mos(Middleware Output) — мутаторы, модифицируемые данным слайсом;SliceState— возвращаемый тип конкретного слайса.
Если стор использует devtools и immer, сигнатура слайса принимает вид:
import { StateCreator } from 'zustand';
// Тип для слайса с devtools и immer
export type AppStateCreator<Root, Slice> = StateCreator<
Root,
[['zustand/devtools', never], ['zustand/immer', never]],
[],
Slice
>;
Сборка корневого стора из типизированных слайсов
Создадим два слайса: для управления профилем пользователя и для корзины покупок.
1. Слайс профиля (userSlice.ts)
import { StateCreator } from 'zustand';
import { RootStore } from './store';
export interface UserSlice {
user: { name: string; email: string } | null;
setUser: (user: { name: string; email: string } | null) => void;
}
export const createUserSlice: StateCreator<
RootStore,
[['zustand/devtools', never], ['zustand/immer', never]],
[],
UserSlice
> = (set) => ({
user: null,
setUser: (user) =>
set(
(state) => {
state.user = user;
},
false,
'user/setUser'
),
});
2. Слайс корзины (cartSlice.ts)
import { StateCreator } from 'zustand';
import { RootStore } from './store';
export interface CartSlice {
items: { id: string; count: number }[];
addItem: (id: string) => void;
clearCart: () => void;
}
export const createCartSlice: StateCreator<
RootStore,
[['zustand/devtools', never], ['zustand/immer', never]],
[],
CartSlice
> = (set) => ({
items: [],
addItem: (id) =>
set(
(state) => {
const item = state.items.find((i) => i.id === id);
if (item) {
item.count += 1;
} else {
state.items.push({ id, count: 1 });
}
},
false,
'cart/addItem'
),
clearCart: () =>
set(
(state) => {
state.items = [];
},
false,
'cart/clearCart'
),
});
3. Корневой стор (store.ts)
import { create } from 'zustand';
import { devtools, persist, createJSONStorage } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
import { createUserSlice, UserSlice } from './userSlice';
import { createCartSlice, CartSlice } from './cartSlice';
export type RootStore = UserSlice & CartSlice;
export const useBoundStore = create<RootStore>()(
devtools(
persist(
immer((...a) => ({
...createUserSlice(...a),
...createCartSlice(...a),
})),
{
name: 'app-root-storage',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({ items: state.items }),
}
),
{ name: 'RootStore' }
)
);
Типичные ошибки типизации и как их исправить
Ошибка «Expected 0 type arguments, but got 1»
- Причина: Использование старого синтаксиса
create<Store>((set) => ...)вместе с middleware. - Решение: Использовать каррирование:
create<Store>()(middleware(...)).
Ошибка «Type '(state: Draft<T>) => void' is not assignable to type 'T'»
- Причина: В функции
setс middlewareimmerпроисходит возврат значения вместо мутации draft-объекта (например, короткая запись стрелочной функции без фигурных скобокset((state) => state.count = 5)). - Решение: Обернуть тело коллбэка в фигурные скобки
{ state.count = 5; }или вернуть новый объект стейта целиком.
Потеря автодополнения и типов аргументов в set при композиции
- Причина: Нарушен порядок вызовов middleware или пропущен кортеж
[['zustand/middlewareName', never]]внутриStateCreator. - Решение: Синхронизировать порядок: внешний
devtoolsдолжен идти первым в generic-спискеStateCreator, а затемimmer.
FAQ (Часто задаваемые вопросы)
1. Зачем нужен двойной вызов create<State>()(...) в TypeScript?
TypeScript не поддерживает частичный вывод дженериков (Partial Type Argument Inference). Каррированный вызов разделяет передачу явного типа состояния T и вывод типов внутренних аргументов middleware, предотвращая стирание сигнатур методов set и get.
2. В каком порядке нужно оборачивать devtools, persist и immer?
Оптимальная вложенность: devtools(persist(immer((set, get) => ({ ... })))). Это позволяет DevTools получать корректные имена экшенов, persist — сериализовать итоговое состояние, а immer — безопасно мутировать черновик внутри экшенов.
3. Как правильно типизировать partialize в persist, если нужно сохранить только часть полей?
Функция partialize принимает полный тип состояния State и возвращает объект с выбранными полями. В большинстве случаев TypeScript автоматически выводит возвращаемый тип, если вы передаете свойства существующего состояния:
partialize: (state) => ({ token: state.token })
4. Почему при использовании immer TypeScript ругается на возвращаемое значение в set?
Immer ожидает одно из двух: либо вы мутируете переданный объект draft и ничего не возвращаете (void), либо возвращаете абсолютно новый объект состояния. Если стрелочная функция случайно возвращает результат присваивания (например, (state) => state.val = 1), TS видит конфликт сигнатур.
5. Как избежать явного дублирования типов StateCreator для каждого отдельного слайса?
Создайте вспомогательный generic-тип в отдельном файле (например, types/zustand.ts):
import { StateCreator } from 'zustand';
import { RootStore } from './store';
export type CustomSlice<T> = StateCreator<
RootStore,
[['zustand/devtools', never], ['zustand/immer', never]],
[],
T
>;
После этого объявляйте слайсы лаконично: export const createAuthSlice: CustomSlice<AuthSlice> = (set) => ({ ... }).
Резюме
Для надежной и типобезопасной работы с Zustand в TypeScript:
- Всегда используйте каррированную форму
create<T>()(...). - Соблюдайте порядок middleware:
devtools➔persist➔immer. - Для слайсов используйте точную типизацию
StateCreatorс указанием мутаторов middleware. - Следите за отсутствием неявных возвращаемых значений при мутациях стейта внутри
immer.




.svg.webp)



