Gestión de estado en React con Claude Code: guía práctica
Ordena estado React con Claude Code: Context, Zustand, Jotai, TanStack Query, tests y prompts seguros.
La gestión de estado en React se complica cuando todo se trata como si fuera lo mismo. El valor de un input, un modal abierto, un carrito, una preferencia de tema y una lista de productos obtenida por API son estado, pero no tienen el mismo dueño ni la misma vida útil.
Claude Code ayuda mucho a limpiar este tipo de código, pero necesita una instrucción precisa. “Mejora el state management” suele terminar en una store global demasiado grande, caches duplicadas o una migración de librería sin pruebas. Un flujo más seguro es pedir primero un inventario de estado, separar client state y server state, y después migrar una parte pequeña.
Esta guía cubre criterios de decisión, ejemplos copiables de React/TypeScript, casos de uso de todo, carrito, settings y productos, pruebas, prompts de refactorización, enlaces oficiales y un CTA de monetización para convertir este aprendizaje en un proceso de equipo.
Clasifica el estado antes de elegir librería
La primera pregunta no es “¿Zustand o Jotai?”, sino “¿quién es dueño de este dato?”. Puede ser estado local de UI, estado de cliente compartido, preferencia persistida o estado del servidor.
flowchart TD
A["React state management"] --> B["Local UI state"]
A --> C["Shared client state"]
A --> D["Persisted preferences"]
A --> E["Server state"]
B --> B1["input, modal, tab"]
C --> C1["cart, wizard, editor"]
D --> D1["theme, density, locale"]
E --> E1["products, orders, profile from API"]
El estado local de UI debe vivir cerca del componente. Usa useState para valores simples y useReducer cuando una pantalla tiene varias acciones. El estado de cliente compartido nace en el navegador y se lee desde partes lejanas del árbol, como un carrito. Las preferencias persistidas sobreviven a una recarga. El server state viene de una API y puede cambiar fuera de tu aplicación.
Cuándo basta React
La documentación oficial de React sobre Managing State recomienda estructurar bien el estado, elevarlo cuando sea necesario y escalar con reducer y context. Ese orden evita meter una librería antes de tiempo.
| Necesidad | Primera opción | Considera librería cuando |
|---|---|---|
| Input, tab, modal | useState | Casi nunca |
| Pantalla con muchas acciones | useReducer | El reducer se reutiliza en varias rutas |
| Evitar prop drilling | Context | Las actualizaciones frecuentes re-renderizan demasiado |
| Carrito o borrador de editor | Zustand | Componentes lejanos lo modifican |
| Tema y densidad visual | Jotai | Hay muchas preferencias pequeñas |
| Productos, pedidos, perfil API | TanStack Query | Necesitas cache, retry, refetch e invalidación |
Una regla práctica: si el valor se usa en menos de tres lugares, no viene de una API y no necesita persistir tras recargar, empieza con React.
Caso 1: Todo con useReducer y Context
Un todo list tiene acciones claras: añadir, alternar y borrar. Todavía no necesita sincronización con servidor, así que reducer y context son suficientes.
import {
createContext,
useContext,
useMemo,
useReducer,
type Dispatch,
type ReactNode,
} from "react";
export type Todo = {
id: string;
title: string;
done: boolean;
};
type TodoAction =
| { type: "added"; title: string }
| { type: "toggled"; id: string }
| { type: "deleted"; id: string };
export function todoReducer(todos: Todo[], action: TodoAction): Todo[] {
switch (action.type) {
case "added":
return [
...todos,
{ id: crypto.randomUUID(), title: action.title.trim(), done: false },
];
case "toggled":
return todos.map((todo) =>
todo.id === action.id ? { ...todo, done: !todo.done } : todo,
);
case "deleted":
return todos.filter((todo) => todo.id !== action.id);
default:
return todos;
}
}
const TodoStateContext = createContext<Todo[] | null>(null);
const TodoDispatchContext = createContext<Dispatch<TodoAction> | null>(null);
export function TodoProvider({ children }: { children: ReactNode }) {
const [todos, dispatch] = useReducer(todoReducer, []);
const stateValue = useMemo(() => todos, [todos]);
return (
<TodoStateContext.Provider value={stateValue}>
<TodoDispatchContext.Provider value={dispatch}>
{children}
</TodoDispatchContext.Provider>
</TodoStateContext.Provider>
);
}
export function useTodos() {
const value = useContext(TodoStateContext);
if (value === null) throw new Error("useTodos must be used in TodoProvider");
return value;
}
export function useTodoDispatch() {
const value = useContext(TodoDispatchContext);
if (value === null) {
throw new Error("useTodoDispatch must be used in TodoProvider");
}
return value;
}
Para Claude Code, esta estructura es fácil de revisar. Puedes pedir “añade una acción edited”, “rechaza títulos vacíos” o “escribe tests del reducer” sin tocar toda la aplicación.
Caso 2: Carrito con Zustand
Un carrito lo leen la página de producto, el header, el drawer y checkout. Zustand funciona bien cuando varias zonas distantes necesitan el mismo estado de cliente y acciones simples. El middleware persist sirve para guardar solo la parte segura del estado.
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
export type CartItem = {
productId: string;
name: string;
price: number;
quantity: number;
};
type CartState = {
items: CartItem[];
currency: "JPY" | "USD";
addItem: (item: Omit<CartItem, "quantity">) => void;
removeItem: (productId: string) => void;
setQuantity: (productId: string, quantity: number) => void;
clear: () => void;
total: () => number;
};
export const useCartStore = create<CartState>()(
persist(
(set, get) => ({
items: [],
currency: "USD",
addItem: (item) =>
set((state) => {
const current = state.items.find(
(entry) => entry.productId === item.productId,
);
if (current) {
return {
items: state.items.map((entry) =>
entry.productId === item.productId
? { ...entry, quantity: entry.quantity + 1 }
: entry,
),
};
}
return { items: [...state.items, { ...item, quantity: 1 }] };
}),
removeItem: (productId) =>
set((state) => ({
items: state.items.filter((item) => item.productId !== productId),
})),
setQuantity: (productId, quantity) =>
set((state) => ({
items: state.items.map((item) =>
item.productId === productId
? { ...item, quantity: Math.max(1, quantity) }
: item,
),
})),
clear: () => set({ items: [] }),
total: () =>
get().items.reduce((sum, item) => sum + item.price * item.quantity, 0),
}),
{
name: "cart-v1",
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
items: state.items,
currency: state.currency,
}),
},
),
);
El carrito del navegador no debe decidir el precio final. El servidor debe validar precio, stock, impuestos, descuento y pago. En el prompt, dile a Claude Code qué se puede persistir y qué debe confirmarse por API.
Caso 3: Settings con Jotai
Tema, densidad, sidebar y modo preview son valores pequeños. Jotai permite modelarlos como atoms y crear valores derivados sin construir una store gigante.
import { atom } from "jotai";
import { atomWithStorage } from "jotai/utils";
export const themeAtom = atomWithStorage<"light" | "dark">(
"settings.theme",
"light",
);
export const densityAtom = atomWithStorage<"comfortable" | "compact">(
"settings.density",
"comfortable",
);
export const sidebarOpenAtom = atom(true);
export const settingsLabelAtom = atom((get) => {
const theme = get(themeAtom) === "dark" ? "dark theme" : "light theme";
const density =
get(densityAtom) === "compact" ? "compact layout" : "comfortable layout";
return `${theme}, ${density}`;
});
export const resetSettingsAtom = atom(null, (_get, set) => {
set(themeAtom, "light");
set(densityAtom, "comfortable");
set(sidebarOpenAtom, true);
});
El peligro es atomizar todo. Jotai es cómodo para preferencias y valores derivados, pero no debe reemplazar el cache de una API ni esconder reglas complejas de negocio.
Caso 4: Productos y pedidos con TanStack Query
Productos, pedidos y perfil son server state. Pueden estar obsoletos, fallar, reintentarse o cambiar desde otro dispositivo. TanStack Query v5 está diseñado para fetching, caching, synchronization e updates de server state.
import {
QueryClient,
QueryClientProvider,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import type { ReactNode } from "react";
type Product = {
id: string;
name: string;
price: number;
stock: number;
};
const queryClient = new QueryClient();
export function ProductsProvider({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
async function fetchProducts(category: string): Promise<Product[]> {
const response = await fetch(`/api/products?category=${category}`);
if (!response.ok) throw new Error("Failed to fetch products");
return response.json() as Promise<Product[]>;
}
async function addCartLine(productId: string) {
const response = await fetch("/api/cart/lines", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ productId }),
});
if (!response.ok) throw new Error("Failed to add cart line");
return response.json();
}
export function useProducts(category: string) {
return useQuery({
queryKey: ["products", category],
queryFn: () => fetchProducts(category),
staleTime: 60_000,
});
}
export function useAddCartLine() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: addCartLine,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["cart"] });
},
});
}
La frontera práctica es esta: Zustand puede guardar la intención temporal del carrito; TanStack Query debe leer el carrito confirmado, productos, pedidos y checkout desde el servidor.
Prompts seguros para Claude Code
La documentación de How Claude Code works describe un ciclo de contexto, acción y verificación. Úsalo literalmente. Primero pide análisis, no cambios.
Inspecciona la gestión de estado de esta app React. No edites todavía.
Objetivos:
- separar client state y server state
- identificar qué puede quedarse en useState/useReducer/Context
- justificar cualquier uso de Zustand, Jotai o TanStack Query
Devuelve:
- tabla de inventario de estado
- estado duplicado o derivado
- datos de API guardados como client state
- orden de migración seguro con tests
Luego migra una parte:
Aplica solo el refactor del todo list a useReducer + Context.
Restricciones:
- no cambiar rutas ni copy visible
- no añadir dependencias
- añadir o actualizar tests del reducer
- reportar archivos modificados y comandos de verificación
Para carrito y API, añade reglas de dominio: no persistir tokens, confirmar precio en servidor, usar TanStack Query para datos remotos y mantener estable la API pública de componentes.
Tests y errores comunes
Empieza probando lógica pura. Un reducer se puede testear con Vitest sin montar toda la UI.
import { describe, expect, it, vi } from "vitest";
import { todoReducer, type Todo } from "./TodoProvider";
describe("todoReducer", () => {
it("adds a todo with a generated id", () => {
vi.spyOn(crypto, "randomUUID").mockReturnValue("todo-1");
const result = todoReducer([], { type: "added", title: "Write tests" });
expect(result).toEqual([
{ id: "todo-1", title: "Write tests", done: false },
]);
});
it("toggles a todo", () => {
const initial: Todo[] = [{ id: "todo-1", title: "Ship", done: false }];
const result = todoReducer(initial, { type: "toggled", id: "todo-1" });
expect(result[0].done).toBe(true);
});
});
Para TanStack Query, sigue su testing guide: crea un QueryClient nuevo por test para que el cache no se comparta.
Los errores más comunes son meter todo en una store global, guardar server state en Zustand, duplicar valores derivados, persistir datos sensibles y aceptar el diff de Claude Code sin test, build y revisión manual en recarga, error de API y red lenta.
Enlaces, CTA y resultado probado
Referencias oficiales: React Managing State, Zustand persist, Jotai, TanStack Query overview y Claude Code best practices.
Para mejorar los prompts, sigue con la guía de mejores prompts y los tips de productividad de Claude Code. Si necesitas plantillas, revisa productos. Para aplicar esto en un repositorio real de equipo, usa training y consultoría.
Al probar este flujo, el mayor avance fue mover primero el server state a TanStack Query. Zustand quedó más pequeño, Jotai se limitó a preferencias y los diffs de Claude Code fueron más fáciles de revisar porque antes existía un inventario de estado y un plan de migración.
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
Escalera de permisos de Claude Code para ampliar acceso sin perder control
Pasa de read-only a ediciones limitadas, comandos de prueba y checks de deploy con menos riesgo.
Claude Code Small PR Proof Pack: cambios pequeños que sí se pueden revisar
Un paquete de prueba para PRs de Claude Code: diff, checks, URL pública, CTA y rollback.
Gate de revisión antes del commit con Claude Code
Cómo revisar con Claude Code antes del commit: diff, build, URL pública, Gumroad, consultoría, tests y archivos ajenos.