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.