Сайт использует сookies для хранения данных. Продолжая использовать сайт, вы даёте согласие на работу с этими файлами.

ОК
💻
Технологии
Опубликовано:
14.09.2026
Обновлено:
14.09.2026

Zustand с TypeScript: полное руководство по типизации middleware (persist, immer, devtools)

Тимофей Ищенко

Коротко: Полное практическое руководство по типизации 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) => ...)))

Такая последовательность гарантирует:

  1. devtools фиксирует все изменения и передает понятные имена экшенов;
  2. persist сохраняет уже обновленный и валидный сериализуемый стейт;
  3. 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 принимает следующие ключевые аргументы:

  1. T — общий тип корневого состояния (объединение всех слайсов);
  2. Mis (Middleware Insertions) — мутаторы, добавляемые внешними middleware (кортеж типов);
  3. Mos (Middleware Output) — мутаторы, модифицируемые данным слайсом;
  4. 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 с middleware immer происходит возврат значения вместо мутации 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:

  1. Всегда используйте каррированную форму create<T>()(...).
  2. Соблюдайте порядок middleware: devtoolspersistimmer.
  3. Для слайсов используйте точную типизацию StateCreator с указанием мутаторов middleware.
  4. Следите за отсутствием неявных возвращаемых значений при мутациях стейта внутри immer.

Источники

Это авторская статья, основанная на личном опыте и субъективном взгляде автора. Заметили ошибку или битую ссылку? Сообщите нам: info@codesrc.ru - мы оперативно исправим. Спасибо, что помогаете делать блог лучше.
Следите за нами в соцсетях:

Читайте также