如果你跟我一样,这几年在前端项目里反复写 loading、error、data 三件套,那你大概率也经历过这种场景:页面越做越多,接口请求的重复代码越来越多,最后每个组件里都塞满了一堆 useState、useEffect 和 axios 调用。一开始我觉得这只是"业务需要",后来换了几次项目、接手过几个老系统,才意识到问题出在根上——我们一直在手动管理服务器状态,而这件事根本不该由业务组件来操心。
TanStack Query 解决的就是这件事。它不是为了替代 axios,而是把"接口数据的获取、缓存、更新、失效"这些和 UI 无关的脏活累活都接管过去。我最近把一个老项目的数据层整体换成了 TanStack Query,改造完之后,代码量少了一大截,接口重复请求消失,页面切换也不再有那种"闪一下 loading"的廉价感。
这篇文章就从实际使用出发,讲讲我为什么推荐前端项目都该用 TanStack Query,以及你在接入时会遇到的关键点、坑位和实操方案。如果你正被接口状态管理折磨,或者想给下一个项目选个靠谱的方案,这篇应该能帮你省不少时间。
1. 传统请求管理方式的痛点:为什么"手动管接口"撑不住项目变大
1.1 每个页面都在重复造轮子
现在的接口请求,绝大多数还是"拿数据"和"改数据"两类。拿数据要处理加载中、空数据、报错、重试;改数据要处理提交中、成功提示、失败回滚。这两类逻辑在每个具体页面里长得都差不多,但因为没有统一的抽象,大家只能每个页面复制一份。
我见过很多代码长这样:
const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); useEffect(() => { setLoading(true); fetchOrderList(params) .then((res) => setData(res)) .catch((err) => setError(err)) .finally(() => setLoading(false)); }, [JSON.stringify(params)]);这套写法的第一个问题就是组件一多,状态就被拆得七零八落。尤其是列表页加详情页再加一个编辑弹窗,好几处都要用到同一份订单数据,你没法保证它们拿到的数据是同一个版本。为了"同步",很多人开始把数据塞进全局 store,然后专门写 action 去触发请求,再写 reducer 维护三个字段的状态。store 越写越胖,一个接口动辄牵扯四五个文件,最后维护成本高得离谱。
1.2 自定义 axios 封装救不了"状态"
有团队意识到问题后,会选择封装一层 useRequest 之类的自定义 Hook,把 loading、error、data 收敛到一个函数里。这个方向是对的,但做起来很难做好。因为你会发现,光有请求还不够,你还需要解决下面这些问题:
- 两个组件同时用到同一份数据,要不要合并请求?合并了之后怎么通知两边同时更新?
- 用户切走页面再切回来,是重新请求还是用旧缓存?
- 编辑成功之后,哪些相关查询需要失效并自动刷新?
- 轮询接口怎么实现才不浪费流量?
- 数据长时间不用,内存里的缓存什么时候清掉?
这些问题听着不起眼,一旦项目上规模,每一个都是坑。我见过自己封装请求 Hook 的团队,一开始很爽,后来为了支持缓存和并发去重,在内部加了一个 Map 存 promise,又为了支持失效更新,加了一套中心化的事件通知。说白了,就是在重新发明轮子——而且轮子还未必比 TanStack Query 圆。
1.3 "服务器状态"和"客户端状态"从头就没分清楚
传统的开发习惯里,我们把接口返回的数据和用户输入的临时数据混在一套 useState 里管理。但这两类数据本质上是不同的:客户端状态是即时的、可变的,比如弹窗开没开、表单填到哪一步;服务器状态则是异步的、共享的,而且它有一个重要特征——会过期。
你无法完全掌握服务器端的数据什么时候变了,所以本地这份"拷贝"天然就是不可信的。TanStack Query 的逻辑就是专门管理这类"不可信的、异步的、共享的服务器状态"。它把缓存、过期时间、请求失败重试、窗口聚焦重新获取这些机制全部内置,而开发者只需要告诉它"我的数据从哪儿来"。
2. 核心概念拆解:queryKey、缓存与状态机的正确理解
2.1 把接口数据看作"缓存"而不是"变量"
想用好 TanStack Query,最重要的是心态上的转变。以前写代码,接口返回的数据是某个组件的私有财产,现在要把它理解成一份全局缓存——任何组件都能访问,只要 key 一样,拿到的就是同一份数据。
这个思路和数据库的缓存层很像。TanStack Query 内部会维护一个查询缓存池,每个查询由 queryKey 唯一标识。当你调用useQuery时,它会先去缓存池里找有没有对应的数据;如果没有,就执行 queryFn 发起请求;如果有,并且没过期,就直接把缓存返回给组件。
我的经验是把 queryKey 当作"接口 + 参数"的天然序列化。比如用户信息接口可以写成['user', userId],订单列表可以写成['orders', { page: 1, status: 'done' }]。注意对象会被自动序列化,所以参数的顺序无关紧要,但内容变了 key 就会变,这是后面做缓存拆分的基础。
2.2 staleTime 和 gcTime:两个被默认值坑过的参数
这两个参数是 TanStack Query 里最重要的配置,也是最容易被忽视的。简单说:
- staleTime 表示数据从"新鲜"变为"过期"的时长。在过期之前,任何组件读取都会直接用缓存,不会发请求。
- gcTime 表示数据从不再被使用到真正从内存里清除的时长。默认是 5 分钟。
默认情况下 staleTime 是 0,也就是说数据被拿到手的那一刻就已经过期了。一旦有过期数据存在,组件重新挂载或者窗口重新聚焦时就会触发后台重新请求。对很多项目来说,这个默认行为反而会造成不必要的频繁请求。
我实际项目里一般会根据业务设置 staleTime。比如用户基本信息这类低频变化数据设 5 分钟,订单列表这种中等频率的数据设 30 秒,只有实时性要求高的才设为 0。设置之后,你会发现无意中少了一大批请求,页面切换也不会再疯狂转 loading。
注意:staleTime 设大不代表数据永远不更新。手动调用 invalidateQueries 或者 refetch 时,照样可以强制刷新。它只是控制"自动重新请求"的触发条件。
2.3 isLoading 与 isFetching 的区别,很多人一直用错
接触 TanStack Query 之后,最先要抛弃的一大习惯就是"所有请求都看 loading"。它区分了两个非常关键的布尔值:
- isFetching:任何一次请求正在进行中。包括后台静默刷新、分页加载、手动 refetch。
- isLoading:当前没有任何数据,并且正在首次加载。
举个例子:一个列表页已经加载过第一页了,用户点击翻页。此时 isFetching 是 true,但如果数据还没拿到手之前,你难道要把整个页面变成空白 loading 吗?显然不合理。正确做法是保留旧数据,只在局部显示加载指示器。
这背后的设计哲学是:服务器状态在大部分时间都有"上一次成功的数据"可以使用。既然有旧数据,就没必要让用户面对空白。TanStack Query 用 placeholderData 或者 keepPreviousData 来做这件事,具体细节下面实战部分会展开。
3. 实战案例:把一个真实项目的数据层接入 TanStack Query
3.1 五步搭好基础环境
我用 React 项目举例,Vue 项目用 @tanstack/vue-query,思路完全一样。第一步是安装依赖:
npm i @tanstack/react-query第二步创建全局 QueryClient 并设置默认参数。我习惯把重试次数、缓存时间、请求失败回调都放在这一层统一配置:
import { QueryClient } from '@tanstack/react-query'; export const queryClient = new QueryClient({ defaultOptions: { queries: { retry: 2, staleTime: 30 * 1000, gcTime: 5 * 60 * 1000, refetchOnWindowFocus: false, }, }, });第三步在应用入口用 QueryClientProvider 包裹:
import { QueryClientProvider } from '@tanstack/react-query'; createRoot(document.getElementById('root')!).render( <QueryClientProvider client={queryClient}> <App /> </QueryClientProvider> );第四步写一个最小的查询组件:
function UserInfo({ userId }: { userId: string }) { const { data, isLoading, error } = useQuery({ queryKey: ['user', userId], queryFn: () => fetchUser(userId), }); if (isLoading) return <div>加载中...</div>; if (error) return <div>加载失败</div>; return <div>{data.name}</div>; }第五步,把之前 useEffect 里手动拉数据的代码全部删掉。这句很关键——只要你接入了 TanStack Query,页面里 90% 的使用Effect拉接口的代码都不需要了。
3.2 分页列表的缓存与预取:让翻页不再白屏
分页列表是管理后台最常见的场景。传统写法每次翻页都把 data 清空,重新走后一遍 loading。用 TanStack Query 之后,你可以让"上一页的数据"留在界面上,直到新数据到达:
const [page, setPage] = useState(1); const { data, isFetching, isPlaceholderData } = useQuery({ queryKey: ['projects', page], queryFn: () => fetchProjects(page), placeholderData: keepPreviousData, });这里的placeholderData: keepPreviousData是一个很实用的 API:当 queryKey 从['projects', 1]变成['projects', 2]的瞬间,因为第二页还没有缓存,useQuery 会把第一页的数据作为占位数据返回给你,同时 isPlaceholderData 为 true。这样界面不会闪白,你可以在表格顶部显示一个细小的进度条,提示用户"正在加载下一页"。
配合预取,体验还能再上一个台阶。用户在第一页停留的时候,就可以提前把第二页请求出来:
const queryClient = useQueryClient(); useEffect(() => { void queryClient.prefetchQuery({ queryKey: ['projects', page + 1], queryFn: () => fetchProjects(page + 1), }); }, [page, queryClient]);用户点下一页时,数据早就躺在缓存里了,页面几乎是瞬间渲染,完全感觉不到网络延迟。这个效果用传统的分页组件实现需要写很多协调代码,而 TanStack Query 这边只需要十几行。
3.3 乐观更新:让操作快人一步
乐观更新指的是:在接口还没返回时,先在本地把 UI 改成"成功后"的状态,等接口真正成功后再把服务端数据同步回来;如果接口失败,就回滚到之前的状态。最典型的场景是点赞、收藏、修改标题。
用 useMutation 实现非常顺:
const queryClient = useQueryClient(); const mutation = useMutation({ mutationFn: (newTitle: string) => updateProjectTitle(projectId, newTitle), onMutate: async (newTitle) => { await queryClient.cancelQueries({ queryKey: ['project', projectId] }); const previous = queryClient.getQueryData(['project', projectId]); queryClient.setQueryData(['project', projectId], (old) => ({ ...old, title: newTitle, })); return { previous }; }, onError: (_err, _newTitle, context) => { queryClient.setQueryData(['project', projectId], context.previous); }, onSettled: () => { queryClient.invalidateQueries({ queryKey: ['project', projectId] }); }, });这里我解释一下每一步在干嘛。onMutate 里先取消可能正在进行的请求,防止旧的响应把乐观更新覆盖掉;然后取出之前的缓存,再把新的标题写入缓存;如果失败,onError 里把旧数据写回去;最后不管成功失败,onSettled 里都要让这个 key 对应的查询失效,触发一次后台重新拉取,保证和服务端最终一致。
注意:乐观更新适合用户体验要求高、失败概率低的操作。像删除订单这种高风险操作,还是要等接口返回成功后再更新数据。
3.4 轮询、重试与手动刷新:配置项用好就是效率翻倍
实时性要求高的页面,比如大屏监控、待办数量,可以用 refetchInterval 做轮询。不需要自己 setInterval,它自己会处理清理和生命周期:
useQuery({ queryKey: ['todoCount'], queryFn: fetchTodoCount, refetchInterval: 5000, });请求失败时,默认会重试 3 次。我建议在网络环境复杂的产品里把重试次数和间隔重新配置一下:
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: (failureCount, error) => { if (error instanceof HttpError && error.status >= 400 && error.status < 500) { return false; } return failureCount < 3; }, retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30000), }, }, });4xx 的请求错误基本是参数或权限问题,再怎么重试也不会成功,不如直接返回。5xx 和网络错误才值得重试,而且用指数退避的方式避免把服务端打崩。至于手动刷新,一个 mutation 或 refetch 方法就搞定了,不需要额外写状态。
4. 选型决策与团队协作:为什么说这不只是一个请求库
4.1 它接管了状态管理里最麻烦的那部分
不少人一听"接口请求管理",第一反应是"我直接用 axios 就行"。但 TanStack Query 的定位比"请求库"高一层。它管的是数据从"请求"到"缓存"到"展示"到"更新"的整个生命周期。
我以前也用过 Redux 管理接口数据,那滋味很酸爽:要在 store 里定义 state、action、reducer、dispatch,还要处理异步的 thunk/saga。数据一多,查一个 bug 要沿着一整条链路点半天。而 TanStack Query 的思路是"配置式"的:声明 queryKey 和 queryFn,剩下交给它处理。这大幅减少了状态管理的心智负担——大部分接口数据其实没那么多全局共享的需求,根本不需要进 store。
把两个方案摆在团队里比较,新成员上手成本也非常明显。TanStack Query 的写法几乎没有"内部规范"的空间,照着文档写就行。而自研方案往往依赖项目里约定的一堆 store 目录、action 文件命名规则,新人踩坑率很高。
4.2 Devtools 是排查问题的神器
TanStack Query 提供了官方的开发者工具,可以直接查看所有缓存的查询、状态、请求耗时、提交和请求频率。
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; <QueryClientProvider client={queryClient}> <App /> <ReactQueryDevtools /> </QueryClientProvider>我看缓存时最常用的几个信息:
- 状态:fresh / fetching / stale / inactive,能直观看到哪些数据是新的、哪些过期了。
- 请求耗时:定位慢接口很好用。
- 查询键树:展开之后能清楚看到 key 的结构,检查有没有写错。
- 缓存数据区:直接看内存里存的值长什么样,不用打 console.log。
在开发环境开着 Devtools,排查接口问题效率会高出不少。某种程度上,这也倒逼项目把接口数据的 CRUD 做得更规范。
4.3 什么场景不适合用 TanStack Query
虽然我非常推荐,但也不是所有项目都该无脑上。我帮你们划一下边界:
- 纯 SSR 页面、几乎没有交互的小型官网:用 React Query 反而引入不必要的依赖。
- 实时双向通信场景,比如聊天室、实时白板、协同编辑:这些本质是 websocket 推送,不是"客户端发起的请求-响应"模式,TanStack Query 帮不上忙。
- 极简的组件内单次请求:如果页面只有一两个接口,也不存在跨组件共享,手动 useRequest 就够了。
绝大多数中后台管理系统、B 端产品、带列表详情编辑流程的应用,都属于"非常合适用"的范畴。如果你正在做这类项目,早点接入是在节省时间。
5. 常见问题与排查技巧实录
5.1 接口数据更新了,但页面就是不刷新
这是刚上手时最常撞的坑。原因基本是缓存没失效。TanStack Query 不会在每次接口数据变化时自动感知,它只会根据 queryKey 和 staleTime 决定要不要重新请求。
解决方案是在 mutation 成功之后主动让相关查询失效:
const mutation = useMutation({ mutationFn: createOrder, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['orders'] }); }, });这样所有以['orders']开头的查询都会标记为过期,重新挂载或后台自动刷新时就会拉取新数据。如果你设置了 staleTime 很长,又要立即刷新,可以使用 refetch:
await queryClient.refetchQueries({ queryKey: ['orders'] });5.2 接口被重复请求,跟预期不一样
TanStack Query 在同一时间对同一 queryKey 的请求会自动合并,也就是说 10 个组件同时调用同一个查询,只会发一次请求。但如果你真看到重复请求,先检查是不是不小心把 queryKey 写成了不同值。
比如订单列表,有人写成['orders'],有人写成['orderList'],还有人写['orders', undefined],这三个 key 会被当成三个完全不同的缓存,自然就会发多次请求。我习惯把接口 URL 作为 key 的第一段,参数作为第二段,整个项目统一这个规则,基本能避免这类问题。
另外,queryKey 里如果有对象,TanStack Query 会做深比较。如果你在 render 里临时创建了一个新对象字面量,而且字段每次都不一样(比如无意义的 Date.now()),那么 key 永远不稳定,会导致无限循环请求。这是最容易排查不出来的 bug 之一,我踩过一次,后来写 queryKey 就会刻意避免放不稳定的字段。
5.3 v4 和 v5 的差异,升级前先看两眼
如果你之前用的是 React Query v3/v4,升级到 TanStack Query v5 有一些破坏性变更。这里列几个我在升级时踩过的主要差异:
| 版本差异 | v3/v4 | v5 |
|---|---|---|
| 包名 | react-query | @tanstack/react-query |
| useQuery 回调 | onSuccess 可以直接在 useQuery 里写 | useQuery 上的 onSuccess 已废弃 |
| 分页占位 | keepPreviousData 单独导出传入 options | 从 @tanstack/react-query 导入 keepPreviousData 函数传入 placeholderData |
| useMutation 回调 | 同样支持 onSuccess | onSuccess 仍然支持,但建议用 onSettled |
v5 把选项统一为对象式传参,对 TypeScript 的推断也更友好。如果新项目直接用 v5 就好,老项目升级时重点检查 useQuery 的 onSuccess 和 keepPreviousData 这两处改动。
5.4 一个速查表:被问得最多的问题及解决方案
| 现象 | 原因 | 解决方案 |
|---|---|---|
| 页面一直显示旧数据 | staleTime 太长 | 缩短 staleTime,或手动 invalidateQueries / refetch |
| 接口疯狂重复请求 | queryKey 不稳定或包含变化值 | 排查 queryKey 内容,确保稳定可序列化 |
| 多个组件重复发同类请求 | queryKey 写法不统一 | 统一 key 规则,或检查 key 参数是否一致 |
| 切换页面后 loading 闪烁 | 未使用缓存占位 | 使用 placeholderData 或 keepPreviousData |
| 请求失败后一直无提示 | retry 次数太多 | 配置 retry 逻辑,4xx 不重试并统一报错处理 |
| 组件卸载后还在发请求 | useEffect 里手动请求未取消 | 删除手动 useEffect 请求代码,交给 useQuery 管理 |
按这套排查逻辑,基本能覆盖日常使用中 90% 的问题。剩下的,大概率就是某个参数确实设置得和业务预期不匹配,调整配置就能解决。
在我自己把新项目的数据层切换到 TanStack Query 之后,最大的感受不是"少写了多少代码",而是整个团队对"数据什么时候更新"这件事的认知被拉齐了——不需要再靠人脑维护一份 store 状态流转,也省去了大量和 loading 状态较劲的时间。哪怕你短期内只拿它管理一两个列表页,长期看也值。如果正在规划下一个前端项目,我建议给它一次机会,用一周时间做个小的并行试点,应该很快就能体会到差距。