Use Cases (Updated: 6/2/2026)

Claude Code and TanStack Query: A Practical React Data Fetching Guide

Use Claude Code to implement TanStack Query with safer query keys, staleTime, mutations, SSR hydration, and tests.

Claude Code and TanStack Query: A Practical React Data Fetching Guide

If you ask Claude Code to “add TanStack Query” without boundaries, the result often looks fine but hides brittle cache behavior. Query keys drift from file to file, staleTime becomes a magic number, mutation invalidation is too broad, and SSR hydration refetches immediately on the client.

This guide uses a project-list screen to show a safer workflow for Claude Code and TanStack Query v5: query key design, staleTime and gcTime, mutation invalidation, optimistic updates, loading and error states, SSR hydration, and tests with mocked network calls. The factual API details are checked against the official TanStack docs for Query Keys, Important Defaults, Query Invalidation, Optimistic Updates, Server Rendering & Hydration, and Testing.

For surrounding ClaudeCodeLab material, pair this with React development, testing strategies, MSW mocks, and Zustand state management. TanStack Query should own server state; small local UI state should usually stay in React state or a focused client store.

Define the Boundary Before Claude Code Edits

TanStack Query is for server state: data whose source of truth lives behind an API, database, or server process. It is different from a search input while the user is typing, a dialog’s open state, or the currently selected tab. Give Claude Code that split before it creates files.

Use this contract before the first implementation prompt.

DecisionExampleReview reason
Query key unit["projects", "list", filters]Keeps cache addresses stable
Freshness policyLists 60 seconds, details 5 minutesMakes refetch behavior explainable
Mutation follow-upInvalidate list prefix and detail keyAvoids stale UI after writes
Optimistic updateReflect status changes immediatelyAllows rollback on failure
VerificationVitest, MSW, buildGives reviewers evidence

A query key is the cache address. TanStack Query v5 expects query keys to be arrays at the top level, serializable, and unique to the data being fetched. That means a key like ["projects", "list", { status: "active" }] should describe data conditions, not just a component name.

Practical Use Cases

Use case one is an admin list: users, invoices, tickets, projects, or products. Put search terms, status filters, and page numbers in the query key. Tell Claude Code to normalize empty strings and casing before those filters become cache keys, otherwise “Billing” and “ billing ” can become two separate cache entries.

Use case two is a detail and edit workflow. A detail query such as ["projects", "detail", id] can stay fresh longer than a list. After an edit succeeds, invalidate the detail key and the list prefix. TanStack Query’s invalidateQueries marks matching queries stale and refetches them in the background if they are currently rendered.

Use case three is a fast toggle such as status, favorite, archive, or assignment. Optimistic updates are useful here, but only if Claude Code also cancels in-flight refetches, snapshots previous cache data, rolls back on error, and invalidates after settlement.

Use case four is SSR in frameworks like Next.js. Server prefetch plus hydration can improve first paint, but it also creates a trap: if the server key and client key differ, or staleTime defaults to zero, the client may refetch immediately. For SSR, use the same query factory on both sides and usually set a positive default staleTime.

Query Keys, staleTime, and gcTime

Start Claude Code with a precise prompt:

Read the existing React + TypeScript project and implement a TanStack Query v5 projects list.
Use top-level array query keys, normalized filters, staleTime of 60 seconds for lists, and gcTime of 10 minutes.
Use queryOptions and placeholderData: keepPreviousData for pagination.
Create the API functions, types, query key factory, and hook in one module.
Do not use the old cacheTime option name.

Paste this as src/features/projects/projects.query.ts. staleTime is how long data is considered fresh. gcTime is how long inactive query data remains in cache before garbage collection. In v5, use gcTime; older articles may still mention 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,
  });
}

The important detail is normalization before filters reach the key. The article examples also avoid setting staleTime: Infinity as a default. In multi-user dashboards, another person can change the same server data, so “never consider this stale” is often a poor first choice.

Mutation Invalidation and Optimistic Updates

A mutation is a server write. When Claude Code writes mutations, require the full failure path, not just the happy path. The official optimistic update pattern is: cancel affected queries, snapshot previous cache data, write the optimistic value, roll back on error, and invalidate after success or failure.

Add 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) }),
      ]);
    },
  });
}

The common failure is updating only the detail cache and forgetting every visible list. Another failure is calling invalidateQueries() with no filter after every mutation. It appears safe in a small app, but as the product grows it creates unnecessary network traffic and unclear reviews. Ask Claude Code to list each invalidated key in the PR summary.

Loading and Error States in UI

Do not collapse every async state into one spinner. Initial loading, background fetching, mutation in progress, and mutation failure mean different things. The page below keeps those states visible and testable.

Paste this as src/features/projects/ProjectListPage.tsx.

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>
  );
}

A useful Claude Code review prompt here is: “Check whether initial loading, background refresh, mutation failure, retry, and button disabled behavior are all distinguishable.” That catches many real UI bugs before visual polish starts.

SSR and Hydration Caveats

For SSR, create a new QueryClient per request and reuse the same query factory on server and client. Do not build a different key in the route loader than the one used by the hook. With SSR, a positive staleTime is commonly used so prefetched data does not refetch immediately on hydration.

This is a Next.js App Router style example:

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>
  );
}

The subtle bug is type drift. Server code may parse page as a string while client code uses a number. That produces a different query key and defeats prefetching. Keep the parser and key factory shared, then ask Claude Code to compare server and client keys during review.

Tests with Mocked Network

Use MSW to mock HTTP boundaries, and create a fresh test QueryClient for each test. Set retry: false so failures are fast and deterministic. The official testing guide also notes gcTime: Infinity as a useful setting for Jest timer issues; using it in this isolated client keeps tests quiet.

Paste this as src/features/projects/ProjectListPage.test.tsx.

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();
  });
});

Run the example in a small Vite app:

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

For this site article, run:

node scripts/check-code-fences.mjs
node scripts/check-updated-article-quality.mjs

Failure Modes and Safer Prompts

First, do not put functions, class instances, or raw Date objects in query keys. The key needs to be serializable. Convert dates to ISO strings or stable date segments before they reach the key factory.

Second, do not confuse staleTime and gcTime. staleTime controls freshness and refetch decisions. gcTime controls how long inactive query data remains cached. By default, query data is considered stale, and inactive queries are garbage-collected after about five minutes.

Third, avoid global invalidation unless you really mean it. queryClient.invalidateQueries() with no filter can hide missing key design. Prefer a prefix such as projectKeys.lists() or an exact detail key.

Fourth, avoid separate server and client key factories. SSR hydration depends on the same key describing the same data on both sides. A number on the client and a string on the server is enough to miss the cache.

Fifth, do not test against the real API for component behavior. Mock the HTTP boundary, create a fresh QueryClient, disable retries, and test loading, success, mutation failure, and rollback separately.

A safer prompt for Claude Code is:

Use TanStack Query v5 only. Use gcTime, not cacheTime.
Separate the query key factory, API functions, hooks, UI, and tests.
For the mutation, implement cancel, snapshot, optimistic update, rollback, and targeted invalidation.
Review whether the SSR prefetch key and client hook key are identical.
Show the expected verification commands: npm run test -- --run ProjectListPage.test.tsx and npm run build.

CTA and Hands-On Result

TanStack Query is a good place to standardize your Claude Code workflow because cache mistakes are subtle and expensive. Solo builders can start with the free cheatsheet. If you want reusable implementation and review prompts, use the ClaudeCodeLab products. Teams that need repository-specific query key rules, permissions, review policy, and rollout training should use Claude Code training and consultation.

When Masa tried this workflow, the biggest improvement came from freezing the query key factory before touching the UI. Starting with UI first led to missed filter normalization, mismatched SSR keys, and incomplete invalidation after mutations. Starting with keys, staleTime, gcTime, rollback behavior, and MSW tests made the Claude Code diff smaller and the review much more concrete: each discussion was about which cache changed and why.

#Claude Code #TanStack Query #React #data fetching #caching
Free

Free PDF: Claude Code Cheatsheet

Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.

We handle your data with care and never send spam.

Level up your Claude Code workflow

Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.

Masa

About the Author

Masa

Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.