Claude Code dan TanStack Query: panduan praktis data fetching React
Implementasikan TanStack Query dengan Claude Code: query keys, staleTime, optimistic update, SSR hydration, dan test.
Kalau kamu hanya meminta Claude Code “tambahkan TanStack Query”, hasilnya sering terlihat jalan tetapi sulit direview. Bentuk query key berbeda antar file, staleTime menjadi angka tanpa alasan, mutation meng-invalidasi terlalu luas atau terlalu sempit, dan SSR hydration bisa langsung memicu fetch kedua di client.
Panduan ini memakai layar daftar project untuk menunjukkan workflow yang lebih aman dengan TanStack Query v5: desain query key, staleTime dan gcTime, invalidation setelah mutation, optimistic update, loading/error states, SSR hydration, dan test dengan network yang dimock. Detail API dicek ke dokumentasi resmi TanStack: Query Keys, Important Defaults, Query Invalidation, Optimistic Updates, Server Rendering & Hydration, dan Testing.
Untuk konteks repo, baca juga React development dengan Claude Code, testing strategies, MSW mock, dan Zustand state management. TanStack Query sebaiknya menangani server state; UI state kecil tetap di React state atau store client yang jelas.
Tetapkan batas sebelum Claude Code mengedit
TanStack Query mengelola server state: data yang sumber kebenarannya ada di API, database, atau backend. Ini berbeda dari teks yang sedang diketik di search box, modal yang sedang terbuka, atau tab yang dipilih.
Berikan kontrak ini sebelum implementasi:
| Keputusan | Contoh | Alasan review |
|---|---|---|
| Unit query key | ["projects", "list", filters] | Alamat cache stabil |
| Freshness | List 60 detik, detail 5 menit | Refetch bisa dijelaskan |
| Setelah mutation | Invalidate prefix list dan detail key | UI lama tidak tersisa |
| Optimistic update | Status langsung berubah | Bisa rollback saat gagal |
| Verifikasi | Vitest, MSW, build | Review berbasis bukti |
Query key adalah alamat cache. Di TanStack Query v5, query key harus berupa array di level teratas, serializable, dan unik untuk data yang diambil. Jadi ["projects", "list", { status: "active" }] menjelaskan kondisi data, bukan nama komponen.
Use case nyata
Use case pertama adalah daftar admin: user, invoice, ticket, project, atau product. Search, status, dan page harus masuk ke query key. Minta Claude Code menormalisasi spasi dan huruf sebelum key dibuat, agar query yang sama tidak menjadi cache berbeda.
Use case kedua adalah detail dan edit. Detail bisa memakai ["projects", "detail", id] dan biasanya punya staleTime lebih lama daripada list. Setelah edit sukses, invalidate detail key dan list prefix. invalidateQueries menandai query sebagai stale dan refetch di background jika sedang dirender.
Use case ketiga adalah toggle cepat seperti status, favorite, archive, atau assignment. Optimistic update berguna, tetapi harus lengkap: cancel refetch yang sedang berjalan, snapshot cache lama, rollback kalau gagal, lalu invalidate.
Use case keempat adalah SSR. Server prefetch dan hydration bisa mempercepat first render, tetapi kalau server dan client membangun key berbeda, cache hydration tidak terpakai. Gunakan query factory yang sama dan biasanya pasang staleTime lebih dari 0.
Query keys, staleTime, dan gcTime
Prompt yang aman:
Baca project React + TypeScript yang ada, lalu implementasikan daftar projects dengan TanStack Query v5.
Gunakan query keys sebagai top-level arrays, filters yang dinormalisasi, staleTime 60 detik untuk list, dan gcTime 10 menit.
Gunakan queryOptions dan placeholderData: keepPreviousData untuk pagination.
Buat API functions, types, query key factory, dan hook dalam satu module.
Jangan gunakan option lama cacheTime.
Simpan sebagai src/features/projects/projects.query.ts. staleTime adalah durasi data dianggap fresh. gcTime adalah durasi query inactive tetap ada di cache. Di v5, gunakan 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,
});
}
Bagian paling penting adalah normalisasi sebelum filters menjadi key. Jangan langsung menjadikan staleTime: Infinity sebagai default, karena di dashboard multi-user data bisa berubah dari user lain.
Mutation invalidation dan optimistic update
Mutation adalah write ke server. Claude Code harus menangani success dan failure. Pola yang aman: cancel query terkait, ambil snapshot, tulis nilai optimis, rollback saat error, lalu invalidate secara targeted.
Tambahkan 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) }),
]);
},
});
}
Pitfall umum: hanya update detail cache dan lupa list yang terlihat, atau memanggil invalidateQueries() tanpa filter. Minta Claude Code mencantumkan key yang di-invalidate di summary PR.
Loading dan error state
Initial loading, background refresh, mutation pending, dan mutation error harus dibedakan.
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 review yang berguna: “cek apakah initial loading, background refresh, mutation failure, retry, dan disabled behavior bisa dibedakan.”
SSR dan hydration
Untuk SSR, buat QueryClient baru per request dan gunakan query factory yang sama di server serta client.
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>
);
}
Bug yang sering tersembunyi adalah type drift: page: "1" di server dan page: 1 di client menjadi key berbeda. Pusatkan parser dan key factory.
Test dengan mocked network
Gunakan MSW untuk mock HTTP, buat QueryClient baru per test, dan matikan 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();
});
});
Perintah verifikasi:
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
Untuk artikel ini:
node scripts/check-code-fences.mjs
node scripts/check-updated-article-quality.mjs
Failure modes dan prompt aman
Jangan masukkan function, class instance, atau raw Date ke query key. Ubah tanggal menjadi string stabil. Jangan mencampur staleTime dan gcTime: yang pertama mengatur freshness dan refetch, yang kedua mengatur umur cache untuk query inactive. Default-nya data dianggap stale dan inactive query biasanya dikumpulkan setelah sekitar 5 menit.
Jangan melakukan global invalidation tanpa alasan. queryClient.invalidateQueries() tanpa filter menyembunyikan desain key yang lemah. Jangan juga membuat factory berbeda antara SSR dan client.
Gunakan hanya TanStack Query v5. Gunakan gcTime, bukan cacheTime.
Pisahkan query key factory, API functions, hooks, UI, dan tests.
Di mutation, implementasikan cancel, snapshot, optimistic update, rollback, dan targeted invalidation.
Review apakah SSR prefetch key dan client hook key benar-benar identik.
Tampilkan command verifikasi: npm run test -- --run ProjectListPage.test.tsx dan npm run build.
CTA dan hasil praktik
TanStack Query adalah tempat yang bagus untuk menstandarkan workflow Claude Code karena bug cache sering diam-diam tetapi mahal. Mulai dari cheatsheet gratis. Untuk prompt dan template review yang bisa dipakai ulang, lihat ClaudeCodeLab products. Untuk menerapkan query key rules, permission, review policy, dan rollout di repository tim, gunakan Claude Code training and consultation.
Saat Masa mencoba workflow ini, peningkatan terbesar datang dari menetapkan query key factory sebelum membangun UI. Versi yang mulai dari UI melewatkan normalisasi filter, membuat SSR key berbeda, dan meninggalkan list lama setelah mutation. Saat query keys, staleTime, gcTime, rollback, dan MSW tests dimasukkan ke prompt pertama, diff Claude Code lebih kecil dan review menjadi konkret: cache mana yang berubah dan kenapa.
PDF gratis: cheatsheet Claude Code
Masukkan email dan unduh satu halaman berisi command, kebiasaan review, dan workflow aman.
Kami menjaga datamu dan tidak mengirim spam.
Tentang penulis
Masa
Engineer yang berfokus pada workflow Claude Code praktis dan adopsi tim.
Artikel terkait
Workflow Obsidian ke CLAUDE.md untuk Claude Code
Ubah catatan kerja Obsidian menjadi operating note CLAUDE.md agar konteks tidak dijelaskan ulang.
Claude Code Revenue CTA Routing: dari artikel ke PDF, Gumroad, dan konsultasi
Workflow Claude Code untuk mengarahkan pembaca ke PDF gratis, Gumroad, atau konsultasi sesuai intent.
Aturan handoff tim Claude Code: bukti review, permission, rollback, dan jalur revenue
Format handoff Claude Code untuk tim: bukti, permission rule, rollback, PDF gratis, Gumroad, dan konsultasi.