TanStack Query v5 基础:从 Server State 到 Query、Mutation 与缓存
8月 12, 2026
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-queryv5 的初学者。它刻意不从 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@^5TanStack 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 下的组件就可以使用 useQuery、useMutation 和 useQueryClient。对于普通纯客户端 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 仍会返回一个Response。queryFn必须抛错或返回 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 秒内,通常把它当作新鲜数据。” |
不要把 isPending 和 isFetching 当成同一个状态#
status 描述“是否已有可用结果”;fetchStatus 描述“请求函数此刻是否正在运行”。因此,已经成功显示列表时,后台仍可以再发一次请求。
| 画面 | isPending | isFetching | 应如何显示 |
|---|---|---|---|
| 首次进入,尚无列表 | true | true | 整块 loading / skeleton |
| 已有列表,正在后台更新 | false | true | 保留列表,显示小的“同步中”提示 |
| 已有列表,也没有请求 | false | false | 正常显示 |
这就是为什么示例里用 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
缓存只需要先理解两个时间:staleTime 与 gcTime#
这两个名字很像,却回答不同问题。
| 名称 | 它控制什么 | 默认值 | 不控制什么 |
|---|---|---|---|
staleTime | 成功数据被视为 fresh 多久 | 0 | 缓存何时删除 |
gcTime | 没有任何组件观察的 inactive Query 在内存保留多久 | 5 分钟 | 数据是否 fresh、是否立刻重新请求 |
stale 不等于“数据没了”#
默认 staleTime 是 0,也就是成功数据会立刻被视为 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,但 fetchStatus 是 idle:它不是网络慢,而是等待条件成立。这也是示例先判断 !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'、旧 isLoading | status: 'pending'、isPending |
v5 的 isLoading | isPending && isFetching,表示首次请求确实在进行 |
useQuery 的 onSuccess / onError / onSettled | 已移除;Mutation 的这些生命周期回调仍可使用 |
尤其要记住:gcTime 的改名不是 cosmetic change。它正是为了纠正过去 cacheTime 容易让人误解成“数据会缓存多久”的问题;数据是否该被视为新鲜,应该看 staleTime。Migrating to v5
初学者最常踩的五个坑#
- 把会影响请求结果的变量漏出
queryKey。 例如按filter请求,却始终写['todos'];缓存就无法区分两种列表。 - 忘记检查
response.ok。fetch不会因 HTTP 500 自动进入isError。 - 把写操作也当 Query。 GET/读取用 Query;POST、PATCH、DELETE 等有副作用的操作用 Mutation。
- Mutation 成功后不失效相关 Query。 服务端已变,但页面缓存仍可能展示旧数据。
- 把 stale 当作 loading。 stale 数据通常仍能显示;
isFetching才表示请求正在跑。不要因此让整个页面反复闪成 skeleton。
暂时也不用急着学 optimistic update、Suspense、SSR 或无限列表。先用上面的闭环实现一两个真实页面:读列表、处理 loading/error、提交表单、按 key 失效列表。等这些判断变成直觉,再进入高级主题会轻松得多。
给下一篇文章的阅读路径#
- 官方入门:Quick Start、Queries、Query Keys。
- 缓存策略:Important Defaults、Query Invalidation。
- 已掌握本文闭环后,再阅读 Convex + TanStack Query:useQuery 与 useSuspenseQuery 的选择、原理与场景。那篇讨论的是实时数据、Suspense 与 SSR 的进阶选择,不是本篇的前置知识。
最后用一句自检收尾:
这份数据的权威来源是不是服务端?如果是,先给它一个完整的
queryKey;如果要改它,就用 Mutation,并让受影响的 key 失效。