Skill de Gestión de State con Zustand - Una receta de skill de Claude Code para construir state global escalable con Zustand
Estas recetas de skills están diseñadas para Claude Code, pero también funcionan con otros agentes de codificación con IA que admitan archivos de skill/instrucciones.
Receta
El contenido completo de SKILL.md que puedes copiar en .claude/skills/zustand-state-management/SKILL.md:
---
name: zustand-state-management
description: "Construcción de state global escalable y performante con Zustand y TypeScript. Úsalo cuando te pidan: ayuda con zustand, state global, patrón de store, gestión de state, selectores de zustand, middleware de zustand, persist de zustand, SSR con zustand."
allowed-tools: "Read, Write, Edit, Glob, Grep, Bash(npm:*), Bash(npx:*), Agent"
---
# Gestión de State con Zustand
Eres un experto en Zustand. Ayuda a los desarrolladores a construir gestión de state escalable, performante y bien tipada.
## Reglas de Arquitectura de Stores
1. **Un store por dominio** - store de auth, store de carrito, store de UI. Nunca un store gigante.
2. **State plano** - Evita objetos profundamente anidados. Normaliza los datos como en una base de datos.
3. **Coloca las acciones junto al state** - Mantén las acciones en el mismo store que el state que modifican.
4. **Usa selectores** - Nunca te suscribas al store completo. Selecciona siempre los datos mínimos necesarios.
5. **Deriva, no almacenes** - Los valores calculados deben derivarse en selectores, no almacenarse.
## Patrones Principales
### Store Básico con TypeScript
```tsx
import \{ create \} from "zustand";
interface CounterState \{
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
\}
const useCounterStore = create<CounterState>((set) => (\{
count: 0,
increment: () => set((state) => (\{ count: state.count + 1 \})),
decrement: () => set((state) => (\{ count: state.count - 1 \})),
reset: () => set(\{ count: 0 \}),
\}));Patrones de Selectores (Evitar Re-renderizados)
// MAL - se suscribe al store completo, re-renderiza con CUALQUIER cambio
function Component() \{
const store = useCounterStore();
return <span>\{store.count\}</span>;
\}
// BIEN - se suscribe solo a count
function Component() \{
const count = useCounterStore((state) => state.count);
return <span>\{count\}</span>;
\}
// BIEN - múltiples valores con comparación superficial
import \{ useShallow \} from "zustand/react/shallow";
function Component() \{
const \{ count, increment \} = useCounterStore(
useShallow((state) => (\{ count: state.count, increment: state.increment \}))
);
return <button onClick=\{increment\}>\{count\}</button>;
\}
// BIEN - referencia estable de acción (las acciones nunca cambian)
function Component() \{
const increment = useCounterStore((state) => state.increment);
// la referencia de increment es estable, sin re-renderizados por otros cambios de state
\}Patrón de Slices (Stores Modulares)
interface AuthSlice \{
user: User | null;
login: (credentials: Credentials) => Promise<void>;
logout: () => void;
\}
interface CartSlice \{
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
total: () => number;
\}
const createAuthSlice: StateCreator<AuthSlice & CartSlice, [], [], AuthSlice> = (
set
) => (\{
user: null,
login: async (credentials) => \{
const user = await api.login(credentials);
set(\{ user \});
\},
logout: () => set(\{ user: null \}),
\});
const createCartSlice: StateCreator<AuthSlice & CartSlice, [], [], CartSlice> = (
set,
get
) => (\{
items: [],
addItem: (item) => set((state) => (\{ items: [...state.items, item] \})),
removeItem: (id) =>
set((state) => (\{ items: state.items.filter((i) => i.id !== id) \})),
total: () => get().items.reduce((sum, item) => sum + item.price, 0),
\});
const useStore = create<AuthSlice & CartSlice>()((...args) => (\{
...createAuthSlice(...args),
...createCartSlice(...args),
\}));Middleware
Persist
import \{ persist, createJSONStorage \} from "zustand/middleware";
const useSettingsStore = create<SettingsState>()(
persist(
(set) => (\{
theme: "light" as const,
language: "en",
setTheme: (theme: "light" | "dark") => set(\{ theme \}),
setLanguage: (language: string) => set(\{ language \}),
\}),
\{
name: "settings-storage",
storage: createJSONStorage(() => localStorage),
partialize: (state) => (\{
theme: state.theme,
language: state.language,
\}), // Solo persistir estos campos, no las acciones
\}
)
);Devtools
import \{ devtools \} from "zustand/middleware";
const useStore = create<StoreState>()(
devtools(
(set) => (\{
count: 0,
increment: () =>
set(
(state) => (\{ count: state.count + 1 \}),
false,
"increment" // nombre de la acción en devtools
),
\}),
\{ name: "MyStore" \}
)
);Immer (para actualizaciones complejas)
import \{ immer \} from "zustand/middleware/immer";
const useTodoStore = create<TodoState>()(
immer((set) => (\{
todos: [],
toggleTodo: (id: string) =>
set((state) => \{
const todo = state.todos.find((t) => t.id === id);
if (todo) todo.completed = !todo.completed; // la mutación directa es segura con immer
\}),
addTodo: (text: string) =>
set((state) => \{
state.todos.push(\{ id: crypto.randomUUID(), text, completed: false \});
\}),
\}))
);Combinación de Middleware
const useStore = create<StoreState>()(
devtools(
persist(
immer((set) => (\{
// definición del store
\})),
\{ name: "my-store" \}
),
\{ name: "MyStore" \}
)
);
// Orden: immer (más interno) -> persist -> devtools (más externo)Hidratación SSR (Next.js)
// stores/counter-store.ts
import \{ create \} from "zustand";
interface CounterState \{
count: number;
increment: () => void;
\}
export const useCounterStore = create<CounterState>((set) => (\{
count: 0,
increment: () => set((state) => (\{ count: state.count + 1 \})),
\}));
// Para evitar desincronización de hidratación con el middleware persist:
// components/HydrationGuard.tsx
"use client";
import \{ useEffect, useState \} from "react";
export function HydrationGuard(\{ children \}: \{ children: React.ReactNode \}) \{
const [hydrated, setHydrated] = useState(false);
useEffect(() => setHydrated(true), []);
return hydrated ? <>\{children\}</> : null; // o un skeleton
\}Acciones Asíncronas
interface ProductState \{
products: Product[];
loading: boolean;
error: string | null;
fetchProducts: () => Promise<void>;
\}
const useProductStore = create<ProductState>((set) => (\{
products: [],
loading: false,
error: null,
fetchProducts: async () => \{
set(\{ loading: true, error: null \});
try \{
const products = await api.getProducts();
set(\{ products, loading: false \});
\} catch (error) \{
set(\{ error: (error as Error).message, loading: false \});
\}
\},
\}));Pruebas
import \{ renderHook, act \} from "@testing-library/react";
import \{ useCounterStore \} from "./counter-store";
// Restablecer el store entre pruebas
beforeEach(() => \{
useCounterStore.setState(\{ count: 0 \});
\});
test("increment aumenta count", () => \{
const \{ result \} = renderHook(() =>
useCounterStore((state) => (\{
count: state.count,
increment: state.increment,
\}))
);
act(() => result.current.increment());
expect(result.current.count).toBe(1);
\});
// Probar el store directamente sin React
test("el store funciona sin componentes", () => \{
const \{ getState \} = useCounterStore;
expect(getState().count).toBe(0);
getState().increment();
expect(getState().count).toBe(1);
\});Anti-Patrones
- Suscribirse al store completo - Provoca re-renderizados en cada cambio de state
- Almacenar datos derivados - Calcúlalos en selectores o componentes en su lugar
- Un mega-store - Divídelo en stores específicos por dominio
- Almacenar state del servidor - Usa SWR o TanStack Query para datos del servidor; Zustand es para state del cliente
- No usar useShallow para selectores de objeto - Los selectores de objeto crean nuevas referencias en cada renderizado
- Meter en el store lo que no es state - Las refs de React, elementos DOM y suscripciones no pertenecen a Zustand
## Ejemplo funcional
### Ejemplo 1: El usuario pide "Configura un store de Zustand para mi carrito de compras"
**Prompt del usuario:** "Necesito un store de carrito con añadir, eliminar, actualizar cantidad y cálculo del total."
**La respuesta guiada por el skill produciría:**
```tsx
// stores/cart-store.ts
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import { immer } from "zustand/middleware/immer";
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
interface CartState {
items: CartItem[];
addItem: (item: Omit<CartItem, "quantity">) => void;
removeItem: (id: string) => void;
updateQuantity: (id: string, quantity: number) => void;
clearCart: () => void;
}
export const useCartStore = create<CartState>()(
persist(
immer((set) => ({
items: [],
addItem: (item) =>
set((state) => {
const existing = state.items.find((i) => i.id === item.id);
if (existing) {
existing.quantity += 1;
} else {
state.items.push({ ...item, quantity: 1 });
}
}),
removeItem: (id) =>
set((state) => {
state.items = state.items.filter((i) => i.id !== id);
}),
updateQuantity: (id, quantity) =>
set((state) => {
const item = state.items.find((i) => i.id === id);
if (item) item.quantity = Math.max(0, quantity);
}),
clearCart: () => set({ items: [] }),
})),
{
name: "cart-storage",
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({ items: state.items }),
}
)
);
// Selectores derivados (definidos fuera del store)
export const selectCartTotal = (state: CartState) =>
state.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
export const selectCartCount = (state: CartState) =>
state.items.reduce((sum, item) => sum + item.quantity, 0);
Ejemplo 2: El usuario pregunta "¿Por qué mi componente se re-renderiza cuando cambia un state no relacionado?"
Respuesta guiada por el skill: Diagnosticar el patrón de selector: probablemente se está suscribiendo al store completo o se usa un selector de objeto sin useShallow.
Profundización
Cómo Funciona el Skill
Este skill proporciona:
- Reglas de arquitectura - Estructura del store, convenciones de nomenclatura y organización
- Patrones de rendimiento - Optimización de selectores para evitar re-renderizados innecesarios
- Recetas de middleware - Persist, devtools, immer y cómo combinarlos
- Detección de anti-patrones - Errores comunes de Zustand y cómo corregirlos
Personalización
- Añade las convenciones de store específicas de tu proyecto
- Incluye middleware personalizado (p. ej., logging, seguimiento de analíticas)
- Define convenciones de nomenclatura para stores y selectores
- Añade patrones específicos de SSR para tu framework
Cómo Instalar
mkdir -p .claude/skills/zustand-state-management
# Pega el contenido de la Receta en .claude/skills/zustand-state-management/SKILL.mdErrores comunes
- Las acciones son referencias estables - No necesitas envolverlas en useCallback ni incluirlas en arrays de dependencias.
- set() fusiona de forma superficial -
set({ count: 1 })fusiona con el state existente, no lo reemplaza. Usaset(state => state, true)para un reemplazo completo. - La hidratación de persist es asíncrona - El state persistido se carga de forma asíncrona, lo que puede provocar un flash de valores predeterminados. Usa el callback
onRehydrateStorageo un guard de hidratación. - useShallow viene de zustand/react/shallow - No del import principal de zustand. Este es un error de importación habitual.
- El orden del middleware importa - Immer debe ser el más interno, devtools el más externo.
Alternativas
| Enfoque | Cuándo usarlo |
|---|---|
| Jotai | Modelo de state atómico, enfoque bottom-up |
| Valtio | Basado en proxy, API mutable |
| Redux Toolkit | Equipos grandes, necesidades complejas de middleware, depuración con time-travel |
| React Context | State simple compartido entre pocos componentes |
| Signals (Preact) | Reactividad granular sin selectores |
FAQs
¿Cuáles son las cinco reglas de arquitectura de stores para Zustand?
- Un store por dominio (auth, carrito, UI — nunca un store gigante)
- State plano (evita objetos profundamente anidados, normaliza los datos)
- Coloca las acciones junto al state en el mismo store
- Usa selectores (nunca te suscribas al store completo)
- Deriva los valores calculados en selectores, no los almacenes
¿Por qué nunca debes suscribirte al store completo de Zustand?
// Mal: re-renderiza con CUALQUIER cambio de state
const store = useCounterStore();
// Bien: se suscribe solo a count
const count = useCounterStore((state) => state.count);- Suscribirse al store completo hace que el componente se re-renderice en cada cambio de state
- Selecciona siempre los datos mínimos que el componente necesita
¿Cuándo y cómo usas useShallow con Zustand?
import { useShallow } from "zustand/react/shallow";
const { count, increment } = useCounterStore(
useShallow((state) => ({ count: state.count, increment: state.increment }))
);- Usa
useShallowcuando tu selector devuelve un objeto (múltiples valores) - Sin él, la referencia del objeto cambia en cada renderizado, provocando re-renderizados innecesarios
Trampa: ¿Cuál es la ruta de importación correcta para useShallow?
- La importación correcta es
from "zustand/react/shallow" - NO viene del import principal de
zustand - Este es un error habitual que provoca errores de importación
¿Cómo tipas un store de Zustand con TypeScript?
import { create } from "zustand";
interface CounterState {
count: number;
increment: () => void;
decrement: () => void;
}
const useCounterStore = create<CounterState>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
}));- Define una interfaz para la forma completa del state, incluidas las acciones
- Pasa la interfaz como genérico a
create<State>()
¿Cuál es el orden correcto del middleware al combinar immer, persist y devtools?
const useStore = create<StoreState>()(
devtools(
persist(
immer((set) => ({ /* ... */ })),
{ name: "my-store" }
),
{ name: "MyStore" }
)
);- Immer debe ser el más interno, devtools el más externo
- Orden: immer -> persist -> devtools
¿Cómo funciona el middleware persist y para qué sirve partialize?
persistguarda el state del store en localStorage (u otro almacenamiento)partializete permite seleccionar qué campos persistir (excluye acciones y datos derivados)- Usa
createJSONStorage(() => localStorage)como adaptador de almacenamiento
Trampa: ¿set() fusiona el state de forma superficial o profunda?
set({ count: 1 })fusiona de forma superficial con el state existente (no reemplaza el store completo)- Para un reemplazo completo del state, usa
set(newState, true)con la bandera replace - Los objetos anidados NO se fusionan en profundidad — debes propagar manualmente las actualizaciones anidadas
¿Cómo manejas las desincronizaciones de hidratación SSR con stores de Zustand persistidos?
- El state persistido se carga de forma asíncrona, lo que puede provocar un flash de valores predeterminados
- Usa un componente
HydrationGuardque espere auseEffectantes de renderizar - Alternativamente, usa el callback
onRehydrateStoragedel middleware persist
¿Qué es el patrón de slices y cuándo debes usarlo?
- El patrón de slices te permite componer un store a partir de múltiples creadores de slice independientes
- Cada slice define su propio state y acciones con un tipo
StateCreator - Combina los slices en la llamada final a
create() - Úsalo cuando un store de un solo dominio crece demasiado o cuando quieres una organización modular
¿Cómo pruebas stores de Zustand?
// Restablecer el store entre pruebas
beforeEach(() => {
useCounterStore.setState({ count: 0 });
});
// Probar sin React
test("increment funciona", () => {
const { getState } = useCounterStore;
getState().increment();
expect(getState().count).toBe(1);
});- Usa
setStatepara restablecer entre pruebas - Puedes probar los stores directamente sin renderizar componentes de React
¿Por qué debes usar SWR o TanStack Query en lugar de Zustand para datos del servidor?
- Zustand está diseñado para state del cliente (state de UI, state de formularios, preferencias del usuario)
- Los datos del servidor necesitan deduplicación, refetch en segundo plano, invalidación de caché y stale-while-revalidate
- SWR y TanStack Query proporcionan estas características de forma nativa
- Almacenar datos del servidor en Zustand conduce a gestión manual de caché y bugs por datos obsoletos