Patrones de diseño de store
Receta
Diseña stores con acciones colocalizadas, valores derivados (calculados) y una separación clara entre state y comportamiento. Mantén los stores enfocados en un único dominio.
import { create } from "zustand";
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
interface CartStore {
// State
items: CartItem[];
// Acciones
addItem: (item: Omit<CartItem, "quantity">) => void;
removeItem: (id: string) => void;
updateQuantity: (id: string, quantity: number) => void;
clearCart: () => void;
// Calculado (como getters)
getTotal: () => number;
getItemCount: () => number;
}
export const useCartStore = create<CartStore>((set, get) => ({
items: [],
addItem: (item) =>
set((state) => {
const existing = state.items.find((i) => i.id === item.id);
if (existing) {
return {
items: state.items.map((i) =>
i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
),
};
}
return { items: [...state.items, { ...item, quantity: 1 }] };
}),
removeItem: (id) =>
set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
updateQuantity: (id, quantity) =>
set((state) => ({
items: state.items.map((i) => (i.id === id ? { ...i, quantity } : i)),
})),
clearCart: () => set({ items: [] }),
getTotal: () => get().items.reduce((sum, i) => sum + i.price * i.quantity, 0),
getItemCount: () => get().items.reduce((sum, i) => sum + i.quantity, 0),
}));Ejemplo funcional
// stores/auth-store.ts
import { create } from "zustand";
interface User {
id: string;
name: string;
email: string;
role: "admin" | "user";
}
interface AuthStore {
// State
user: User | null;
token: string | null;
isHydrated: boolean;
// Acciones
login: (email: string, password: string) => Promise<void>;
logout: () => void;
updateProfile: (updates: Partial<User>) => void;
// Calculado
isAuthenticated: () => boolean;
isAdmin: () => boolean;
}
export const useAuthStore = create<AuthStore>((set, get) => ({
user: null,
token: null,
isHydrated: false,
login: async (email, password) => {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!res.ok) throw new Error("Error al iniciar sesión");
const { user, token } = await res.json();
set({ user, token });
},
logout: () => set({ user: null, token: null }),
updateProfile: (updates) =>
set((state) => ({
user: state.user ? { ...state.user, ...updates } : null,
})),
isAuthenticated: () => get().token !== null,
isAdmin: () => get().user?.role === "admin",
}));// components/auth-guard.tsx
"use client";
import { useAuthStore } from "@/stores/auth-store";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
export function AuthGuard({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated());
const router = useRouter();
useEffect(() => {
if (!isAuthenticated) {
router.push("/login");
}
}, [isAuthenticated, router]);
if (!isAuthenticated) return null;
return <>{children}</>;
}// components/user-menu.tsx
"use client";
import { useAuthStore } from "@/stores/auth-store";
export function UserMenu() {
const user = useAuthStore((s) => s.user);
const logout = useAuthStore((s) => s.logout);
const isAdmin = useAuthStore((s) => s.isAdmin());
if (!user) return null;
return (
<div>
<span>{user.name}</span>
{isAdmin && <a href="/admin">Panel de administración</a>}
<button onClick={logout}>Cerrar sesión</button>
</div>
);
}Análisis profundo
Cómo funciona
- Los stores de Zustand son objetos planos. Las acciones son simplemente funciones almacenadas junto a los valores del state.
setproporciona fusión parcial del state.getproporciona acceso de lectura al state actual — útil para valores calculados y acciones async.- Los valores calculados como funciones getter (usando
get()) se evalúan de forma perezosa cada vez que se llaman, no se cachean. - La función creadora del store
(set, get, api) => staterecibe tres argumentos:setpara actualizaciones,getpara lectura yapipara suscripciones y la API completa del store. - Las acciones definidas dentro del store tienen cierre sobre
setyget, manteniéndolas autocontenidas.
Variaciones
Separar acciones del state:
// Algunos equipos prefieren acciones fuera del store
const useStore = create<State>((set) => ({
count: 0,
}));
// Acciones como funciones independientes
export const increment = () => useStore.setState((s) => ({ count: s.count + 1 }));
export const reset = () => useStore.setState({ count: 0 });Valores calculados con subscribe (cacheados):
import { create } from "zustand";
import { subscribeWithSelector } from "zustand/middleware";
const useStore = create(
subscribeWithSelector<{ items: Item[]; total: number }>((set) => ({
items: [],
total: 0,
}))
);
// Actualiza total cada vez que cambian los items
useStore.subscribe(
(s) => s.items,
(items) => useStore.setState({ total: items.reduce((s, i) => s + i.price, 0) })
);Patrón de acciones agrupadas:
interface Store {
count: number;
actions: {
increment: () => void;
decrement: () => void;
reset: () => void;
};
}
const useStore = create<Store>((set) => ({
count: 0,
actions: {
increment: () => set((s) => ({ count: s.count + 1 })),
decrement: () => set((s) => ({ count: s.count - 1 })),
reset: () => set({ count: 0 }),
},
}));
// Selecciona las acciones una vez (referencia estable, nunca provoca re-renderizado)
const { increment } = useStore((s) => s.actions);Notas de TypeScript
- Define la interfaz del state de forma explícita y pásala a
create<State>. - Separa los tipos de state de los tipos de acciones si la interfaz crece mucho.
- Usa
ReturnType<typeof useStore.getState>para inferir el tipo del store de forma dinámica.
interface State {
count: number;
}
interface Actions {
increment: () => void;
reset: () => void;
}
type Store = State & Actions;
const useStore = create<Store>((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
reset: () => set({ count: 0 }),
}));Errores comunes
- Las funciones getter (
getTotal()) no se cachean. Cada llamada recalcula. Para cálculos costosos, deriva los valores en el selector o usasubscribeWithSelector. - Llamar a
isAuthenticated()como selector(s) => s.isAuthenticated()se reevalúa en cada cambio de state, ya que la función siempre devuelve un valor nuevo. Selecciona el state subyacente en su lugar:(s) => s.token !== null. - Las actualizaciones de state profundamente anidadas requieren spreading manual en cada nivel. Considera el middleware immer para state anidado complejo.
- Las acciones que llaman a
setvarias veces en secuencia provocarán múltiples renders. Agrupa los cambios relacionados en una sola llamada aset. - Almacenar state derivado (como
total) junto al state fuente (comoitems) crea un riesgo de sincronización. Prefiere calcular los valores derivados al vuelo.
Alternativas
| Enfoque | Ventajas | Desventajas |
|---|---|---|
| Acciones colocalizadas | Autocontenidas, fáciles de descubrir | Archivo de store grande para dominios complejos |
| Acciones externas | Testeables, desacopladas | Más difícil encontrar todas las acciones de un store |
| Objeto de acciones agrupadas | Referencia estable, sin re-renderizados | Anidamiento extra, menos convencional |
| Stores separados por dominio | Enfocados, independientes | Se necesita coordinación entre stores |
Ejemplo del mundo real
De una aplicación SaaS en producción con Next.js 15 / React 19 (SystemsArchitect.io).
// Ejemplo en producción: actualización optimista con reversión en un store de Zustand
// Archivo: src/stores/project-store.ts (acción savePoint)
import { create } from 'zustand';
interface ProjectStore {
savedPoints: Map<string, boolean>;
savePoint: (pointId: string, isSaved: boolean) => Promise<void>;
}
export const useProjectStore = create<ProjectStore>((set, get) => ({
savedPoints: new Map(),
savePoint: async (pointId: string, isSaved: boolean) => {
const previousPoints = get().savedPoints;
// 1. Actualiza la UI de forma optimista de inmediato
const optimisticPoints = new Map(previousPoints);
optimisticPoints.set(pointId, isSaved);
set({ savedPoints: optimisticPoints });
try {
// 2. Envía el cambio a la API
const res = await fetch('/api/save-point', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pointId, isSaved }),
});
if (!res.ok) throw new Error('Error al guardar');
} catch (error) {
// 3. Revierte al state anterior en caso de fallo
set({ savedPoints: new Map(previousPoints) });
console.error('Error al guardar, revertido:', error);
}
},
}));Lo que esto demuestra en producción:
- El patrón de actualización optimista muestra el cambio en la UI al instante (paso 1), envía la solicitud a la API en segundo plano (paso 2) y revierte si la solicitud falla (paso 3). Los usuarios ven retroalimentación inmediata sin esperar el viaje de ida y vuelta de la red.
new Map(previousPoints)crea una nueva referencia de Map en cada paso. Zustand usa igualdad por referencia para detectar cambios. Si mutaras el Map existente conpreviousPoints.set(pointId, isSaved), Zustand no detectaría el cambio y no provocaría un re-renderizado.- La reversión en el bloque catch también crea un
new Map(previousPoints)en lugar de soloset({ savedPoints: previousPoints }). Esto es necesario porquepreviousPointses la misma referencia que estaba en el store antes de la actualización optimista. Si algún componente capturó esa referencia, restaurarla sin crear un nuevo Map podría no provocar re-renderizados. get().savedPointscaptura el state actual antes de la actualización optimista. Esta instantánea se usa para la reversión. Si leesget().savedPointsdentro del bloque catch, devolvería el state optimista, no el state previo a la actualización.- Este patrón funciona bien para acciones de tipo toggle (guardar/desguardar, like/unlike) donde el cambio en la UI es binario y fácil de revertir.
FAQs
¿Cuáles son los tres argumentos que recibe la función creadora del store?
set-- para actualizar el state (fusión parcial o función).get-- para leer el state actual (útil para valores calculados y acciones async).api-- la API completa del store para suscripciones y uso avanzado.
¿Cómo funcionan los valores calculados (funciones getter) en Zustand?
- Defínelos como funciones usando
get():getTotal: () => get().items.reduce(...). - Se evalúan de forma perezosa cada vez que se llaman, no se cachean.
- Para cálculos costosos, usa selectores con
useMemoen su lugar.
¿Qué es el patrón de "acciones colocalizadas"?
- Las acciones se definen dentro del store junto a los valores del state.
- Tienen cierre sobre
setyget, manteniéndolas autocontenidas. - Este es el patrón de Zustand más común y recomendado.
¿Qué es el patrón de "acciones separadas" y cuándo lo usarías?
const useStore = create<State>((set) => ({ count: 0 }));
export const increment = () =>
useStore.setState((s) => ({ count: s.count + 1 }));- Las acciones son funciones independientes que usan
store.setState. - Útil cuando quieres que las acciones sean testeables de forma independiente del store.
¿Qué es el patrón de "acciones agrupadas"?
- Anida todas las acciones dentro de un objeto
actionsen el store. - Seleccionar
(s) => s.actionsofrece una referencia estable que nunca provoca re-renderizados. - Compromiso: anidamiento extra, menos convencional.
Trampa: ¿Por qué llamar a (s) => s.isAuthenticated() como selector provoca re-renderizado en cada cambio de state?
- La función se llama durante el render y devuelve un valor nuevo cada vez.
- Zustand lo interpreta como un valor cambiado porque el selector llama a una función.
- En su lugar, selecciona el state subyacente:
(s) => s.token !== null.
Trampa: ¿Qué ocurre si llamas a set varias veces en secuencia dentro de una acción?
- Cada llamada a
setprovoca una actualización de state y un posible re-renderizado. - Agrupa los cambios relacionados en una sola llamada a
setpara evitar múltiples renders.
// Mal: dos renders
set({ isLoading: false });
set({ data: result });
// Bien: un render
set({ isLoading: false, data: result });¿Por qué es arriesgado almacenar state derivado (como total) junto al state fuente (como items)?
- Crea un riesgo de sincronización --
totalpodría desincronizarse deitems. - Prefiere calcular los valores derivados al vuelo usando
get()o en selectores.
¿Cómo funciona el patrón de actualización optimista en el ejemplo de producción?
- Captura el state actual con
get()antes de la operación async. - Aplica el cambio optimista de inmediato con
set(nueva referencia). - Si la llamada a la API falla, revierte estableciendo una nueva copia de la instantánea.
¿Cómo tipas un store con interfaces State y Actions separadas en TypeScript?
interface State { count: number; }
interface Actions { increment: () => void; reset: () => void; }
type Store = State & Actions;
const useStore = create<Store>((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
reset: () => set({ count: 0 }),
}));¿Cómo infieres el tipo del store de forma dinámica en TypeScript?
type StoreState = ReturnType<typeof useStore.getState>;- Esto infiere tanto el state como las acciones a partir de la implementación real del store.