Middleware de Zustand
Receta
Zustand incluye varios middleware integrados: persist para almacenamiento, devtools para Redux DevTools, immer para actualizaciones inmutables y subscribeWithSelector para suscripciones granulares. Apílalos anidándolos.
import { create } from "zustand";
import { devtools, persist } from "zustand/middleware";
import { immer } from "zustand/middleware/immer";
interface AppState {
count: number;
increment: () => void;
}
export const useStore = create<AppState>()(
devtools(
persist(
immer((set) => ({
count: 0,
increment: () =>
set((state) => {
state.count += 1; // Mutación directa gracias a immer
}),
})),
{ name: "app-store" }
),
{ name: "AppStore" }
)
);Ejemplo funcional
// stores/settings-store.ts
import { create } from "zustand";
import { devtools, persist, subscribeWithSelector } from "zustand/middleware";
import { immer } from "zustand/middleware/immer";
interface Settings {
theme: "light" | "dark" | "system";
fontSize: number;
language: string;
notifications: {
email: boolean;
push: boolean;
sms: boolean;
};
}
interface SettingsStore extends Settings {
setTheme: (theme: Settings["theme"]) => void;
setFontSize: (size: number) => void;
setLanguage: (lang: string) => void;
toggleNotification: (channel: keyof Settings["notifications"]) => void;
resetToDefaults: () => void;
}
const defaults: Settings = {
theme: "system",
fontSize: 16,
language: "en",
notifications: { email: true, push: true, sms: false },
};
export const useSettingsStore = create<SettingsStore>()(
devtools(
subscribeWithSelector(
persist(
immer((set) => ({
...defaults,
setTheme: (theme) =>
set((state) => {
state.theme = theme;
}),
setFontSize: (size) =>
set((state) => {
state.fontSize = Math.max(12, Math.min(24, size));
}),
setLanguage: (lang) =>
set((state) => {
state.language = lang;
}),
toggleNotification: (channel) =>
set((state) => {
state.notifications[channel] = !state.notifications[channel];
}),
resetToDefaults: () =>
set((state) => {
Object.assign(state, defaults);
}),
})),
{
name: "settings-storage",
partialize: (state) => ({
theme: state.theme,
fontSize: state.fontSize,
language: state.language,
notifications: state.notifications,
}),
}
)
),
{ name: "SettingsStore" }
)
);
// Suscribirse a cambios específicos del state
useSettingsStore.subscribe(
(state) => state.theme,
(theme) => {
document.documentElement.setAttribute("data-theme", theme);
}
);
useSettingsStore.subscribe(
(state) => state.fontSize,
(fontSize) => {
document.documentElement.style.fontSize = `${fontSize}px`;
}
);// components/settings-panel.tsx
"use client";
import { useSettingsStore } from "@/stores/settings-store";
export function SettingsPanel() {
const theme = useSettingsStore((s) => s.theme);
const fontSize = useSettingsStore((s) => s.fontSize);
const notifications = useSettingsStore((s) => s.notifications);
const setTheme = useSettingsStore((s) => s.setTheme);
const setFontSize = useSettingsStore((s) => s.setFontSize);
const toggleNotification = useSettingsStore((s) => s.toggleNotification);
const resetToDefaults = useSettingsStore((s) => s.resetToDefaults);
return (
<div>
<section>
<h3>Tema</h3>
<select value={theme} onChange={(e) => setTheme(e.target.value as any)}>
<option value="light">Claro</option>
<option value="dark">Oscuro</option>
<option value="system">Sistema</option>
</select>
</section>
<section>
<h3>Tamaño de fuente: {fontSize}px</h3>
<input
type="range"
min={12}
max={24}
value={fontSize}
onChange={(e) => setFontSize(Number(e.target.value))}
/>
</section>
<section>
<h3>Notificaciones</h3>
{(Object.keys(notifications) as Array<keyof typeof notifications>).map((ch) => (
<label key={ch}>
<input
type="checkbox"
checked={notifications[ch]}
onChange={() => toggleNotification(ch)}
/>
{ch}
</label>
))}
</section>
<button onClick={resetToDefaults}>Restablecer valores por defecto</button>
</div>
);
}Análisis profundo
Cómo funciona
- El middleware envuelve la función creadora del store, añadiendo comportamiento antes o después de los cambios de state.
- persist: Serializa el state en almacenamiento (localStorage por defecto) y lo rehidrata al montar.
- devtools: Se conecta a la extensión de navegador Redux DevTools para depuración con viaje en el tiempo.
- immer: Envuelve
setpara que puedas escribir actualizaciones con estilo mutable que producen state inmutable. - subscribeWithSelector: Extiende
.subscribe()para aceptar un selector y una función de igualdad, permitiendo efectos secundarios granulares. - El middleware se aplica de dentro hacia fuera. El middleware más interno se ejecuta primero.
Variaciones
Orden del middleware (recomendado):
// devtools más externo, luego subscribeWithSelector, luego persist, luego immer más interno
create<Store>()(
devtools(
subscribeWithSelector(
persist(
immer((set) => ({ ... })),
{ name: "store" }
)
)
)
);Middleware personalizado:
import { StateCreator, StoreMutatorIdentifier } from "zustand";
type Logger = <
T,
Mps extends [StoreMutatorIdentifier, unknown][] = [],
Mcs extends [StoreMutatorIdentifier, unknown][] = []
>(
f: StateCreator<T, Mps, Mcs>,
name?: string
) => StateCreator<T, Mps, Mcs>;
const logger: Logger = (f, name) => (set, get, store) => {
const loggedSet: typeof set = (...args) => {
set(...(args as any));
console.log(`[${name || "store"}]`, get());
};
return f(loggedSet, get, store);
};
// Uso
const useStore = create(logger((set) => ({ count: 0 }), "CountStore"));Notas de TypeScript
- Al apilar middleware, usa
create<State>()()(doble invocación) para ayudar a TypeScript a inferir los tipos combinados. - Cada middleware añade sus propios mutadores de tipo. El orden afecta la inferencia de tipos.
partializeen el middleware persist debe devolver un subconjunto del state con tipos seguros.
// El patrón ()() es necesario para la inferencia de tipos del middleware
export const useStore = create<State>()(
devtools(persist(immer((set) => ({ ... })), { name: "store" }))
);Trampas comunes
- El orden del middleware importa tanto para el comportamiento como para TypeScript. Pon siempre
devtoolscomo el más externo eimmercomo el más interno. - Usar
immersin importar el middleware desdezustand/middleware/immerno funcionará. Es un paquete separado. persistserializa el state conJSON.stringifypor defecto. Las funciones, Dates, Maps y Sets se perderán. Usapartializepara excluir valores no serializables.devtoolsañade sobrecarga. Desactívalo en producción:devtools(fn, { enabled: process.env.NODE_ENV === "development" }).subscribeWithSelectordebe envolverpersist(no al revés) si quieres suscribirte a cambios del state persistido.- Apilar demasiado middleware dificulta la depuración. Añade solo el middleware que realmente necesites.
Alternativas
| Enfoque | Ventajas | Desventajas |
|---|---|---|
| Middleware integrados de Zustand | Soporte oficial, bien tipados | Conjunto fijo de middleware |
| Middleware personalizado | Adaptado a tus necesidades | Debes gestionar los tipos manualmente |
| Sin middleware (store simple) | Lo más simple y rápido | Sin persistencia, devtools ni immer |
| Middleware de Redux Toolkit | Ecosistema maduro | Biblioteca distinta, más boilerplate |
Preguntas frecuentes
¿Qué middleware integrados proporciona Zustand?
persist-- guarda y restaura el state en almacenamiento (localStorage por defecto).devtools-- se conecta a la extensión de navegador Redux DevTools.immer-- permite actualizaciones de state con estilo mutable que producen state inmutable.subscribeWithSelector-- permite suscribirse a fragmentos específicos del state fuera de React.
¿Cuál es el orden recomendado para apilar middleware?
devtoolscomo el más externo, luegosubscribeWithSelector, luegopersistyimmercomo el más interno.- El middleware se aplica de dentro hacia fuera: el más interno se ejecuta primero.
¿Por qué se necesita la doble invocación create<State>()() con middleware?
- El primer
()ayuda a TypeScript a inferir correctamente los tipos combinados del middleware. - Sin él, TypeScript no puede propagar la información de tipos a través de la cadena de middleware.
¿En qué se diferencia subscribeWithSelector del .subscribe() por defecto?
- Extiende
.subscribe()para aceptar un selector y una función de igualdad. - Puedes suscribirte a un campo específico y ejecutar el callback solo cuando ese campo cambie.
useStore.subscribe(
(state) => state.theme,
(theme) => document.documentElement.setAttribute("data-theme", theme)
);¿Cómo escribes middleware personalizado?
- El middleware personalizado envuelve la función creadora del store e intercepta
set,getostore. - Devuelve un nuevo creador de store que añade comportamiento antes o después de los cambios de state.
Trampa: ¿Qué ocurre si pones immer fuera de persist en lugar de dentro?
- Immer debe ser el middleware más interno. Colocarlo fuera de
persistodevtoolsno funcionará correctamente. - El proxy draft mutable no funcionará como se espera si otro middleware lo envuelve.
Trampa: ¿persist serializa funciones en el state?
- Sí,
JSON.stringifyserializa las funciones comonull. - Usa
partializepara excluir acciones y valores no serializables. - Las funciones perdidas durante la serialización se restauran desde los valores por defecto al rehidratar.
¿Cómo desactivas el middleware devtools en producción?
devtools(storeCreator, {
name: "AppStore",
enabled: process.env.NODE_ENV === "development",
});¿subscribeWithSelector debe envolver persist o al revés?
subscribeWithSelectordebe envolverpersist(no al revés) si quieres suscribirte a cambios del state persistido.
¿Cómo afecta cada middleware a los tipos de TypeScript?
- Cada middleware añade sus propios mutadores de tipo a la cadena de tipos del store.
- El orden del middleware afecta la inferencia de tipos, por eso importa mantener un orden consistente.
- Usa
create<State>()()para que TypeScript infiera correctamente los tipos combinados.
¿Cómo funciona partialize en el middleware persist?
persist(storeCreator, {
name: "settings-storage",
partialize: (state) => ({
theme: state.theme,
fontSize: state.fontSize,
}),
});- Solo las propiedades devueltas se guardan en almacenamiento. Todo lo demás queda excluido.