Zustand: Minimal Global State with Advanced TypeScript
Zustand’s selling proposition for TypeScript teams is restraint. You describe state and actions as a
plain interface, create returns a hook-shaped store, and the type system travels with you into
selectors and middleware without a parallel DSL. Where larger solutions ask you to learn lifecycles
and conventions first, Zustand asks you to be clear about what lives in the store and who may
update it.
That clarity becomes load-bearing when the store grows. Middleware stacks (devtools for DX, persist
for hydration, immer for ergonomic updates) compose without forcing you to give up inference—as long
as you use the curried create<T>()(...) form that preserves generics through the pipeline.
A fully typed store with middleware
The following pattern is the backbone of most production Zustand setups: explicit UserStore shape,
actions as methods, and a middleware onion that matches how the store is observed and stored.
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
import { persist, devtools } from "zustand/middleware";
interface User {
id: string;
name: string;
}
interface UserStore {
user: User | null;
isLoading: boolean;
error: string | null;
setUser: (user: User) => void;
logout: () => void;
}
const useUserStore = create<UserStore>()(
devtools(
persist(
(set) => ({
user: null,
isLoading: false,
error: null,
setUser: (user) => set({ user }),
logout: () => set({ user: null }),
}),
{ name: "user-storage" },
),
),
);
const user = useUserStore((state) => state.user);
const isLoading = useUserStore((state) => state.isLoading);
Two details matter for both performance and types. First, selectors should be as narrow as
possible: state => state.user avoids rerenders when isLoading flips. Second, the curried
create<UserStore>() is what allows TypeScript to thread UserStore through devtools and
persist without collapsing to any.
Auto-generating selectors
Hand-written selectors scale fine until you have dozens of fields and every screen wants ergonomic
access. The advanced guide pattern attaches a use namespace with one hook per state key, built by
introspecting getState() keys at initialization time.
import type { StoreApi, UseBoundStore } from "zustand";
type WithSelectors<S> = S extends { getState: () => infer T }
? S & { use: { [K in keyof T]: () => T[K] } }
: never;
function createSelectors<S extends UseBoundStore<StoreApi<object>>>(store: S) {
const storeIn = store as WithSelectors<typeof store>;
storeIn.use = {};
for (const k of Object.keys(storeIn.getState())) {
(storeIn.use as Record<string, unknown>)[k] = () => storeIn((s) => s[k as keyof typeof s]);
}
return storeIn;
}
Use this when your team agrees on the convention: it trades a little magic for consistent ergonomics. If only a few fields are read widely, explicit selectors remain easier to grep and tree-shake mentally.
Slices for modular stores
When multiple domains share one store—user session beside shopping cart, for example—slice
functions keep each concern in its own module while preserving a single CombinedStore type.
import { create, type SetState } from "zustand";
interface User {
id: string;
name: string;
}
interface CartItem {
id: string;
qty: number;
}
const createUserSlice = (set: SetState<CombinedStore>, ..._: unknown[]) => ({
user: null as User | null,
setUser: (user: User) => set({ user }),
});
const createCartSlice = (set: SetState<CombinedStore>, ..._: unknown[]) => ({
items: [] as CartItem[],
addItem: (item: CartItem) =>
set((state) => ({
items: [...state.items, item],
})),
});
type CombinedStore = ReturnType<typeof createUserSlice> & ReturnType<typeof createCartSlice>;
const useStore = create<CombinedStore>()((...a) => ({
...createUserSlice(...a),
...createCartSlice(...a),
}));
The type trick is intentional: CombinedStore is the intersection of slice return types, so each
slice’s actions know about sibling fields only if you thread types carefully. For very large apps,
some teams prefer multiple stores over one combined slice; either is valid if boundaries are
documented.
Immer for nested updates
When state is nested or list-shaped, immutable updates with spreads become noisy and error-prone. The Immer middleware lets you write mutative-looking logic that remains correct under the hood.
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
interface Todo {
id: string;
completed: boolean;
}
interface ComplexStore {
todos: Todo[];
toggleTodo: (id: string) => void;
}
const useComplexStore = create<ComplexStore>()(
immer((set) => ({
todos: [],
toggleTodo: (id: string) =>
set((state) => {
const todo = state.todos.find((t) => t.id === id);
if (todo) todo.completed = !todo.completed;
}),
})),
);
Immer is not free: it adds conceptual weight and bundle cost. Use it where updates touch graphs;
keep simple flags and counters on plain set({ ... }).
When Zustand is the wrong tool
Zustand excels at modest global client state with CRUD-shaped actions. It is a weaker default when:
- The state is mostly server-backed and shared across routes (prefer TanStack Query).
- The dependency structure is a dense derived graph with many incremental readers (consider Jotai).
- The state should be addressable (use the router).
If you reach for persist and devtools on every store, audit whether you are accidentally caching remote truth next to UI chrome—splitting stores or query keys often clarifies invalidation.
Zustand’s TypeScript story is “stay close to the object model.” Middleware in curried form, narrow selectors, slices for boundaries, and Immer for gnarly updates give you a global store that stays readable when the product surface area grows.
Testing and module boundaries
Stores that are plain objects invite straightforward unit tests: call actions, assert
getState(), snapshot only when the shape is stable. Avoid testing through React when the logic
lives in Zustand—hook tests are slower and blur the boundary between UI and state. For slices, test
each slice factory in isolation by passing a mock set that records patches, then integration-test
the combined store when cross-slice invariants matter. If a selector becomes complex enough to
deserve its own tests, consider lifting that computation to a pure module and keeping the store as
thin wiring; TypeScript will still infer the same types at the call site.