В екосистемі TypeScript управління клієнтським станом за допомогою Zustand здобуло широку популярність завдяки мінімалістичній архітектурі на базі замикань та підписок через useSyncExternalStore. Проте при масштабуванні до рівня enterprise-архітектури інженери стикаються зі значною складністю: типізацією ланцюжків проміжного ПЗ (Middleware Pipeline) та модульною композицією незалежних зрізів стану (Slice Pattern).
Історично функція create у Zustand мала сигнатуру create<T>(...). При явній передачі параметра T компілятор TypeScript фіксував кореневий тип стану, але втрачав здатність автоматично виводити типи мутацій, які вносяться проміжним ПЗ (наприклад, аргументи draft в 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' -> Додає назву дії у set() |
| |-- Mutator 2: 'zustand/persist' -> Надає API api.persist |
| `-- Mutator 3: 'zustand/immer' -> Перетворює draft у set() |
+-------------------------------------------------------------------------+
|
v
+-------------------------------------------------------------------------+
| Результуючий тип: UseBoundStore<Write<StoreApi<T>, StoreMutators>> |
| (Повний автокомпліт методів сховища, actions та middleware API) |
+-------------------------------------------------------------------------+Внутрішня архітектура типізації Zustand спирається на дженерик StateCreator, що оперує кортежами мутаторів:
Взаємодія слайсів через StateCreator:
+-------------------------------------------------------------+
| RootStore |
| (AuthSlice & CartSlice & NotificationSlice) |
+-------------------------------------------------------------+
^ ^
| |
StateCreator<...> StateCreator<...>
| |
+--------------------+ +--------------------+
| AuthSlice | | CartSlice |
| - user: User | | - items: Item[] |
| - login(): void | | - checkout(): void|
| | | (читає auth через |
| | | виклик get()) |
+--------------------+ +--------------------+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()#Слайс кошика демонструє безпечне читання стану автентифікації із сусіднього слайса за допомогою 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. Типобезпечні назви дій для Redux 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' }
)
);useShallow#'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>
);
}Перший виклик каррованої функції закріплює контракт T = RootStore, створюючи ізольований контекст виведення.
Обгортки devtools -> persist -> immer послідовно додають мета-типи мутацій до кортежу Mutator Tuple.
Завдяки кортежу мутаторів аргумент set розпізнається як Immer Draft<RootStore>, дозволяючи прямі зміни.
Компілятор генерує тип useRootStore з повним автодоповненням actions та методу 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('Попередній стан:', get());
}
set(...args);
if (process.env.NODE_ENV !== 'production') {
console.log('Новий стан:', 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: 'uk',
setTheme: (theme) => set({ theme }),
}),
'SettingsStore'
)
);При виклику 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, компілятор TypeScript видасть помилку несумісності типів аргументів 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.ts, а cartSlice.ts напряму імпортує authSlice.ts, TypeScript може зіткнутися з лімітом глибини під час генерації .d.ts. Усі слайси повинні посилатися лише на спільний контракт RootStore.
// ✅ ПРАВИЛЬНО: Слайс типізується через спільний RootStore
export const createAuthSlice: StateCreator<RootStore, ...> = ...Опишіть інтерфейси AuthSlice, CartSlice та об'єднайте їх у спільний RootStore = AuthSlice & CartSlice.
Реалізуйте кожен слайс в окремому файлі з типізацією через StateCreator<RootStore, Mutators, [], Slice>.
Об'єднайте слайси у rootStore.ts, обгорнувши ланцюжком devtools(persist(immer(...))).
Реалізуйте partialize для збереження у сховищі лише необхідних полів стану.
Використовуйте useShallow для композитних селекторів, щоб уникнути зайвих ререндерів інтерфейсу.