Use Cases (Diperbarui: 2/6/2026)

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.

Claude Code dan TanStack Query: panduan praktis data fetching React

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:

KeputusanContohAlasan review
Unit query key["projects", "list", filters]Alamat cache stabil
FreshnessList 60 detik, detail 5 menitRefetch bisa dijelaskan
Setelah mutationInvalidate prefix list dan detail keyUI lama tidak tersisa
Optimistic updateStatus langsung berubahBisa rollback saat gagal
VerifikasiVitest, MSW, buildReview 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.

#Claude Code #TanStack Query #React #data fetching #cache
Gratis

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.

Masa

Tentang penulis

Masa

Engineer yang berfokus pada workflow Claude Code praktis dan adopsi tim.