//
Busca en todas las páginas de la documentación
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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),
}));// 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>
);
}set proporciona fusión parcial del state. get proporciona acceso de lectura al state actual - útil para valores calculados y acciones async.get()) se evalúan de forma perezosa cada vez que se llaman, no se cachean.(set, get, api) => state recibe tres argumentos: set para actualizaciones, get para lectura y api para suscripciones y la API completa del store.set y get, manteniéndolas autocontenidas.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);create<State>.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 }),
}));getTotal()) no se cachean. Cada llamada recalcula. Para cálculos costosos, deriva los valores en el selector o usa subscribeWithSelector.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.set varias veces en secuencia provocarán múltiples renders. Agrupa los cambios relacionados en una sola llamada a set.total) junto al state fuente (como items) crea un riesgo de sincronización. Prefiere calcular los valores derivados al vuelo.| 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 |
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:
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 con previousPoints.set(pointId, isSaved), Zustand no detectaría el cambio y no provocaría un re-renderizado.new Map(previousPoints) en lugar de solo set({ savedPoints: previousPoints }). Esto es necesario porque previousPoints es 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().savedPoints captura el state actual antes de la actualización optimista. Esta instantánea se usa para la reversión. Si lees get().savedPoints dentro del bloque catch, devolvería el state optimista, no el state previo a la actualización.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.get(): getTotal: () => get().items.reduce(...).useMemo en su lugar.set y get, manteniéndolas autocontenidas.const useStore = create<State>((set) => ({ count: 0 }));
export const increment = () =>
useStore.setState((s) => ({ count: s.count + 1 }));store.setState.actions en el store.(s) => s.actions ofrece una referencia estable que nunca provoca re-renderizados.(s) => s.token !== null.set provoca una actualización de state y un posible re-renderizado.set para evitar múltiples renders.// Mal: dos renders
set({ isLoading: false });
set({ data: result });
// Bien: un render
set({ isLoading: false, data: result });total podría desincronizarse de items.get() o en selectores.get() antes de la operación async.set (nueva referencia).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 }),
}));type StoreState = ReturnType<typeof useStore.getState>;Revisado por Chris St. John·Última actualización: 10 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥