TanStack Query v5 基础:从 Server State 到 Query、Mutation 与缓存

8月 12, 2026
React, Frontend, TypeScript, ByAI

AI 参与说明(Agent:/root、/root/tanstack_v5_docs、/root/tanstack_beginner_examples、/root/tanstack_v5_blog_preflight):本文由 Agent 根据 TanStack Query 官方 v5 文档辅助整理,重点核对 Query 生命周期、缓存默认值、Mutation、失效与 v4 到 v5 的迁移差异。资料核验于 2026-08-12;请以项目的 lockfile 和目标版本为准。

适用范围:本文面向 React 18+ 与 @tanstack/react-query v5 的初学者。它刻意不从 Convex、SSR、Suspense、无限滚动或 optimistic update 开始;先把最常用的“读列表 → 写入 → 让列表同步”闭环讲清楚。

示例运行说明:下文是一个 Todo 前端示例,假定项目已有真实后端接口:GET /api/todos?filter=all|open|done 返回 Todo[]POST /api/todos 接受 { "title": string } 并返回新建的 Todo。TanStack Query 不提供后端;将代码放进已有 React 应用,并实现这两个接口后即可复现。示例使用原生 fetch,所以特意检查了 response.ok

先用一句话说清:它不替代 useState#

TanStack Query 管理的是 server state:权威副本在服务器、需要异步获取、可能过期,也可能被别的用户、别的页面或别的设备改掉的数据。

useState 仍然很重要,但它更适合 UI state:输入框里的草稿、弹窗是否打开、当前选择的筛选条件、Tab 位置等。不要因为装了 TanStack Query,就把所有状态都塞进缓存。

数据谁是权威来源更合适的工具
Todo 列表、用户资料、订单状态服务端useQuery
新增、修改、删除 Todo服务端写操作useMutation
输入框文字、弹窗开关、当前筛选项当前页面useState 或表单状态工具

可以把它记成一句话:

useQuery 声明“这个组件依赖哪一份远端数据”;TanStack Query 根据 queryKey 复用、缓存、刷新这份数据,并把请求状态交给组件。

官方 Overview 对这类状态的边界有更完整的说明。

先看全局流程:一条数据怎样来到页面上#

第一次读 Todo 列表时,组件不是直接“发一个请求然后自己记住结果”。它把请求交给 QueryClient:由 queryKey 标识这份数据,queryFn 负责真正请求。相同 key 的组件可以共享结果与请求状态。

flowchart TD
    A["组件调用 useQuery"] --> B["QueryClient 按 queryKey 查缓存"]
    B --> C{"有可用的缓存结果?"}
    C -->|"有"| D["先渲染已有数据"]
    C -->|"没有或需要重新验证"| E["运行 queryFn 请求服务端"]
    E --> F["把结果写入缓存并重新渲染"]
    G["Mutation 写入成功"] --> H["invalidateQueries 标记相关 key 为 stale"]
    H --> E

图里的“缓存”不是第二个数据库,也不是永远正确的真相;服务端才是权威。缓存的目标是让页面复用已经拿到的结果,并在合适的时机重新验证。何时“合适”,由 staleTime、组件是否仍在使用这份 query 等规则决定,后面会拆开讲。

用一个 Todo 示例从零走一遍#

先安装 v5:

npm install @tanstack/react-query@^5

TanStack Query v5 的 React 最低要求是 React 18。v5 migration guide

第一步:在 React 根部放入 QueryClientProvider#

QueryClient 是浏览器中管理 Query 缓存、观察者和失效操作的中心。把它创建在组件外,避免每次 render 都得到一份新缓存。

// src/main.tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import {
  QueryClient,
  QueryClientProvider,
} from "@tanstack/react-query";
import { App } from "./App";

const queryClient = new QueryClient();

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <QueryClientProvider client={queryClient}>
      <App />
    </QueryClientProvider>
  </StrictMode>,
);

现在 App 下的组件就可以使用 useQueryuseMutationuseQueryClient。对于普通纯客户端 React 应用,这已经是最小配置;SSR 框架会有各自的初始化方式,不在本文展开。

第二步:先写清接口函数和 queryKey#

新手最容易忽略 queryKey。它不是随手起的标签,而是 这份缓存数据的身份。只要请求结果会因某个参数改变,那个参数就应放进 key。

// src/todos.ts
export type Todo = {
  id: string;
  title: string;
  completed: boolean;
};

export type TodoFilter = "all" | "open" | "done";

const todoRoot = ["todos"] as const;

export const todoKeys = {
  all: todoRoot,
  list: (filter: TodoFilter) =>
    [...todoRoot, "list", { filter }] as const,
  detail: (id: string) => [...todoRoot, "detail", id] as const,
};

export async function fetchTodos(
  filter: TodoFilter,
  signal?: AbortSignal,
): Promise<Todo[]> {
  const response = await fetch(`/api/todos?filter=${filter}`, { signal });

  // fetch 在收到 HTTP 4xx / 5xx 时不会自动 reject。
  if (!response.ok) {
    throw new Error(`读取 Todo 失败:${response.status}`);
  }

  return response.json() as Promise<Todo[]>;
}

export async function createTodo(input: {
  title: string;
}): Promise<Todo> {
  const response = await fetch("/api/todos", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(input),
  });

  if (!response.ok) {
    throw new Error(`创建 Todo 失败:${response.status}`);
  }

  return response.json() as Promise<Todo>;
}

这里有两个关键点:

  • fetch 只有遇到网络层错误才会自动 reject;HTTP 400、500 仍会返回一个 ResponsequeryFn 必须抛错或返回 rejected Promise,TanStack Query 才能进入错误状态。
  • filter 改变了服务端返回的列表,所以它必须进入 todoKeys.list(filter)。如果所有筛选都使用 ['todos'],不同筛选条件会错误地共用同一份缓存。

queryKey 顶层必须是数组,并且应当可序列化、能唯一描述返回的数据。对象属性的书写顺序不影响 key;数组元素的顺序会影响 key。Query Keys Query Functions

第三步:用 useQuery 读取列表#

// src/TodoList.tsx
import { useQuery } from "@tanstack/react-query";
import {
  fetchTodos,
  todoKeys,
  type TodoFilter,
} from "./todos";

export function TodoList({ filter }: { filter: TodoFilter }) {
  const todosQuery = useQuery({
    queryKey: todoKeys.list(filter),
    queryFn: ({ signal }) => fetchTodos(filter, signal),
    staleTime: 30_000,
  });

  if (todosQuery.status === "pending") {
    return <p>正在加载 Todo</p>;
  }

  if (todosQuery.status === "error") {
    return <p role="alert">加载失败:{todosQuery.error.message}</p>;
  }

  return (
    <>
      {todosQuery.isFetching ? (
        <small>正在同步最新内容…</small>
      ) : null}

      <ul>
        {todosQuery.data.map((todo) => (
          <li key={todo.id}>
            {todo.completed ? "✅" : "⬜️"} {todo.title}
          </li>
        ))}
      </ul>
    </>
  );
}

此处的三个选项分别回答三个不同问题:

配置 / 字段它回答的问题
queryKey“我读的是哪一份数据?”
queryFn“实际怎样向服务器拿数据?”
staleTime: 30_000“拿到数据后的 30 秒内,通常把它当作新鲜数据。”

不要把 isPendingisFetching 当成同一个状态#

status 描述“是否已有可用结果”;fetchStatus 描述“请求函数此刻是否正在运行”。因此,已经成功显示列表时,后台仍可以再发一次请求。

画面isPendingisFetching应如何显示
首次进入,尚无列表truetrue整块 loading / skeleton
已有列表,正在后台更新falsetrue保留列表,显示小的“同步中”提示
已有列表,也没有请求falsefalse正常显示

这就是为什么示例里用 status === "pending" 处理首次 loading,却用 isFetching 在已有列表上方显示轻量提示。isLoading 在 v5 中表示“第一次请求正在进行”,等价于 isPending && isFetching;它在有条件查询时尤其容易与 isPending 混淆。Queries useQuery reference

第四步:用 useMutation 写入,并让列表重新同步#

读取远端数据用 Query;创建、更新、删除等会改变服务端数据的操作用 Mutation。Mutation 成功后,TanStack Query 不会凭空知道“哪些列表受到影响”,所以需要明确告诉它:相关的 key 已经可能过期。

// src/AddTodoForm.tsx
import { useState, type FormEvent } from "react";
import {
  useMutation,
  useQueryClient,
} from "@tanstack/react-query";
import { createTodo, todoKeys } from "./todos";

export function AddTodoForm() {
  const [title, setTitle] = useState("");
  const queryClient = useQueryClient();

  const createTodoMutation = useMutation({
    mutationFn: createTodo,
    onSuccess: async () => {
      setTitle("");

      await queryClient.invalidateQueries({
        queryKey: todoKeys.all,
      });
    },
  });

  function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();

    const trimmedTitle = title.trim();
    if (!trimmedTitle) return;

    createTodoMutation.mutate({ title: trimmedTitle });
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={title}
        onChange={(event) => setTitle(event.target.value)}
        placeholder="新增一个 Todo"
      />

      <button
        type="submit"
        disabled={createTodoMutation.isPending || !title.trim()}
      >
        {createTodoMutation.isPending ? "创建中…" : "创建"}
      </button>

      {createTodoMutation.isError ? (
        <p role="alert">
          创建失败:{createTodoMutation.error.message}
        </p>
      ) : null}
    </form>
  );
}

这里最值得理解的是这段:

await queryClient.invalidateQueries({
  queryKey: todoKeys.all,
});

它的含义不是“删掉 Todo 缓存后盲目重来”,而是:所有以 ['todos'] 开头的 Query(包括不同 filter 的列表和详情)都可能不再可信了。 TanStack Query 会把它们标为 stale;此刻正在页面中使用的匹配 Query 会在后台重新请求,未使用的则在下次被使用时再更新。

由于 onSuccess 返回并等待了这个 Promise,Mutation 会保持 pending,直到失效后的刷新完成。初学阶段,优先使用“写入成功 → invalidation → 重新读取”的模式;它比一开始就手动修改多份缓存或做 optimistic update 更容易保证正确性。Mutations Invalidations from Mutations

缓存只需要先理解两个时间:staleTimegcTime#

这两个名字很像,却回答不同问题。

名称它控制什么默认值不控制什么
staleTime成功数据被视为 fresh 多久0缓存何时删除
gcTime没有任何组件观察的 inactive Query 在内存保留多久5 分钟数据是否 fresh、是否立刻重新请求

stale 不等于“数据没了”#

默认 staleTime0,也就是成功数据会立刻被视为 stale。这里的 stale 不表示缓存被清空,更不表示 UI 必须闪回 loading;它只表示这份数据在合适的时机可以被重新验证。

默认情况下,stale Query 在以下情况会触发后台刷新:新的 observer 挂载、浏览器窗口重新获得焦点、网络恢复。若这会对你的接口造成不必要的请求,优先思考业务上数据多久可以接受为“足够新”,再设置 staleTime,例如:

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 30_000,
    },
  },
});

gcTime 才是“离开页面后保留多久”#

当最后一个使用同一 queryKey 的组件卸载后,Query 变成 inactive。默认情况下,它仍留在缓存中 5 分钟;期间如果用户返回页面,TanStack Query 可以先复用旧结果,再按 stale 规则决定是否更新。5 分钟后才有资格被垃圾回收。

所以,不要把 gcTime 当作新鲜度配置,也不要仅为了“看起来专业”随手改成很大的数。先根据列表切换、内存和数据新鲜度的实际需求调整 staleTime;只有确实需要改变离页缓存寿命时再考虑 gcTime

客户端 Query 失败时默认还会进行 3 次指数退避重试。以上都是默认行为,不必在第一个项目里全部改掉。Important Defaults

参数尚未准备好时:用 enabled,不要条件调用 Hook#

假设用户还没有选定 userId,此时不应请求该用户的项目。正确做法是始终调用 Hook,再用 enabled 声明查询的前置条件;不要写 if (userId) { useQuery(...) },那会违反 React Hooks 规则。

import { useQuery } from "@tanstack/react-query";

type Project = { id: string; name: string };

async function fetchProjects(
  userId: string,
  signal?: AbortSignal,
): Promise<Project[]> {
  const response = await fetch(`/api/users/${userId}/projects`, { signal });

  if (!response.ok) {
    throw new Error(`读取项目失败:${response.status}`);
  }

  return response.json() as Promise<Project[]>;
}

export function ProjectList({ userId }: { userId?: string }) {
  const projectsQuery = useQuery({
    queryKey: ["users", userId, "projects"],
    queryFn: ({ signal }) => {
      if (!userId) {
        throw new Error("userId 尚未准备好");
      }

      return fetchProjects(userId, signal);
    },
    enabled: Boolean(userId),
  });

  if (!userId) {
    return <p>请先选择用户。</p>;
  }

  if (projectsQuery.status === "pending") {
    return <p>正在读取项目…</p>;
  }

  if (projectsQuery.status === "error") {
    return <p role="alert">读取失败:{projectsQuery.error.message}</p>;
  }

  return (
    <ul>
      {projectsQuery.data.map((project) => (
        <li key={project.id}>{project.name}</li>
      ))}
    </ul>
  );
}

enabled: false 且没有缓存时,Query 的确会是 pending,但 fetchStatusidle:它不是网络慢,而是等待条件成立。这也是示例先判断 !userId 的原因。对于“条件暂未满足”的情况,enabled 很合适;把 Query 永久设为 false、再到处手动 refetch(),则会放弃自动刷新和 invalidation 带来的大部分好处。Disabling/Pausing Queries

写代码前,先用这张表判断该用什么#

需求首选为什么
页面初次读取用户、列表、详情useQuery声明式读取、缓存和请求状态
创建、修改、删除服务端数据useMutation表示有副作用的写操作
写入成功后让列表、详情正确更新invalidateQueries明确标记受影响的 Query 为 stale
输入框内容、筛选值、弹窗开关useState这些是本地 UI state,不是远端资源
缺少必要参数时暂不请求enabled不违反 Hooks 规则,条件满足后自动运行

只要你能回答“服务端哪一份数据变了,所以哪些 queryKey 需要失效”,大多数日常 TanStack Query 代码就已经有了清晰结构。

从 v4 文章迁移到 v5:先改这些,不要只做字符串替换#

仓库中已有的 《全面掌握 TanStack Query:现代 React 应用的数据管理利器》 是历史的 v4+ 参考。v5 最容易造成困惑的变化如下:

v4 常见写法 / 概念v5 对应写法 / 含义
useQuery(['todos'], fetchTodos, options)只支持对象形式:useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
useMutation(createTodo)useMutation({ mutationFn: createTodo })
cacheTime改名为 gcTime;只在 Query inactive 后决定何时清理,不决定数据新鲜度
status: 'loading'、旧 isLoadingstatus: 'pending'isPending
v5 的 isLoadingisPending && isFetching,表示首次请求确实在进行
useQueryonSuccess / onError / onSettled已移除;Mutation 的这些生命周期回调仍可使用

尤其要记住:gcTime 的改名不是 cosmetic change。它正是为了纠正过去 cacheTime 容易让人误解成“数据会缓存多久”的问题;数据是否该被视为新鲜,应该看 staleTimeMigrating to v5

初学者最常踩的五个坑#

  1. 把会影响请求结果的变量漏出 queryKey 例如按 filter 请求,却始终写 ['todos'];缓存就无法区分两种列表。
  2. 忘记检查 response.ok fetch 不会因 HTTP 500 自动进入 isError
  3. 把写操作也当 Query。 GET/读取用 Query;POST、PATCH、DELETE 等有副作用的操作用 Mutation。
  4. Mutation 成功后不失效相关 Query。 服务端已变,但页面缓存仍可能展示旧数据。
  5. 把 stale 当作 loading。 stale 数据通常仍能显示;isFetching 才表示请求正在跑。不要因此让整个页面反复闪成 skeleton。

暂时也不用急着学 optimistic update、Suspense、SSR 或无限列表。先用上面的闭环实现一两个真实页面:读列表、处理 loading/error、提交表单、按 key 失效列表。等这些判断变成直觉,再进入高级主题会轻松得多。

给下一篇文章的阅读路径#

最后用一句自检收尾:

这份数据的权威来源是不是服务端?如果是,先给它一个完整的 queryKey;如果要改它,就用 Mutation,并让受影响的 key 失效。

本文共 5308 字,上次修改于 Aug 12, 2026,以 CC 署名-非商业性使用-禁止演绎 4.0 国际 协议进行许可。

相关文章

» Convex + TanStack Query:useQuery 与 useSuspenseQuery 的选择、原理与场景

» Expo 技术原理与交付:从 React Native 项目到 EAS 发布

» Expo、React Native 与 Flutter:概念、架构、上架与选型

» React Native 技术原理:从 TypeScript 到原生界面、Fabric 与 Hermes

» Sentry 配置实践:前后端项目划分、Logs、Tracing、Source Maps 与 CLI 迁移