Use Cases (更新: 2026/6/2)

Claude Code 与 TanStack Query:React 数据获取实战指南

用 Claude Code 安全实现 TanStack Query:query key、staleTime、乐观更新、SSR hydration 与测试。

Claude Code 与 TanStack Query:React 数据获取实战指南

如果只对 Claude Code 说“帮我加 TanStack Query”,它很容易生成看似能跑、但缓存边界很脆的代码。query key 在不同文件里形状不一致,staleTime 变成没有依据的数字,mutation 成功后到底要刷新哪些列表也说不清。这样的代码上线后,最常见的问题不是立刻报错,而是“刚编辑的数据为什么还旧”。

这篇文章用一个项目列表页做例子,整理 Claude Code 与 TanStack Query v5 的安全工作流:query key 设计、staleTimegcTime、mutation invalidation、乐观更新、加载/错误状态、SSR hydration,以及用 MSW mock network 的测试。涉及 API 行为的事实以官方文档为准:TanStack Query 的 Query KeysImportant DefaultsQuery InvalidationOptimistic UpdatesServer Rendering & HydrationTesting

如果你还在整理 React 组件边界,可以先看 Claude Code React 开发。测试策略可配合 Claude Code 测试策略,API mock 可看 Claude Code MSW Mock,而临时 UI 状态与服务端状态的分离可参考 Claude Code Zustand

先给 Claude Code 明确边界

TanStack Query 主要管理 server state,也就是以 API、数据库或后端服务为准的数据。搜索框正在输入的文字、弹窗是否打开、当前选中的 tab,这些更像本地 UI 状态,不应该随手塞进 query cache。

在让 Claude Code 修改代码前,先写下这个表:

需要决定的点示例为什么方便 review
query key 单位["projects", "list", filters]缓存地址不会漂移
数据新鲜度列表 60 秒,详情 5 分钟refetch 行为可解释
mutation 后续动作invalidate 列表 prefix 和详情 key写入后不保留旧 UI
乐观更新状态切换先反映到界面失败时可以 rollback
验证方式Vitest、MSW、buildreview 有证据,不靠感觉

可以把 query key 理解为缓存地址。TanStack Query v5 官方文档说明,query key 顶层必须是数组,并且要可序列化、能唯一描述对应数据。因此 ["projects", "list", { status: "active" }] 这种 key 描述的是数据条件,而不是某个组件的名字。

真实可用的场景

第一个场景是后台列表:用户、订单、发票、工单、项目、商品都属于这一类。搜索词、状态筛选、页码应该进入 query key。给 Claude Code 的要求要具体到“空字符串要正规化”“大小写要统一”,否则 Billingbilling 可能变成两个缓存。

第二个场景是详情页与编辑页。详情可以用 ["projects", "detail", id],并且通常比列表保持更久的新鲜度。编辑成功后,既要 invalidate 详情 key,也要 invalidate 列表 prefix。TanStack Query 的 invalidateQueries 会把匹配的 query 标记为 stale,如果它正在页面上渲染,还会在后台重新获取。

第三个场景是状态切换、收藏、归档、分配负责人这类快速操作。这里适合乐观更新,但不能只做“先改 UI”。正确流程是 cancel 正在进行的 refetch,保存旧 cache 快照,写入乐观值,失败时恢复,最后再 invalidate。

第四个场景是 Next.js 等框架里的 SSR。服务端 prefetch 后把数据 hydrate 到客户端,可以改善首屏;但如果服务端和客户端 query key 不一致,或者 staleTime 仍是默认的 0,客户端会立刻重新请求。SSR 中要复用同一个 query factory,并通常设置一个大于 0 的默认 staleTime

query key、staleTime 与 gcTime

先把需求这样交给 Claude Code:

读取现有 React + TypeScript 项目,用 TanStack Query v5 实现 projects 列表。
query key 使用顶层数组,filters 先正规化,列表 staleTime 为 60 秒,gcTime 为 10 分钟。
使用 queryOptions,并在 useQuery 中用 placeholderData: keepPreviousData 减少翻页闪烁。
API 函数、类型、query key factory、hook 放在同一模块。
不要使用旧的 cacheTime 选项名。

下面代码可保存为 src/features/projects/projects.query.tsstaleTime 表示数据被视为 fresh 的时间,gcTime 表示 inactive query 在缓存里保留多久。v5 应使用 gcTime,不要照抄旧文章里的 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,
  });
}

这里的重点不是写了多少代码,而是把 key factory 固定下来。不要一开始就把所有 query 都设成 staleTime: Infinity。在多人后台里,数据可能被别人更新,过长的新鲜期会让页面长期显示旧信息。

mutation invalidation 与乐观更新

mutation 是写入服务端的动作。让 Claude Code 写 mutation 时,必须要求它同时处理失败路径。安全的乐观更新步骤是:取消相关 query,保存旧数据快照,写入临时值,失败时恢复,最后 invalidate。

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

常见坑是只更新 detail cache,忘了当前可见的列表;或者每次都无条件 invalidateQueries(),导致整个应用重新请求。让 Claude Code 在 PR 说明里列出“本次 mutation 影响了哪些 query key”,review 会轻很多。

显示 loading 与 error 状态

不要把所有异步状态都做成一个 spinner。首次加载、后台刷新、mutation 进行中、mutation 失败,对用户和维护者的含义都不同。

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

给 Claude Code 的 review 指令可以是:“检查首次 loading、后台 refreshing、mutation 失败、retry、button disabled 是否能被用户区分。”这比“看起来漂亮一点”更容易发现真实问题。

SSR 与 hydration 注意点

SSR 中不要跨请求共享同一个 QueryClient。每个请求都创建新的 client,避免不同用户的数据混入同一个 cache。服务端和客户端还必须使用同一个 query factory,否则 hydration 后会出现二次请求。

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 是类型不一致:服务端把 page 留成字符串,客户端把它变成数字,就会得到不同的 query key。把解析函数和 key factory 放在共享模块里,再让 Claude Code 比较 server/client 的 key。

用 MSW mock network 写测试

组件测试不要依赖真实 API。用 MSW mock HTTP 边界,每个测试创建新的 QueryClient,并设置 retry: false。这样失败路径不会因为自动重试而变慢。

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

本地验证命令如下:

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

更新本站文章后,也执行:

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

失败例与安全 prompt

第一,不要把函数、class instance 或原始 Date 放进 query key。需要先转换成稳定字符串。

第二,不要混淆 staleTimegcTime。前者决定数据多久算 fresh,后者决定 inactive query 留在 cache 中多久。默认情况下数据会被视为 stale,inactive query 通常 5 分钟后被 garbage collect。

第三,不要随手无参数调用 queryClient.invalidateQueries()。小应用里看似安全,页面变多后会制造多余请求,也掩盖 key 设计问题。

第四,不要在 SSR 和 client hook 中各写一套 key。page: "1"page: 1 这种差异就足以让 hydration cache 失效。

第五,不要用真实 API 测组件失败路径。用 MSW、独立 test QueryClientretry: false,分别测试 loading、success、mutation failure 与 rollback。

给 Claude Code 的安全 prompt 可以这样写:

只使用 TanStack Query v5。使用 gcTime,不使用 cacheTime。
拆分 query key factory、API functions、hooks、UI 和 tests。
mutation 必须包含 cancel、snapshot、optimistic update、rollback、targeted invalidation。
检查 SSR prefetch key 与 client hook key 是否完全一致。
最后给出验证命令:npm run test -- --run ProjectListPage.test.tsx 和 npm run build。

CTA 与实际验证结果

TanStack Query 很适合标准化 Claude Code 工作流,因为缓存错误隐蔽、修复成本高。个人练习可以从免费 Claude Code cheatsheet开始;需要可复用 prompt 和 review 模板,可以看ClaudeCodeLab 产品;团队要把 query key 规则、权限、review policy 和上线流程放进真实仓库,可从Claude Code 培训与咨询开始。

Masa 实际试过这个流程后,最明显的收获是:先固定 query key factory,再做 UI。先做 UI 的版本容易漏掉 filter 正规化、SSR key 一致性和 mutation 后的列表刷新。把 query key、staleTimegcTime、rollback 与 MSW 测试放进第一条 Claude Code 指令后,diff 更小,review 也更具体:每次讨论都能回到“哪个 cache 被更新,为什么更新”。

#Claude Code #TanStack Query #React #数据获取 #缓存
免费

免费 PDF: Claude Code 速查表

输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。

我们会妥善保护你的信息,不发送垃圾邮件。

把 Claude Code 变成真正能带来结果的工作流

先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。

Masa

关于作者

Masa

专注 Claude Code 实务流程、团队导入和内容转化的工程师。