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. setis typed as(partial: Partial<State> | ((s: State) => Partial<State>), replace?: boolean) => void.getis typed as() => State, giving full access to current state.- When using
immer,set's callback parameter becomesDraft<State>, allowing mutations. partializeinpersistshould return a well-typed subset: usePick<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 usecreate<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, alwayspartializeto exclude actions. - When using
StateCreatorwith 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 separateStateandActionsinterfaces.as constassertions on literal values insetmay be needed to preserve narrow types (e.g.,status: "active" as const).
Alternatives
| Approach | Pros | Cons |
|---|---|---|
| Single interface (State + Actions) | Simple, one type to manage | Grows large for complex stores |
| Separate State and Actions types | Clear separation, reusable State type | More types to maintain |
| Inferred types (no explicit generic) | Less boilerplate | Weaker guarantees, easy to get any |
| Zod-validated state | Runtime validation + type inference | Extra 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
StateandActionsinterfaces 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?
StateCreatoris 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)returnsnumberifcountis typed asnumber.- 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
setacceptsPartial<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"tostring. - Use
as constto 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 bycreate.- Use
StoreApiwhen working withcreateStorefor 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
StateandActionsinterfaces and useStatedirectly.