Gestionar estado con Zustand y Claude Code
Diseña stores Zustand con Claude Code: selectors, persist parcial, acciones async, tests y prompts de revision.
Define primero el limite del estado
Zustand es una libreria ligera de gestion de estado para React. Gestionar estado no significa guardar todas las variables en un objeto global; significa decidir que valores deben compartirse, que acciones pueden cambiarlos y que valores deben quedarse dentro de un componente. Claude Code puede generar un store muy rapido, pero necesita un limite claro. Sin ese limite, texto temporal de formularios, datos de autenticacion, respuestas de API, toasts y carrito pueden terminar mezclados en el mismo store global.
Esta guia usa Claude Code como asistente de diseno de estado. Los casos practicos son filtros de administracion, carrito, estado de UI de autenticacion, modal/toast y actualizaciones optimistas. Incluye bloques copiables para store TypeScript, selectors, persist con partialize, accion asincrona, tests con Vitest y un prompt de revision.
La base tecnica viene de la introduccion oficial de Zustand, la referencia de persist middleware y la guia de useShallow. Para separar estado de servidor, revisa la guia de TanStack Query con Claude Code. Para estado mas atomico, compara con Jotai atoms.
Que debe entrar en Zustand
La regla practica es: usa Zustand cuando varios componentes lejanos necesitan el mismo valor, cuando la logica de actualizacion debe probarse en un lugar, o cuando la URL no expresa bien el estado de UI. No guardes respuestas completas del servidor solo porque puedes. Listas de productos, usuarios o busquedas necesitan cache, refetch, stale time y recuperacion de errores; eso suele pertenecer a una herramienta de server state.
| Caso | En Zustand | Fuera de Zustand | Instruccion para Claude Code |
|---|---|---|---|
| Filtros de admin | keyword, status, page, pageSize | respuesta API completa | Separar valores sincronizados con URL y UI local |
| Carrito | SKU, cantidad, precio visible | session de pago, stock real | Persistir solo campos de bajo riesgo |
| Auth UI | dialogo de login, indicador checking | token, email, direccion | No guardar PII ni secretos |
| Modal/toast | activeModal, cola corta de toasts | logs largos, auditoria | Guardar solo lo que renderiza la UI |
| Update optimista | requestId, snapshot anterior | verdad final del servidor | Definir rollback y reglas de carrera |
Masa encontro un fallo real en una pantalla administrativa: el objetivo era “usar Zustand” en vez de aclarar el estado. La primera version guardaba input de busqueda, query de URL, filas traidas, seleccion y toasts juntos. Al navegar, filtros antiguos seguian afectando la vista nueva. La solucion fue inventariar que valores podian permanecer globales.
flowchart LR
UI[React components] --> Selectors[Selectors and useShallow]
Selectors --> Store[Zustand store]
Store --> Actions[Actions]
Actions --> API[API and server state]
Store --> Persist[persist partialize]
Persist --> Storage[localStorage safe fields]
Store TypeScript
Este store mantiene un alcance claro: filtros de administracion, carrito, UI de autenticacion, modal y toasts. En un producto grande puedes dividir archivos, pero primero conviene pedir a Claude Code un slice pequeno y completo.
import { create, type StateCreator } from "zustand";
export type OrderStatus = "all" | "paid" | "refunded" | "failed";
export type AuthUiStatus = "anonymous" | "checking" | "signedIn";
export type ModalId = "invite-user" | "cart-drawer" | "delete-order" | null;
export interface AdminFilters {
keyword: string;
status: OrderStatus;
page: number;
pageSize: number;
}
export interface CartLine {
id: string;
name: string;
price: number;
quantity: number;
}
export interface AuthUi {
status: AuthUiStatus;
loginDialogOpen: boolean;
}
export interface Toast {
id: string;
kind: "success" | "error" | "info";
message: string;
}
export interface CommerceUiState {
filters: AdminFilters;
cart: CartLine[];
auth: AuthUi;
activeModal: ModalId;
toasts: Toast[];
setFilters: (patch: Partial<AdminFilters>) => void;
resetFilters: () => void;
addToCart: (line: Omit<CartLine, "quantity">) => void;
removeFromCart: (id: string) => void;
updateQuantity: (id: string, quantity: number) => void;
setAuthStatus: (status: AuthUiStatus) => void;
setLoginDialogOpen: (open: boolean) => void;
openModal: (modal: Exclude<ModalId, null>) => void;
closeModal: () => void;
pushToast: (toast: Omit<Toast, "id">) => void;
dismissToast: (id: string) => void;
checkoutTotal: () => number;
}
export const initialFilters: AdminFilters = {
keyword: "",
status: "all",
page: 1,
pageSize: 25,
};
const createToastId = () =>
globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2);
export const createCommerceUiSlice: StateCreator<CommerceUiState> = (set, get) => ({
filters: initialFilters,
cart: [],
auth: { status: "anonymous", loginDialogOpen: false },
activeModal: null,
toasts: [],
setFilters: (patch) =>
set((state) => ({
filters: {
...state.filters,
...patch,
page: patch.page ?? 1,
},
})),
resetFilters: () => set({ filters: initialFilters }),
addToCart: (line) =>
set((state) => {
const current = state.cart.find((item) => item.id === line.id);
if (!current) {
return { cart: [...state.cart, { ...line, quantity: 1 }] };
}
return {
cart: state.cart.map((item) =>
item.id === line.id ? { ...item, quantity: item.quantity + 1 } : item,
),
};
}),
removeFromCart: (id) =>
set((state) => ({
cart: state.cart.filter((item) => item.id !== id),
})),
updateQuantity: (id, quantity) =>
set((state) => ({
cart: state.cart
.map((item) =>
item.id === id ? { ...item, quantity: Math.max(0, quantity) } : item,
)
.filter((item) => item.quantity > 0),
})),
setAuthStatus: (status) =>
set((state) => ({
auth: { ...state.auth, status },
})),
setLoginDialogOpen: (open) =>
set((state) => ({
auth: { ...state.auth, loginDialogOpen: open },
})),
openModal: (modal) => set({ activeModal: modal }),
closeModal: () => set({ activeModal: null }),
pushToast: (toast) =>
set((state) => ({
toasts: [...state.toasts, { ...toast, id: createToastId() }],
})),
dismissToast: (id) =>
set((state) => ({
toasts: state.toasts.filter((toast) => toast.id !== id),
})),
checkoutTotal: () =>
get().cart.reduce((total, item) => total + item.price * item.quantity, 0),
});
export const useCommerceUiStore = create<CommerceUiState>()(createCommerceUiSlice);
El punto clave es que el store guarda estado visual de autenticacion, no secretos. Tokens, emails, direcciones, nombres legales y sesiones de pago no deben estar aqui. Cuando pidas persist, indica que PII significa informacion personal identificable y no debe guardarse.
Selectors para evitar renders innecesarios
Si un componente llama useCommerceUiStore() sin selector, se suscribe a todo el store. Entonces un toast puede volver a renderizar el badge del carrito, y un filtro puede afectar un modal no relacionado. Un selector toma solo la parte necesaria.
import { useShallow } from "zustand/react/shallow";
import {
useCommerceUiStore,
type CommerceUiState,
} from "./commerce-ui-store";
export const selectCartCount = (state: CommerceUiState) =>
state.cart.reduce((sum, item) => sum + item.quantity, 0);
export const selectCartTotal = (state: CommerceUiState) => state.checkoutTotal();
export function CartBadge() {
const count = useCommerceUiStore(selectCartCount);
return <button type="button">Cart ({count})</button>;
}
export function AdminFilterSummary() {
const { filters, setFilters, resetFilters } = useCommerceUiStore(
useShallow((state) => ({
filters: state.filters,
setFilters: state.setFilters,
resetFilters: state.resetFilters,
})),
);
return (
<form>
<input
value={filters.keyword}
placeholder="Search orders"
onChange={(event) => setFilters({ keyword: event.currentTarget.value })}
/>
<select
value={filters.status}
onChange={(event) =>
setFilters({ status: event.currentTarget.value as CommerceUiState["filters"]["status"] })
}
>
<option value="all">All</option>
<option value="paid">Paid</option>
<option value="refunded">Refunded</option>
<option value="failed">Failed</option>
</select>
<output>Page {filters.page}</output>
<button type="button" onClick={resetFilters}>
Reset
</button>
</form>
);
}
Pide a Claude Code que revise cada componente que importa el store. Salvo componentes de debug, ninguno debe suscribirse al store completo. Si el selector devuelve un objeto, usa useShallow o separa valores primitivos.
Persist parcial y seguro
persist es util para conservar datos tras recargar, pero tambien puede filtrar informacion sensible. Carrito y filtros pueden guardarse; modal, toast, requestId, tokens y PII no. Usa partialize para guardar solo lo necesario.
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import {
createCommerceUiSlice,
type CommerceUiState,
} from "./commerce-ui-store";
const noopStorage = {
getItem: () => null,
setItem: () => undefined,
removeItem: () => undefined,
};
type PersistedCommerceUiState = Pick<CommerceUiState, "filters" | "cart">;
export const usePersistedCommerceUiStore = create<CommerceUiState>()(
persist(createCommerceUiSlice, {
name: "commerce-ui-v1",
version: 1,
storage: createJSONStorage(() =>
typeof window === "undefined" ? noopStorage : window.localStorage,
),
partialize: (state): PersistedCommerceUiState => ({
filters: state.filters,
cart: state.cart,
}),
}),
);
En SSR, el servidor no puede leer localStorage. Si el HTML inicial muestra carrito 0 y el navegador restaura 3 items, puede haber mismatch de hydration. Muestra esos valores despues del mount o separalos del markup critico.
Acciones async y actualizacion optimista
Una actualizacion optimista cambia la pantalla antes de que el servidor confirme. Es util para follow, like o cantidades de carrito de bajo riesgo. El peligro es que una respuesta vieja llegue tarde y sobrescriba el estado nuevo. Usa requestId y snapshot anterior.
import { create } from "zustand";
interface Profile {
id: string;
name: string;
isFollowing: boolean;
}
interface ProfileState {
profiles: Record<string, Profile>;
followRequestIds: Record<string, string>;
setProfile: (profile: Profile) => void;
followOptimistically: (profileId: string) => Promise<void>;
}
const removeKey = <T,>(record: Record<string, T>, key: string) => {
const { [key]: _removed, ...rest } = record;
return rest;
};
async function updateFollowOnServer(profileId: string, follow: boolean) {
const response = await fetch(`/api/profiles/${profileId}/follow`, {
method: follow ? "PUT" : "DELETE",
});
if (!response.ok) {
throw new Error(`Follow update failed: ${response.status}`);
}
}
export const useProfileStore = create<ProfileState>((set, get) => ({
profiles: {},
followRequestIds: {},
setProfile: (profile) =>
set((state) => ({
profiles: { ...state.profiles, [profile.id]: profile },
})),
followOptimistically: async (profileId) => {
const before = get().profiles[profileId];
if (!before) {
throw new Error("Profile not found");
}
const requestId = `${profileId}-${Date.now()}`;
set((state) => ({
profiles: {
...state.profiles,
[profileId]: { ...before, isFollowing: true },
},
followRequestIds: {
...state.followRequestIds,
[profileId]: requestId,
},
}));
try {
await updateFollowOnServer(profileId, true);
} catch (error) {
set((state) => {
if (state.followRequestIds[profileId] !== requestId) return state;
return {
profiles: { ...state.profiles, [profileId]: before },
followRequestIds: removeKey(state.followRequestIds, profileId),
};
});
throw error;
}
set((state) => {
if (state.followRequestIds[profileId] !== requestId) return state;
return {
followRequestIds: removeKey(state.followRequestIds, profileId),
};
});
},
}));
Claude Code necesita reglas de producto: si se permiten clicks repetidos, que se revierte al fallar, si aparece toast y que solicitud gana cuando hay varias.
Tests con Vitest
Los actions de Zustand se pueden probar sin renderizar React. Restaura el store antes de cada test y comprueba tanto comportamiento como contrato de diseno.
import { beforeEach, describe, expect, it } from "vitest";
import { useCommerceUiStore } from "./commerce-ui-store";
const initialState = useCommerceUiStore.getInitialState();
beforeEach(() => {
useCommerceUiStore.setState(initialState, true);
});
describe("commerce ui store", () => {
it("increments cart quantity and calculates total", () => {
const store = useCommerceUiStore.getState();
store.addToCart({ id: "sku-1", name: "Workshop", price: 1200 });
store.addToCart({ id: "sku-1", name: "Workshop", price: 1200 });
expect(useCommerceUiStore.getState().cart[0]?.quantity).toBe(2);
expect(useCommerceUiStore.getState().checkoutTotal()).toBe(2400);
});
it("resets the page when a filter changes", () => {
useCommerceUiStore.getState().setFilters({ page: 4 });
useCommerceUiStore.getState().setFilters({ keyword: "refund" });
expect(useCommerceUiStore.getState().filters).toMatchObject({
keyword: "refund",
page: 1,
});
});
it("keeps auth UI and toasts explicit", () => {
useCommerceUiStore.getState().setAuthStatus("signedIn");
useCommerceUiStore.getState().pushToast({
kind: "success",
message: "Saved",
});
expect(useCommerceUiStore.getState().auth.status).toBe("signedIn");
expect(useCommerceUiStore.getState().toasts).toHaveLength(1);
});
});
Prompt de revision para Claude Code
Despues de implementar, pide una revision critica y limitada al estado. Esto encuentra problemas que un prompt generico no detecta.
Revisa solo el diseno de gestion de estado Zustand en este diff.
Comprueba:
1. Valores que deben ser local state no fueron movidos al global store.
2. Los componentes usan selectors, no el store completo.
3. persist partialize no guarda PII, tokens, modal, toasts ni requestId.
4. SSR/hydration mismatch esta considerado.
5. Async actions manejan respuestas viejas, clicks repetidos y rollback.
6. devtools e immer no se agregaron sin razon concreta.
7. Vitest cubre acciones principales y al menos un fallo.
Devuelve:
- Blocking issues
- Suggested patches
- Missing tests
- Questions before merge
Errores comunes
Los errores mas frecuentes son globalizar todo, olvidar selectors, persistir PII, ignorar SSR/hydration, escribir async actions sin control de carreras y agregar devtools o immer por costumbre. Cada uno aumenta deuda tecnica. El store debe reducir coordinacion, no esconder decisiones de producto.
Claude Code Lab ofrece formacion y consultoria para revisar stores existentes, separar Zustand de TanStack Query, definir reglas seguras de persist y crear prompts de revision reutilizables. Verificacion: al 2 de junio de 2026, los ejemplos se revisaron contra la documentacion oficial de Zustand para create, persist, useShallow y testing. En proyectos reales, escribir primero una tabla de campos seguros para persistir redujo trabajo posterior de privacidad e hydration.
PDF gratis: cheatsheet de Claude Code
Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.
Cuidamos tus datos y no enviamos spam.
Sobre el autor
Masa
Ingeniero enfocado en workflows prácticos con Claude Code.
Artículos relacionados
Workflow de Obsidian a CLAUDE.md con Claude Code
Convierte notas de trabajo de Obsidian en notas operativas de CLAUDE.md para no repetir contexto.
Claude Code Revenue CTA Routing: de artículos a PDF, Gumroad y consulta
Un flujo con Claude Code para dirigir lectores a PDF gratis, Gumroad o consulta según intención.
Reglas de handoff para equipos con Claude Code: evidencia, permisos, rollback e ingresos
Formato práctico para entregar trabajo de Claude Code con pruebas, permisos, rollback, PDF gratis, Gumroad y consulta.