Claude Code e TanStack Query: guia prático para dados e cache em React
Implemente TanStack Query com Claude Code: query keys, staleTime, optimistic updates, SSR hydration e testes.
Pedir “adicione TanStack Query” ao Claude Code costuma gerar uma tela que parece funcionar, mas não necessariamente uma estratégia de cache revisável. As query keys mudam de formato, staleTime vira número mágico, mutations invalidam tudo ou esquecem listas importantes, e SSR hydration pode fazer fetch duplicado no cliente.
Este guia usa uma lista de projetos para mostrar um fluxo mais seguro com TanStack Query v5: desenho de query keys, staleTime e gcTime, invalidation após mutation, optimistic updates, estados de loading/error, SSR hydration e testes com rede mockada. As afirmações de API foram conferidas na documentação oficial do TanStack: Query Keys, Important Defaults, Query Invalidation, Optimistic Updates, Server Rendering & Hydration e Testing.
Para contexto, leia também desenvolvimento React com Claude Code, estratégias de teste, MSW mocks e Zustand state management. TanStack Query deve cuidar do estado do servidor; estado temporário de UI deve ficar em React state ou em um store cliente pequeno.
Defina o contrato antes da edição
TanStack Query gerencia server state: dados cuja fonte de verdade está na API, no banco ou no backend. Isso é diferente de texto digitado em um filtro, modal aberto ou aba selecionada.
Antes de chamar Claude Code, passe este contrato:
| Decisão | Exemplo | Motivo para review |
|---|---|---|
| Unidade da query key | ["projects", "list", filters] | Endereço de cache estável |
| Frescor | Listas 60 segundos, detalhe 5 minutos | Refetch explicável |
| Depois da mutation | Invalidar prefixo de lista e key de detalhe | Evita UI antiga |
| Optimistic update | Refletir status imediatamente | Permite rollback |
| Verificação | Vitest, MSW, build | Review com evidência |
Query key é o endereço do cache. No TanStack Query v5, ela deve ser um array no nível superior, serializável e única para os dados. ["projects", "list", { status: "active" }] descreve condições de dados, não o nome do componente.
Casos de uso reais
O primeiro caso é uma lista administrativa: usuários, faturas, tickets, projetos ou produtos. Search, status e page entram na query key. Peça ao Claude Code para normalizar espaços e caixa antes de criar a key; caso contrário, buscas equivalentes podem virar caches diferentes.
O segundo caso é detalhe e edição. O detalhe usa ["projects", "detail", id] e pode ter staleTime maior que a lista. Ao editar, invalide o detalhe e o prefixo das listas. invalidateQueries marca queries como stale e refaz fetch em background se estiverem renderizadas.
O terceiro caso é toggle rápido: status, favorito, arquivar ou atribuir responsável. Optimistic update é adequado, mas precisa de cancel de refetches, snapshot do cache antigo, rollback no erro e invalidation final.
O quarto caso é SSR. Prefetch no servidor mais hydration pode melhorar o primeiro render. Mas se server e client montam keys diferentes, ou se staleTime fica em 0, o client busca tudo de novo. Use a mesma query factory.
Query keys, staleTime e gcTime
Prompt recomendado:
Leia o projeto React + TypeScript existente e implemente uma lista projects com TanStack Query v5.
Use query keys como arrays no topo, filters normalizados, staleTime de 60 segundos para listas e gcTime de 10 minutos.
Use queryOptions e placeholderData: keepPreviousData para paginação.
Crie API functions, tipos, query key factory e hook no mesmo módulo.
Não use o nome antigo cacheTime.
Salve como src/features/projects/projects.query.ts. staleTime é o tempo em que os dados são considerados fresh. gcTime é o tempo em que uma query inativa permanece no cache. Em v5, use gcTime.
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,
});
}
O ponto central é normalizar antes da key. Evite staleTime: Infinity como padrão inicial; em sistemas multiusuário, dados podem mudar fora da sessão atual.
Mutation invalidation e optimistic update
Mutation é escrita no servidor. Claude Code precisa tratar sucesso e erro. O fluxo confiável cancela queries afetadas, guarda snapshot, escreve valor otimista, faz rollback em erro e invalida no final.
Crie 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) }),
]);
},
});
}
Falhas comuns: atualizar só o detalhe e esquecer listas visíveis, ou invalidar tudo sem filtro. Peça ao Claude Code para listar as keys invalidadas no resumo do PR.
Estados de loading e erro
Loading inicial, refresh em background, mutation em progresso e falha de mutation devem aparecer de formas distintas.
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>
);
}
Prompt de review útil: “confirme se loading inicial, background refresh, failure de mutation, retry e disabled state são distinguíveis”.
SSR e hydration
Em SSR, crie um QueryClient por request e reutilize a mesma query factory no servidor e no cliente.
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>
);
}
A armadilha é diferença de tipo: page: "1" no server e page: 1 no client são keys diferentes. Centralize parser e factory.
Testes com network mockado
Use MSW para mockar HTTP, crie um QueryClient novo por teste e desligue retry.
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();
});
});
Verificação:
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 artigo:
node scripts/check-code-fences.mjs
node scripts/check-updated-article-quality.mjs
Falhas comuns e prompt seguro
Não coloque funções, instâncias de classe ou Date cru em query keys. Converta datas para strings estáveis. Não confunda staleTime e gcTime: um controla frescor e refetch, o outro controla duração de queries inativas. Por padrão, dados são stale e queries inativas são coletadas após cerca de cinco minutos.
Não invalide globalmente por hábito. queryClient.invalidateQueries() sem filtro esconde key design fraco. Também não duplique factories entre SSR e client.
Use apenas TanStack Query v5. Use gcTime, não cacheTime.
Separe query key factory, API functions, hooks, UI e tests.
Na mutation, implemente cancel, snapshot, optimistic update, rollback e targeted invalidation.
Revise se a key do SSR prefetch e a key do client hook são idênticas.
Mostre npm run test -- --run ProjectListPage.test.tsx e npm run build como verificação.
CTA e resultado prático
TanStack Query é um bom ponto para padronizar Claude Code porque erros de cache são discretos e caros. Comece pelo cheatsheet gratuito. Para prompts e templates reutilizáveis, veja ClaudeCodeLab products. Para aplicar regras de query key, permissões, review e rollout em um repositório real, use treinamento e consultoria Claude Code.
Quando Masa testou esse fluxo, o maior ganho veio de fixar a query key factory antes da UI. Começar pela interface deixou normalização incompleta, keys SSR diferentes e listas antigas após mutation. Com query keys, staleTime, gcTime, rollback e testes MSW no primeiro prompt, o diff do Claude Code ficou menor e o review ficou mais objetivo.
PDF grátis: cheatsheet do Claude Code
Informe seu e-mail e baixe uma página com comandos, hábitos de revisão e workflows seguros.
Cuidamos dos seus dados e não enviamos spam.
Sobre o autor
Masa
Engenheiro focado em workflows práticos com Claude Code.
Artigos relacionados
Workflow Obsidian para CLAUDE.md com Claude Code
Transforme notas de trabalho do Obsidian em notas operacionais CLAUDE.md para preservar contexto.
Claude Code Revenue CTA Routing: artigos para PDF, Gumroad e consultoria
Um fluxo com Claude Code para levar leitores ao PDF grátis, Gumroad ou consultoria conforme intenção.
Regras de handoff para equipes com Claude Code: evidências, permissões, rollback e receita
Formato prático para entregar trabalho do Claude Code com prova, permissões, rollback, PDF grátis, Gumroad e consultoria.