React 的 TanStack Query:缓存、重新获取数据与数据变更
用 TanStack Query 替代 useEffect 中的 fetch 标准代码:查询键、staleTime、gcTime、mutations,以及何时不需要该库。
异步服务器状态的缓存、刷新与更新方式——以及何时值得引入相关库。
许多 React 应用都采用相同的数据加载结构:一个 useEffect、一次 fetch 请求,以及几个用于处理待处理状态和错误界面的 useState 变量。这种模式在初步开发时是可行的,但也会导致重复请求、过时的界面以及大量复制粘贴的样板代码出现。
以下内容将逐一分析这些问题,并展示 TanStack Query 如何解决它们。所需的前提条件仅为 React 钩子函数和基础 TypeScript 知识。示例中使用的 DummyJSON 是一个无需 API 密钥的公共 API。
1. 基础模式
使用常规钩子函数编写的产品列表看起来是这样的。
import { useEffect, useState } from "react";
type Product = {
id: number;
title: string;
price: number;
};
function ProductList() {
const [products, setProducts] = useState<Product[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setIsLoading(true);
fetch("https://dummyjson.com/products?limit=10")
.then((res) => {
if (!res.ok) throw new Error("Request failed");
return res.json();
})
.then((data: { products: Product[] }) => {
if (!cancelled) setProducts(data.products);
})
.catch((err: Error) => {
if (!cancelled) setError(err.message);
})
.finally(() => {
if (!cancelled) setIsLoading(false);
});
return () => {
cancelled = true;
};
}, []);
if (isLoading) return <p>Loading…</p>;
if (error) return <p>{error}</p>;
return (
<ul>
{products.map((product) => (
<li key={product.id}>
{product.title} - ${product.price}
</li>
))}
</ul>
);
}
那个实现已经很谨慎了:它使用了cancelled标志,这样在组件卸载后即使响应缓慢也无法更新状态。很多实际项目却忽略了这一保护措施。
2. 该代码未处理的问题
获取数据本身的操作没有问题,问题出在相关处理流程上。
- 没有缓存机制。离开该页面后再返回时,即使数据内容未变,网络请求仍会再次发起。
- 没有请求去重功能。三个需要相同产品列表的组件会各自发送完全相同的请求。
- 没有重试机制。一次连接中断就会导致界面显示错误,即便再次尝试就能成功。
- 没有数据重新验证机制。一个标签页打开一小时,仍会持续显示一小时前的数据,直到有其他操作触发数据加载。
cancelled标志有助于处理卸载场景,但无法完全解决正在处理的请求重叠问题。每个问题都有已知的解决方案。手动实现这些解决方案意味着需要自行构建缓存层。
3. 该库的设计理念
可以将状态分为两类。
客户端状态由用户界面控制:比如模态框是否打开、当前的表单字段、选定的主题等。这些状态只有在代码主动修改时才会改变。useState正好适用于处理这类需求。
服务器状态是借来的。它存储在你无法控制的仓库中。其他用户可以更改它,而浏览器中的副本仅是快照而已。仅仅将这个快照保存在useState中,就相当于把临时视图当作权威数据。
借来的数据需要一个专用的存储机制:用于存放副本、记录有效期,以及设定何时再次获取数据的规则。
冰箱是个恰当的类比。牛奶放在家里,这样就不必为每杯咖啡都去商店购买,但它会有保质期,因此需要查看日期并在变质前补充。TanStack Query就是为API响应承担这一角色的工具。
4. TanStack Query是什么
TanStack Query用于管理浏览器应用中的异步远程状态。该项目采用MIT许可证,可免费使用,并广泛应用于各种React项目中。
多年前编写的指南中仍使用React Query这一名称。该名称一直沿用至3版本。在为Vue、Svelte、Solid和Angular开发出适配器后,4版本将项目名称改为TanStack Query。在React项目中仍需安装@tanstack/react-query,当前最新版本为5。
它不能替代fetch或axios,请求功能仍由你自己控制。该库负责处理与这个函数相关的定时、存储、数据新鲜度以及错误处理等问题。
5. 设置
只需两步即可开始使用。
npm install @tanstack/react-query
接着在根节点处将整个请求结构包裹起来:
// main.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App";
const queryClient = new QueryClient();
export default function Root() {
return (
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
);
}
QueryClient是缓存实例,而QueryClientProvider则将其暴露给所有子组件。
6. 重新编写的同一组件
import { useQuery } from "@tanstack/react-query";
type Product = {
id: number;
title: string;
price: number;
};
async function fetchProducts(): Promise<Product[]> {
const res = await fetch("https://dummyjson.com/products?limit=10");
if (!res.ok) throw new Error("Request failed");
const data: { products: Product[] } = await res.json();
return data.products;
}
function ProductList() {
const { data, isPending, isError, error } = useQuery({
queryKey: ["products"],
queryFn: fetchProducts,
});
if (isPending) return <p>Loading…</p>;
if (isError) return <p>{error.message}</p>;
return (
<ul>
{data.map((product) => (
<li key={product.id}>
{product.title} - ${product.price}
</li>
))}
</ul>
);
}
原本大约四十行的代码缩减到了十五行左右。data被直接定义为Product[],无需额外注释,因为其类型已由fetchProducts确定。在处理完isPending和isError这两种情况后,TypeScript会认为data已被定义,因此无需在map方法上使用可选链操作符,也不需要进行非空断言。
7. 简化版本的优势
与第2节中提到的不足相比,这些默认设置已经能够覆盖大多数常见场景:
- Remount会立即显示缓存的数据,并在后台重新验证。
- 相同的正在处理中的请求会被合并为单个请求。
- 失败的请求会自动重试(默认尝试三次,并带有延迟机制)。
- 过期的数据会在窗口获得焦点、网络重新连接或Remount时重新获取。
在重写的组件中并未对这些行为进行配置,它们属于库的默认设置。
8. 三件值得了解的内容
早期的许多困惑都源于以下三个概念。
查询键
queryKey 就是缓存地址。使用相同 ["products"] 的两个组件会共享同一个缓存条目及一次网络请求。
经验法则:**查询函数所依赖的每一个值都必须出现在键中。**
function ProductList({ category }: { category: string }) {
const { data } = useQuery({
queryKey: ["products", category],
queryFn: () => fetchProductsByCategory(category),
});
// …
}
如果键中未包含 category,更改分类后仍可能显示之前分类的缓存列表。这种错误在初次使用该功能的人中极为常见。
staleTime与gcTime
这两个名称听起来相似,但含义不同。
staleTime用于设置数据有效性窗口。在該窗口内,库会跳过网络请求操作。默认值为0,此时数据立即被视为过期:界面仍可显示缓存值,但任何触发事件都会启动后台刷新。当数据内容很少变化时,应提高该值:
useQuery({
queryKey: ["products"],
queryFn: fetchProducts,
staleTime: 5 * 60 * 1000, // fresh for five minutes
});
gcTime表示在最后一位订阅者取消连接后,未使用数据在内存中保留的时间。默认值为五分钟。当该时间到期后,相关数据将被删除,下次访问时需重新获取数据。
简而言之:staleTime控制数据的重新获取,而gcTime则控制数据的内存清除。
何时会重新获取数据
默认情况下,过期的查询会在组件加载时、窗口重新获得焦点时以及网络重新连接时重新获取数据。每种行为都可以在客户端或针对单个查询进行关闭:
useQuery({
queryKey: ["products"],
queryFn: fetchProducts,
refetchOnWindowFocus: false,
});
首次看到“焦点恢复时重新获取数据”功能时,很多人会感到意外。通常正是由于启用了该功能,长期打开的标签页才能保持最新状态。
9. 使用 useMutation 更改数据
useQuery 用于读取数据,而 useMutation 用于写入数据。
import { useMutation, useQueryClient } from "@tanstack/react-query";
type NewProduct = {
title: string;
price: number;
};
function AddProductButton() {
const queryClient = useQueryClient();
const { mutate, isPending } = useMutation({
mutationFn: async (product: NewProduct) => {
const res = await fetch("https://dummyjson.com/products/add", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(product),
});
if (!res.ok) throw new Error("Could not add product");
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["products"] });
},
});
return (
<button
onClick={() => mutate({ title: "New product", price: 25 })}
disabled={isPending}
>
{isPending ? "Saving…" : "Add product"}
</button>
);
}
关键所在是 invalidateQueries 这一行代码。它会将所有以 ["products"] 为前缀的缓存条目标记为过期,从而使已加载的观察者立即请求最新数据。无需手动修改本地数组,页面也不需要完全重新加载。
10. 开发工具
npm install @tanstack/react-query-devtools
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
当前正在开发中,该面板会列出所有的查询键、状态、负载数据以及最后一次获取时间。通过点击查看各项数据在“最新”与“过期”状态之间的变化,比单纯阅读更能快速理解缓存机制。该组件会自动从生产版本中移除。
常见错误
- 未将变量包含在查询键中。如果查询函数会读取某个值,那么查询键就必须包含该值。
- 将
useQuery放在useEffect内部。该钩子在渲染时就已经会执行,无需再额外“触发”它。 - 将该库用于纯客户端状态管理。表单字段和模态框相关状态应使用
useState来管理。 - 到处都设置
staleTime: Infinity。这样会关闭重新验证功能,从而丧失该机制的大部分优势。
data复制到本地状态中。这样就会存在两个副本,而已渲染的部分则不再跟踪缓存。12. 何时不需要它
如果应用在单个页面上仅访问一个接口,那么使用provider和hooks可能会显得过于繁琐。
如果框架已经提供了数据层——比如Next.js的服务器组件或带有加载器的路由器——那么部分工作就已经完成。TanStack Query仍可用于处理交互式的客户端数据获取,但并非必需。
对于永远不会离开浏览器的状态,应选择其他工具。
13. 接下来该学什么
日常开发工作已涵盖在上述概念中,更深入的主题可在其他地方学习:
- 官方文档——参考资料与交互式演示
- 分页列表与无限滚动列表——如需在滚动时加载更多页面,请使用
useInfiniteQuery - 等待其他操作完成的查询——通过设置
enabled来控制后续请求,直到前置操作完成为止 - 乐观式 UI——在数据变更响应返回之前就渲染预期的结果
请牢记一个核心原则:远程数据应存储在缓存中,而非临时组件状态中。有了这一理念作为指导,其余的 API 设计也会显得合乎逻辑。