React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

zustandtypescripttypesgenerics

TypeScript with Zustand

Recipe

Type Zustand stores by passing an interface to create<State>(). Use StateCreator for slices and middleware typing. Zustand's TypeScript support provides full inference for selectors, actions, and middleware stacking.

import { create } from "zustand";
 
// Define state and actions in one interface
interface BearStore {
  bears: number;
  hungry: boolean;
  addBear: () => void;
  removeBear: () => void;
  setHungry: (hungry: boolean) => void;
}
 
// Pass the interface as a generic
const useBearStore = create<BearStore>((set) => ({
  bears: 0,
  hungry: false,
  addBear: () => set((s) => ({ bears: s.bears + 1 })),
  removeBear: () => set((s) => ({ bears: Math.max(0, s.bears - 1) })),
  setHungry: (hungry) => set({ hungry }),
}));

Working Example

// types/store-types.ts
export interface User {
  id: string;
  name: string;
  email: string;
  role: "admin" | "editor" | "viewer";
}
 
export interface Project {
  id: string;
  name: string;
  ownerId: string;
  status: "active" | "archived" | "draft";
}
 
// Separate state from actions for clarity
export interface WorkspaceState {
  currentUser: User | null;
  projects: Project[];
  activeProjectId: string | null;
  isLoading: boolean;
  error: string | null;
}
 
export interface WorkspaceActions {
  setUser: (user: User | null) => void;
  fetchProjects: () => Promise<void>;
  setActiveProject: (id: string | null) => void;
  createProject: (input: Pick<Project, "name">) => Promise<Project>;
  archiveProject: (id: string) => Promise<void>;
}
 
export type WorkspaceStore = WorkspaceState & WorkspaceActions;
// stores/workspace-store.ts
import { create } from "zustand";
import { devtools, persist } from "zustand/middleware";
import type { WorkspaceStore, WorkspaceState, Project } from "@/types/store-types";
 
const initialState: WorkspaceState = {
  currentUser: null,
  projects: [],
  activeProjectId: null,
  isLoading: false,
  error: null,
};
 
export const useWorkspaceStore = create<WorkspaceStore>()(
  devtools(
    persist(
      (set, get) => ({
        ...initialState,
 
        setUser: (user) => set({ currentUser: user }),
 
        fetchProjects: async () => {
          set({ isLoading: true, error: null });
          try {
            const res = await fetch("/api/projects");
            if (!res.ok) throw new Error("Failed to fetch");
            const projects: Project[] = await res.json();
            set({ projects, isLoading: false });
          } catch (err) {
            set({ error: (err as Error).message, isLoading: false });
          }
        },
 
        setActiveProject: (id) => set({ activeProjectId: id }),
 
        createProject: async (input) => {
          set({ isLoading: true, error: null });
          try {
            const user = get().currentUser;
            if (!user) throw new Error("Not authenticated");
 
            const res = await fetch("/api/projects", {
              method: "POST",
              headers: { "Content-Type": "application/json" },
              body: JSON.stringify({ ...input, ownerId: user.id }),
            });
            const project: Project = await res.json();
            set((s) => ({
              projects: [...s.projects, project],
              isLoading: false,
            }));
            return project;
          } catch (err) {
            set({ error: (err as Error).message, isLoading: false });
            throw err;
          }
        },
 
        archiveProject: async (id) => {
          await fetch(`/api/projects/${id}`, {
            method: "PATCH",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ status: "archived" }),
          });
          set((s) => ({
            projects: s.projects.map((p) =>
              p.id === id ? { ...p, status: "archived" as const } : p
            ),
          }));
        },
      }),
      {
        name: "workspace",
        partialize: (state): Pick<WorkspaceState, "activeProjectId"> => ({
          activeProjectId: state.activeProjectId,
        }),
      }
    ),
    { name: "WorkspaceStore" }
  )
);
// hooks/use-workspace.ts — typed selector hooks
import { useWorkspaceStore } from "@/stores/workspace-store";
import type { Project } from "@/types/store-types";
 
export function useActiveProject(): Project | undefined {
  return useWorkspaceStore((s) =>
    s.projects.find((p) => p.id === s.activeProjectId)
  );
}
 
export function useProjectsByStatus(status: Project["status"]): Project[] {
  return useWorkspaceStore((s) => s.projects.filter((p) => p.status === status));
}
 
export function useIsProjectOwner(projectId: string): boolean {
  return useWorkspaceStore(
    (s) => s.projects.find((p) => p.id === projectId)?.ownerId === s.currentUser?.id
  );
}

Deep Dive

How It Works

  • create<State>() requires the generic to type the entire store (state + actions).
  • When using middleware, the double invocation create<State>()(middleware(...)) is required for TypeScript to correctly infer middleware types.
  • StateCreator<State, Mutators, Mutators, SliceState> is the core type for slice patterns and middleware.
  • Zustand uses TypeScript's mapped types and conditional types internally to thread middleware type information through the middleware chain.
  • Selectors are fully typed: useStore((s: State) => s.field) infers the return type from the selector.

Variations

Extracting state type from store:

// Infer types from existing store
type State = ReturnType<typeof useWorkspaceStore.getState>;
type ActiveProject = ReturnType<typeof useActiveProject>;

Typed StateCreator for slices:

import { StateCreator } from "zustand";
 
interface AuthSlice {
  token: string | null;
  login: (credentials: { email: string; password: string }) => Promise<void>;
}
 
interface DataSlice {
  items: string[];
  fetchItems: () => Promise<void>;
}
 
type FullStore = AuthSlice & DataSlice;
 
const createAuthSlice: StateCreator<FullStore, [], [], AuthSlice> = (set) => ({
  token: null,
  login: async (credentials) => {
    const { token } = await fetch("/api/login", {
      method: "POST",
      body: JSON.stringify(credentials),
    }).then((r) => r.json());
    set({ token });
  },
});

Typed middleware stacking:

import { create } from "zustand";
import { devtools, persist } from "zustand/middleware";
import { immer } from "zustand/middleware/immer";
 
// TypeScript infers the correct type through all middleware layers
const useStore = create<MyState>()(
  devtools(
    persist(
      immer((set) => ({
        // set accepts Draft<MyState> mutations here
      })),
      { name: "store" }
    ),
    { name: "DevTools" }
  )
);

TypeScript Notes

  • Always use the create<State>()() pattern with middleware. Without the double invocation, TypeScript cannot infer middleware types.
  • set is typed as (partial: Partial<State> | ((s: State) => Partial<State>), replace?: boolean) => void.
  • get is typed as () => State, giving full access to current state.
  • When using immer, set's callback parameter becomes Draft<State>, allowing mutations.
  • partialize in persist should return a well-typed subset: use Pick<State, keys>.
// Explicit typing for complex stores
import { StoreApi, UseBoundStore } from "zustand";
 
type MyStore = UseBoundStore<StoreApi<MyState>>;
const useStore: MyStore = create<MyState>()((set) => ({ ... }));

Gotchas

  • Forgetting the ()() double invocation with middleware causes cryptic TypeScript errors. Always use create<State>()( middleware(...) ).
  • set({ someField: value }) only requires a partial, but TypeScript does not warn if you set a field that does not exist in the state. It silently ignores extra fields.
  • Functions in state (actions) are typed but serialization-unsafe. If you use persist, always partialize to exclude actions.
  • When using StateCreator with four generic parameters for slices, getting the order wrong causes confusing type errors. The order is: StateCreator<FullStore, MutatorsIn, MutatorsOut, SliceType>.
  • ReturnType<typeof store.getState> includes action functions in the type. If you need just the state shape, define separate State and Actions interfaces.
  • as const assertions on literal values in set may be needed to preserve narrow types (e.g., status: "active" as const).

Alternatives

ApproachProsCons
Single interface (State + Actions)Simple, one type to manageGrows large for complex stores
Separate State and Actions typesClear separation, reusable State typeMore types to maintain
Inferred types (no explicit generic)Less boilerplateWeaker guarantees, easy to get any
Zod-validated stateRuntime validation + type inferenceExtra dependency, more code

FAQs

How do you type a basic Zustand store in TypeScript?
interface BearStore {
  bears: number;
  addBear: () => void;
}
 
const useBearStore = create<BearStore>((set) => ({
  bears: 0,
  addBear: () => set((s) => ({ bears: s.bears + 1 })),
}));
  • Pass the full interface (state + actions) as a generic to create<State>().
Why is the create<State>()() double invocation required with middleware?
  • The first () enables TypeScript to correctly infer middleware types through the chain.
  • Without it, cryptic type errors occur because TypeScript cannot thread middleware type information.
Should you define state and actions in one interface or separate them?
  • For small stores, a single combined interface is simpler.
  • For large stores, separate State and Actions interfaces improve clarity and reuse.
  • The store type is always the intersection: type Store = State & Actions.
How do you infer the store type from an existing store?
type State = ReturnType<typeof useWorkspaceStore.getState>;
  • This includes both state and action functions in the inferred type.
What is StateCreator and when do you need its four-parameter generic form?
  • StateCreator is the core type for slice patterns and middleware.
  • The four-parameter form StateCreator<FullStore, MutatorsIn, MutatorsOut, SliceType> is required when a slice needs cross-slice access or uses middleware.
  • For simple slices, StateCreator<SliceType> is sufficient.
How are selectors typed in Zustand?
  • The return type of the selector determines the value's type automatically.
  • useStore((s) => s.count) returns number if count is typed as number.
  • No explicit return type annotation is needed.
Gotcha: Does TypeScript warn if you set a field that does not exist in the state?
  • No. set({ nonexistent: 123 }) silently ignores extra fields.
  • TypeScript does not flag this because set accepts Partial<State>, which is permissive with extra properties in some positions.
Gotcha: Why might you need as const when calling set with literal values?
  • Without as const, TypeScript may widen a string literal like "active" to string.
  • Use as const to preserve narrow types: status: "active" as const.
  • This matters when your state type uses union literals like "active" | "archived".
How do you type partialize in the persist middleware?
partialize: (state): Pick<WorkspaceState, "activeProjectId"> => ({
  activeProjectId: state.activeProjectId,
}),
  • Use Pick<State, keys> for an explicit, type-safe return type.
How do you type a custom selector hook that returns a derived value?
function useActiveProject(): Project | undefined {
  return useWorkspaceStore((s) =>
    s.projects.find((p) => p.id === s.activeProjectId)
  );
}
  • Annotate the return type explicitly for clarity and safety.
What is the difference between StoreApi and UseBoundStore types?
  • StoreApi<State> is the vanilla store type (no React hook).
  • UseBoundStore<StoreApi<State>> is the React hook type returned by create.
  • Use StoreApi when working with createStore for vanilla or Next.js SSR patterns.
Does ReturnType<typeof store.getState> include actions in the type?
  • Yes. It includes everything -- both state properties and action functions.
  • If you need just the state shape, define separate State and Actions interfaces and use State directly.