TypeScript con Zustand
Receta
Tipa los stores de Zustand pasando una interfaz a create<State>(). Usa StateCreator para slices y el tipado de middleware. El soporte de TypeScript de Zustand ofrece inferencia completa para selectores, acciones y apilamiento de middleware.
import { create } from "zustand";
// Define state y acciones en una sola interfaz
interface BearStore {
bears: number;
hungry: boolean;
addBear: () => void;
removeBear: () => void;
setHungry: (hungry: boolean) => void;
}
// Pasa la interfaz como genérico
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 }),
}));Ejemplo funcional
// 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";
}
// Separa state y acciones para mayor claridad
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("Error al obtener");
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("No autenticado");
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 — hooks de selector tipados
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
);
}Análisis profundo
Cómo funciona
create<State>()requiere el genérico para tipar todo el store (state + acciones).- Al usar middleware, la doble invocación
create<State>()(middleware(...))es necesaria para que TypeScript infiera correctamente los tipos del middleware. StateCreator<State, Mutators, Mutators, SliceState>es el tipo central para patrones de slices y middleware.- Zustand usa tipos mapeados y tipos condicionales de TypeScript internamente para propagar la información de tipos del middleware a través de la cadena de middleware.
- Los selectores están completamente tipados:
useStore((s: State) => s.field)infiere el tipo de retorno a partir del selector.
Variaciones
Extraer el tipo de state del store:
// Infiere tipos a partir de un store existente
type State = ReturnType<typeof useWorkspaceStore.getState>;
type ActiveProject = ReturnType<typeof useActiveProject>;StateCreator tipado para 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 });
},
});Apilamiento de middleware tipado:
import { create } from "zustand";
import { devtools, persist } from "zustand/middleware";
import { immer } from "zustand/middleware/immer";
// TypeScript infiere el tipo correcto a través de todas las capas de middleware
const useStore = create<MyState>()(
devtools(
persist(
immer((set) => ({
// set acepta mutaciones Draft<MyState> aquí
})),
{ name: "store" }
),
{ name: "DevTools" }
)
);Notas de TypeScript
- Usa siempre el patrón
create<State>()()con middleware. Sin la doble invocación, TypeScript no puede inferir los tipos del middleware. setestá tipado como(partial: Partial<State> | ((s: State) => Partial<State>), replace?: boolean) => void.getestá tipado como() => State, lo que da acceso completo al state actual.- Al usar
immer, el parámetro callback desetse convierte enDraft<State>, lo que permite mutaciones. partializeenpersistdebe devolver un subconjunto bien tipado: usaPick<State, keys>.
// Tipado explícito para stores complejos
import { StoreApi, UseBoundStore } from "zustand";
type MyStore = UseBoundStore<StoreApi<MyState>>;
const useStore: MyStore = create<MyState>()((set) => ({ ... }));Trampas comunes
- Olvidar la doble invocación
()()con middleware provoca errores crípticos de TypeScript. Usa siemprecreate<State>()( middleware(...) ). set({ someField: value })solo requiere un parcial, pero TypeScript no avisa si estableces un campo que no existe en el state. Ignora silenciosamente los campos extra.- Las funciones en el state (acciones) están tipadas pero no son seguras para serialización. Si usas
persist, usa siemprepartializepara excluir las acciones. - Al usar
StateCreatorcon cuatro parámetros genéricos para slices, un orden incorrecto provoca errores de tipos confusos. El orden es:StateCreator<FullStore, MutatorsIn, MutatorsOut, SliceType>. ReturnType<typeof store.getState>incluye funciones de acción en el tipo. Si solo necesitas la forma del state, define interfacesStateyActionspor separado.- Las aserciones
as consten valores literales dentro desetpueden ser necesarias para preservar tipos estrechos (p. ej.,status: "active" as const).
Alternativas
| Enfoque | Ventajas | Desventajas |
|---|---|---|
| Interfaz única (State + Actions) | Simple, un solo tipo que gestionar | Crece mucho en stores complejos |
| Tipos State y Actions separados | Separación clara, tipo State reutilizable | Más tipos que mantener |
| Tipos inferidos (sin genérico explícito) | Menos boilerplate | Garantías más débiles, fácil acabar con any |
| State validado con Zod | Validación en runtime + inferencia de tipos | Dependencia extra, más código |
Preguntas frecuentes
¿Cómo tipas un store básico de Zustand en TypeScript?
interface BearStore {
bears: number;
addBear: () => void;
}
const useBearStore = create<BearStore>((set) => ({
bears: 0,
addBear: () => set((s) => ({ bears: s.bears + 1 })),
}));- Pasa la interfaz completa (state + acciones) como genérico a
create<State>().
¿Por qué se requiere la doble invocación create<State>()() con middleware?
- El primer
()permite que TypeScript infiera correctamente los tipos del middleware a través de la cadena. - Sin él, aparecen errores de tipos crípticos porque TypeScript no puede propagar la información de tipos del middleware.
¿Debes definir state y acciones en una sola interfaz o por separado?
- Para stores pequeños, una interfaz combinada es más simple.
- Para stores grandes, interfaces
StateyActionsseparadas mejoran la claridad y la reutilización. - El tipo del store es siempre la intersección:
type Store = State & Actions.
¿Cómo infieres el tipo del store a partir de un store existente?
type State = ReturnType<typeof useWorkspaceStore.getState>;- Esto incluye tanto el state como las funciones de acción en el tipo inferido.
¿Qué es StateCreator y cuándo necesitas su forma genérica de cuatro parámetros?
StateCreatores el tipo central para patrones de slices y middleware.- La forma de cuatro parámetros
StateCreator<FullStore, MutatorsIn, MutatorsOut, SliceType>es necesaria cuando un slice necesita acceso entre slices o usa middleware. - Para slices simples,
StateCreator<SliceType>es suficiente.
¿Cómo se tipan los selectores en Zustand?
- El tipo de retorno del selector determina automáticamente el tipo del valor.
useStore((s) => s.count)devuelvenumbersicountestá tipado comonumber.- No hace falta anotar explícitamente el tipo de retorno.
Trampa común: ¿TypeScript avisa si haces set de un campo que no existe en el state?
- No.
set({ nonexistent: 123 })ignora silenciosamente los campos extra. - TypeScript no lo señala porque
setaceptaPartial<State>, que es permisivo con propiedades extra en algunas posiciones.
Trampa común: ¿Por qué podrías necesitar as const al llamar a set con valores literales?
- Sin
as const, TypeScript puede ampliar un literal de cadena como"active"astring. - Usa
as constpara preservar tipos estrechos:status: "active" as const. - Esto importa cuando tu tipo de state usa literales de unión como
"active" | "archived".
¿Cómo tipas partialize en el middleware persist?
partialize: (state): Pick<WorkspaceState, "activeProjectId"> => ({
activeProjectId: state.activeProjectId,
}),- Usa
Pick<State, keys>para un tipo de retorno explícito y seguro en tipos.
¿Cómo tipas un hook de selector personalizado que devuelve un valor derivado?
function useActiveProject(): Project | undefined {
return useWorkspaceStore((s) =>
s.projects.find((p) => p.id === s.activeProjectId)
);
}- Anota el tipo de retorno explícitamente para mayor claridad y seguridad.
¿Cuál es la diferencia entre los tipos StoreApi y UseBoundStore?
StoreApi<State>es el tipo de store vanilla (sin hook de React).UseBoundStore<StoreApi<State>>es el tipo de hook de React que devuelvecreate.- Usa
StoreApicuando trabajes concreateStorepara patrones vanilla o SSR de Next.js.
¿ReturnType<typeof store.getState> incluye acciones en el tipo?
- Sí. Incluye todo: tanto propiedades de state como funciones de acción.
- Si solo necesitas la forma del state, define interfaces
StateyActionspor separado y usaStatedirectamente.