Claude Code y TanStack Query: guía práctica para datos y caché en React
Implementa TanStack Query con Claude Code: query keys, staleTime, optimistic updates, SSR hydration y tests.
Pedirle a Claude Code “añade TanStack Query” suele producir una pantalla que parece funcionar, pero no necesariamente una arquitectura de caché mantenible. Las query keys cambian de forma entre archivos, staleTime queda como un número mágico, las mutations invalidan demasiado o demasiado poco, y la hidratación SSR puede terminar haciendo doble fetch en el cliente.
Esta guía usa una lista de proyectos como ejemplo para trabajar con TanStack Query v5 de forma revisable: diseño de query keys, staleTime y gcTime, invalidación después de mutations, optimistic updates, estados de carga y error, SSR hydration y tests con network mockeado. Las decisiones de API se contrastan con la documentación oficial de TanStack: Query Keys, Important Defaults, Query Invalidation, Optimistic Updates, Server Rendering & Hydration y Testing.
Para el contexto del proyecto, combina este artículo con desarrollo React con Claude Code, estrategias de testing, MSW mocks y gestión de estado con Zustand. TanStack Query debe encargarse del estado del servidor; el estado pequeño de UI debe quedarse en React o en un store cliente bien acotado.
Define el contrato antes de editar
TanStack Query gestiona server state: datos cuya fuente de verdad está en una API, una base de datos o un proceso del backend. Eso no es lo mismo que el texto que el usuario está escribiendo, si un modal está abierto o qué pestaña está seleccionada.
Antes de soltar a Claude Code sobre el repositorio, dale este contrato:
| Decisión | Ejemplo | Por qué importa |
|---|---|---|
| Unidad de query key | ["projects", "list", filters] | Mantiene estable la dirección de caché |
| Frescura de datos | Listas 60 segundos, detalle 5 minutos | Hace explicable el refetch |
| Después de mutation | Invalidar lista prefix y detalle | Evita UI antigua después de escribir |
| Optimistic update | Reflejar un cambio de estado al instante | Permite rollback si falla |
| Verificación | Vitest, MSW, build | Da evidencia al review |
Una query key es la dirección de la caché. TanStack Query v5 espera que la key sea un array en el nivel superior, serializable y única para los datos consultados. Por eso ["projects", "list", { status: "active" }] describe condiciones de datos, no el nombre de un componente.
Casos de uso reales
El primer caso es una lista interna: usuarios, facturas, tickets, proyectos o productos. Search, status y page deben formar parte de la query key. Pídele a Claude Code que normalice espacios y mayúsculas antes de crear la key, para que Billing y billing no sean dos cachés distintas.
El segundo caso es detalle más edición. El detalle puede vivir en ["projects", "detail", id] y tener un staleTime mayor que una lista. Cuando una edición termina, invalida el detalle y el prefix de listas. invalidateQueries marca las queries como stale y, si se están renderizando, las vuelve a traer en background.
El tercer caso es un toggle rápido: estado, favorito, archivado o asignación. Optimistic update es útil, pero solo si también cancelas refetches en curso, guardas snapshot de cache, reviertes si falla e invalidas al final.
El cuarto caso es SSR con Next.js u otro framework. Prefetch en servidor más hydration mejora el primer render, pero si server y client construyen keys distintas, o si staleTime queda en 0, el cliente hará fetch inmediato. Usa el mismo query factory en ambos lados.
Query keys, staleTime y gcTime
Un prompt útil para Claude Code:
Lee el proyecto React + TypeScript existente e implementa una lista de projects con TanStack Query v5.
Usa query keys como arrays de primer nivel, filters normalizados, staleTime de 60 segundos para listas y gcTime de 10 minutos.
Usa queryOptions y placeholderData: keepPreviousData para paginación.
Crea funciones API, tipos, query key factory y hook en un módulo.
No uses el nombre antiguo cacheTime.
Guarda este archivo como src/features/projects/projects.query.ts. staleTime indica cuánto tiempo los datos se consideran frescos; gcTime indica cuánto tiempo queda una query inactiva en caché antes de recolectarse. En v5 usa gcTime, no cacheTime.
import {
keepPreviousData,
queryOptions,
useQuery,
} from "@tanstack/react-query";
export type ProjectStatus = "all" | "active" | "paused";
export type EditableProjectStatus = Exclude<ProjectStatus, "all">;
export type Project = {
id: string;
name: string;
status: EditableProjectStatus;
updatedAt: string;
};
export type ProjectFilters = {
status: ProjectStatus;
search: string;
page: number;
};
export type ProjectListResponse = {
items: Project[];
page: number;
hasMore: boolean;
};
export type UpdateProjectStatusInput = {
id: string;
status: EditableProjectStatus;
};
function normalizeFilters(filters: ProjectFilters): ProjectFilters {
return {
status: filters.status,
search: filters.search.trim().toLowerCase(),
page: Math.max(1, filters.page),
};
}
async function readJson<T>(response: Response): Promise<T> {
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return (await response.json()) as T;
}
export async function fetchProjects(
filters: ProjectFilters,
): Promise<ProjectListResponse> {
const normalized = normalizeFilters(filters);
const params = new URLSearchParams({
status: normalized.status,
search: normalized.search,
page: String(normalized.page),
});
const response = await fetch(`/api/projects?${params.toString()}`);
return readJson<ProjectListResponse>(response);
}
export async function fetchProject(id: string): Promise<Project> {
const response = await fetch(`/api/projects/${id}`);
return readJson<Project>(response);
}
export async function updateProjectStatus(
input: UpdateProjectStatusInput,
): Promise<Project> {
const response = await fetch(`/api/projects/${input.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: input.status }),
});
return readJson<Project>(response);
}
export const projectKeys = {
all: ["projects"] as const,
lists: () => [...projectKeys.all, "list"] as const,
list: (filters: ProjectFilters) =>
[...projectKeys.lists(), normalizeFilters(filters)] as const,
details: () => [...projectKeys.all, "detail"] as const,
detail: (id: string) => [...projectKeys.details(), id] as const,
};
export const projectQueries = {
list: (filters: ProjectFilters) =>
queryOptions({
queryKey: projectKeys.list(filters),
queryFn: () => fetchProjects(filters),
staleTime: 60_000,
gcTime: 10 * 60_000,
}),
detail: (id: string) =>
queryOptions({
queryKey: projectKeys.detail(id),
queryFn: () => fetchProject(id),
staleTime: 5 * 60_000,
gcTime: 30 * 60_000,
}),
};
export function useProjects(filters: ProjectFilters) {
return useQuery({
...projectQueries.list(filters),
placeholderData: keepPreviousData,
});
}
La parte crítica es normalizar antes de construir la key. También conviene evitar staleTime: Infinity como respuesta automática. En dashboards multiusuario, otra persona puede modificar los mismos datos.
Mutation invalidation y optimistic updates
Una mutation escribe en el servidor. Si Claude Code solo implementa el caso feliz, deja una bomba para producción. El patrón sano es cancelar queries afectadas, guardar snapshot, aplicar el valor optimista, hacer rollback en error e invalidar al final.
Añade src/features/projects/useUpdateProjectStatus.ts.
import type { QueryKey } from "@tanstack/react-query";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
projectKeys,
updateProjectStatus,
type Project,
type ProjectListResponse,
} from "./projects.query";
type ListSnapshot = [QueryKey, ProjectListResponse | undefined];
export function useUpdateProjectStatus() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateProjectStatus,
onMutate: async (input) => {
await queryClient.cancelQueries({ queryKey: projectKeys.lists() });
await queryClient.cancelQueries({ queryKey: projectKeys.detail(input.id) });
const listSnapshots =
queryClient.getQueriesData<ProjectListResponse>({
queryKey: projectKeys.lists(),
}) as ListSnapshot[];
const detailSnapshot = queryClient.getQueryData<Project>(
projectKeys.detail(input.id),
);
for (const [key, data] of listSnapshots) {
if (!data) continue;
queryClient.setQueryData<ProjectListResponse>(key, {
...data,
items: data.items.map((project) =>
project.id === input.id
? { ...project, status: input.status }
: project,
),
});
}
queryClient.setQueryData<Project>(
projectKeys.detail(input.id),
(current) =>
current ? { ...current, status: input.status } : current,
);
return { listSnapshots, detailSnapshot };
},
onError: (_error, input, context) => {
for (const [key, data] of context?.listSnapshots ?? []) {
queryClient.setQueryData(key, data);
}
queryClient.setQueryData(projectKeys.detail(input.id), context?.detailSnapshot);
},
onSettled: async (_data, _error, input) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: projectKeys.lists() }),
queryClient.invalidateQueries({ queryKey: projectKeys.detail(input.id) }),
]);
},
});
}
Los fallos típicos son actualizar solo el detalle y olvidar las listas visibles, o invalidar todo el cache con invalidateQueries() sin filtro. Pídele a Claude Code que enumere las keys invalidadas en el resumen del PR.
Estados de loading y error
La UI debe distinguir carga inicial, refetch en background, mutation en progreso y fallo de mutation. Este componente conserva esos estados de forma visible y testeable.
import { useState } from "react";
import {
useProjects,
type EditableProjectStatus,
type ProjectFilters,
type ProjectStatus,
} from "./projects.query";
import { useUpdateProjectStatus } from "./useUpdateProjectStatus";
const defaultFilters: ProjectFilters = {
status: "all",
search: "",
page: 1,
};
function nextStatus(status: EditableProjectStatus): EditableProjectStatus {
return status === "active" ? "paused" : "active";
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : "Unknown error";
}
export function ProjectListPage({
initialFilters = defaultFilters,
}: {
initialFilters?: ProjectFilters;
}) {
const [filters, setFilters] = useState<ProjectFilters>(initialFilters);
const projectsQuery = useProjects(filters);
const updateStatus = useUpdateProjectStatus();
function setStatus(status: ProjectStatus) {
setFilters((current) => ({ ...current, status, page: 1 }));
}
function setSearch(search: string) {
setFilters((current) => ({ ...current, search, page: 1 }));
}
if (projectsQuery.isPending) {
return <p role="status">Loading projects...</p>;
}
if (projectsQuery.isError) {
return (
<section role="alert">
<h2>Projects could not be loaded</h2>
<p>{errorMessage(projectsQuery.error)}</p>
<button type="button" onClick={() => void projectsQuery.refetch()}>
Retry
</button>
</section>
);
}
const projects = projectsQuery.data.items;
return (
<section aria-labelledby="projects-title">
<h2 id="projects-title">Projects</h2>
<label>
Search
<input
value={filters.search}
onChange={(event) => setSearch(event.target.value)}
/>
</label>
<label>
Status
<select
value={filters.status}
onChange={(event) => setStatus(event.target.value as ProjectStatus)}
>
<option value="all">All</option>
<option value="active">Active</option>
<option value="paused">Paused</option>
</select>
</label>
{projectsQuery.isFetching ? (
<p role="status">Refreshing cached data...</p>
) : null}
{updateStatus.isError ? (
<p role="alert">Update failed: {errorMessage(updateStatus.error)}</p>
) : null}
<ul>
{projects.map((project) => {
const next = nextStatus(project.status);
return (
<li key={project.id}>
<strong>{project.name}</strong>{" "}
<span aria-label={`status ${project.status}`}>
{project.status}
</span>
<button
type="button"
disabled={updateStatus.isPending}
onClick={() =>
updateStatus.mutate({ id: project.id, status: next })
}
aria-label={`${next} ${project.name}`}
>
Mark {next}
</button>
</li>
);
})}
</ul>
</section>
);
}
Un buen review prompt es: “comprueba si initial loading, background refresh, fallo de mutation, retry y disabled state se distinguen claramente”. Esta petición detecta más bugs que una orden genérica de “mejorar la UI”.
SSR e hydration
En SSR crea un QueryClient por request y usa la misma query factory en servidor y cliente. Si la key del prefetch no coincide con la key del hook, la hidratación no reutiliza la caché.
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from "@tanstack/react-query";
import { ProjectListPage } from "@/features/projects/ProjectListPage";
import {
projectQueries,
type ProjectFilters,
type ProjectStatus,
} from "@/features/projects/projects.query";
type PageProps = {
searchParams?: {
status?: string;
search?: string;
page?: string;
};
};
function parseStatus(value: string | undefined): ProjectStatus {
return value === "active" || value === "paused" ? value : "all";
}
export default async function ProjectsPage({ searchParams }: PageProps) {
const filters: ProjectFilters = {
status: parseStatus(searchParams?.status),
search: searchParams?.search ?? "",
page: Number(searchParams?.page ?? "1") || 1,
};
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000,
},
},
});
await queryClient.prefetchQuery(projectQueries.list(filters));
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ProjectListPage initialFilters={filters} />
</HydrationBoundary>
);
}
La trampa sutil es el tipo de dato. page: "1" en servidor y page: 1 en cliente son keys distintas. Centraliza el parser y pide a Claude Code que compare las keys server/client.
Tests con network mockeado
Usa MSW para mockear HTTP, crea un QueryClient nuevo por test y desactiva retries para que los errores sean rápidos.
import "@testing-library/jest-dom/vitest";
import type { ReactElement } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { ProjectListPage } from "./ProjectListPage";
const server = setupServer(
http.get("/api/projects", () =>
HttpResponse.json({
items: [
{
id: "p-1",
name: "Docs revamp",
status: "active",
updatedAt: "2026-06-02T00:00:00.000Z",
},
],
page: 1,
hasMore: false,
}),
),
http.patch("/api/projects/:id", async ({ params, request }) => {
const body = (await request.json()) as { status: "active" | "paused" };
return HttpResponse.json({
id: String(params.id),
name: "Docs revamp",
status: body.status,
updatedAt: "2026-06-02T00:01:00.000Z",
});
}),
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
function renderWithClient(ui: ReactElement) {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: Infinity,
},
mutations: {
retry: false,
},
},
});
return {
user: userEvent.setup(),
...render(
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>,
),
};
}
describe("ProjectListPage", () => {
it("loads projects from the API", async () => {
renderWithClient(<ProjectListPage />);
expect(screen.getByRole("status")).toHaveTextContent("Loading projects");
expect(await screen.findByText("Docs revamp")).toBeInTheDocument();
expect(screen.getByLabelText("status active")).toBeInTheDocument();
});
it("rolls back an optimistic update when the mutation fails", async () => {
server.use(
http.patch("/api/projects/:id", () =>
HttpResponse.json({ message: "boom" }, { status: 500 }),
),
);
const { user } = renderWithClient(<ProjectListPage />);
await screen.findByText("Docs revamp");
await user.click(
screen.getByRole("button", { name: /paused docs revamp/i }),
);
await waitFor(() =>
expect(screen.getByText(/Update failed:/)).toBeInTheDocument(),
);
expect(screen.getByLabelText("status active")).toBeInTheDocument();
});
});
Comandos de verificación:
npm create vite@latest tanstack-query-claude-demo -- --template react-ts
cd tanstack-query-claude-demo
npm install @tanstack/react-query
npm install -D vitest jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event msw
npm pkg set scripts.test="vitest"
npm run test -- --run src/features/projects/ProjectListPage.test.tsx
npm run build
Para este sitio:
node scripts/check-code-fences.mjs
node scripts/check-updated-article-quality.mjs
Errores frecuentes y prompt seguro
No pongas funciones, instancias de clase ni Date sin convertir dentro de query keys. La key debe ser serializable. Convierte fechas a strings estables.
No confundas staleTime con gcTime. staleTime decide frescura y refetch; gcTime decide cuánto dura una query inactiva en caché. Por defecto, los datos se consideran stale y las queries inactivas se recolectan después de unos cinco minutos.
No uses invalidación global por costumbre. queryClient.invalidateQueries() sin filtro oculta una mala estrategia de keys. Prefiere un prefix o una detail key exacta.
No dupliques factories entre SSR y cliente. Una diferencia pequeña entre string y number basta para perder la caché hidratada.
Prompt recomendado:
Usa solo TanStack Query v5. Usa gcTime, no cacheTime.
Separa query key factory, API functions, hooks, UI y tests.
En mutation implementa cancel, snapshot, optimistic update, rollback e invalidación dirigida.
Revisa que la key del SSR prefetch y la key del hook cliente sean idénticas.
Muestra los comandos: npm run test -- --run ProjectListPage.test.tsx y npm run build.
CTA y resultado práctico
TanStack Query es un buen punto para estandarizar Claude Code porque los errores de caché son silenciosos y caros. Si estás empezando, usa la chuleta gratuita. Si necesitas prompts y plantillas de review reutilizables, revisa ClaudeCodeLab products. Para aplicar reglas de query key, permisos, review y rollout en un repositorio real de equipo, empieza por formación y consultoría Claude Code.
Cuando Masa probó este flujo, el mayor ahorro vino de fijar primero el query key factory. Empezar por la UI produjo normalización incompleta, keys SSR distintas y listas que no se actualizaban tras mutation. Al incluir query keys, staleTime, gcTime, rollback y tests MSW desde el primer prompt, el diff de Claude Code fue menor y el review se volvió concreto: qué caché cambia y por qué.
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.