//
Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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 }),
}));// 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
);
}create<State>() requires the generic to type the entire store (state + actions).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.useStore((s: State) => s.field) infers the return type from the selector.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" }
)
);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.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) => ({ ... }));()() 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.persist, always partialize to exclude actions.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).| 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 |
interface BearStore {
bears: number;
addBear: () => void;
}
const useBearStore = create<BearStore>((set) => ({
bears: 0,
addBear: () => set((s) => ({ bears: s.bears + 1 })),
}));create<State>().() enables TypeScript to correctly infer middleware types through the chain.State and Actions interfaces improve clarity and reuse.type Store = State & Actions.type State = ReturnType<typeof useWorkspaceStore.getState>;StateCreator is the core type for slice patterns and middleware.StateCreator<FullStore, MutatorsIn, MutatorsOut, SliceType> is required when a slice needs cross-slice access or uses middleware.StateCreator<SliceType> is sufficient.useStore((s) => s.count) returns number if count is typed as number.set({ nonexistent: 123 }) silently ignores extra fields.set accepts Partial<State>, which is permissive with extra properties in some positions.as const, TypeScript may widen a string literal like "active" to string.as const to preserve narrow types: status: "active" as const."active" | "archived".partialize: (state): Pick<WorkspaceState, "activeProjectId"> => ({
activeProjectId: state.activeProjectId,
}),Pick<State, keys> for an explicit, type-safe return type.function useActiveProject(): Project | undefined {
return useWorkspaceStore((s) =>
s.projects.find((p) => p.id === s.activeProjectId)
);
}StoreApi<State> is the vanilla store type (no React hook).UseBoundStore<StoreApi<State>> is the React hook type returned by create.StoreApi when working with createStore for vanilla or Next.js SSR patterns.State and Actions interfaces and use State directly.Reviewed by Chris St. John·Last updated Jul 10, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥