你的状态管理有一半在帮倒忙——试试把异步数据全交给 TanStack Query
我们总在重复造一个叫“请求管理”的轮子,而且造得稀烂。
打开一个典型的中型 React 项目,useEffect 里躺着 fetch,外面包着三层 useState——data、loading、error。每个页面都来一套,稍微复杂点的场景还要手动处理缓存失效、请求去重、窗口聚焦刷新、乐观更新……写到最后,状态管理库(不管是 Redux、Zustand 还是 Jotai)里塞了一半以上的代码在处理服务端数据。这不是状态管理,这是状态搬砖。
把异步数据丢给 TanStack Query(也就是曾经的 React Query),你的客户端状态会瞬间瘦身 70%。剩下的那些真正属于 UI 的状态——弹窗开关、表单草稿、侧栏折叠——才值得你用 Zustand 或 Context 去管。
服务端数据根本不是你的“状态”
你在页面上展示的用户列表,所有权在数据库里,不在浏览器的内存里。你只是暂时借来看一眼,过期了就得还。
但我们写代码的时候老把这件事忘了。拿到数据后 setUsers(data),存进 store,然后这个 store 就成了一个过时数据的温床。用户切到另一个 tab 改了条记录再切回来,页面还美滋滋地展示着旧数据。你写了 useEffect 监听 visibilitychange 去重新请求?恭喜你,你已经在手动实现 React Query 1% 的功能了。
TanStack Query 的核心洞察就一句话:服务端状态是“缓存”,不是“状态”。它用 staleTime 决定数据多久算过期,用 gcTime(v5 之前叫 cacheTime)决定没人用之后多久清掉。你不再拥有数据,你只是订阅了数据的某个版本。
// 过去:我拥有这份数据,我负责维护它
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch('/api/users')
.then(res => res.json())
.then(data => { setUsers(data); setLoading(false); })
.catch(err => { setError(err); setLoading(false); });
}, []);
// 现在:我只是订阅了 users 这个缓存键
const { data: users, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then(res => res.json()),
staleTime: 30_000, // 30 秒内不重新请求
});
少了两行 useState,少了一个 useEffect,少了一整个心智负担。而且 React Query 在背后帮你做了请求去重——同一页面上三个组件同时调用 useQuery({ queryKey: ['users'] }),只会发一次请求。
你那些引以为傲的“优化”,它默认就做了
翻翻你项目里手动写的请求逻辑,大概率有这些代码:
- 请求中显示 loading,请求完隐藏——这个叫
isLoading - 请求失败显示错误提示,点重试——这个叫
error+refetch - 切回页面自动刷新数据——这个叫
refetchOnWindowFocus - 网络恢复后自动重试——这个叫
refetchOnReconnect - 同一个接口别重复请求——这个叫请求去重
你一行一行手写,测试,修 bug,上线后发现忘记处理 race condition(先发的请求后返回,把后发的请求数据覆盖了)。TanStack Query 在 2019 年就把这些全搞定了,而且 v5 版本 API 更简洁,isLoading 和 isFetching 的语义也更清晰——isLoading 是“首次加载且没有缓存数据”,isFetching 是“正在发请求”,不管有没有缓存。
一个我亲身踩过的坑:后台管理系统的订单列表,用户在搜索框快速输入,每次输入都触发请求。手写 debounce 当然可以,但更恶心的是如果第一个请求卡了 3 秒才返回,会覆盖掉后面已经返回的最新结果。TanStack Query 的 queryKey 机制天然解决这个问题——['orders', searchText],每次搜索词变化都是一个新的 query,旧 query 的返回结果直接被忽略。
const [search, setSearch] = useState('');
const { data, isFetching } = useQuery({
queryKey: ['orders', search],
queryFn: () => fetch(`/api/orders?q=${search}`).then(res => res.json()),
placeholderData: keepPreviousData, // v5 中是 placeholderData 而非 keepPreviousData 选项
});
placeholderData: keepPreviousData 让搜索过程中始终显示上一次的数据,而不是闪烁 loading 骨架屏。这个体验细节,手写至少 50 行代码。
把 mutation 也交出去,别自己维护“乐观更新”的噩梦
查询数据只是上半场。修改数据——POST、PUT、DELETE——才是真正让人掉头发的地方。
标准的做法:用户点了“点赞”按钮,你 await fetch(...),成功后 setLiked(true)。但网络慢的时候用户点完没反应,又点一下,你还要加防抖或者 disabled 状态。更高级的玩法是“乐观更新”——先假设请求成功,立刻更新 UI,失败了再回滚。手写这个逻辑大约需要:
- 保存旧数据快照
- 立即更新缓存
- 发请求
- 成功就保留,失败就用快照恢复
- 处理并发情况(连续点两次怎么办)
TanStack Query 的 useMutation 把这件事变成了配置项:
const likeMutation = useMutation({
mutationFn: (postId: string) => fetch(`/api/posts/${postId}/like`, { method: 'POST' }),
onMutate: async (postId) => {
// 取消正在进行的查询,防止旧数据覆盖乐观更新
await queryClient.cancelQueries({ queryKey: ['post', postId] });
// 保存快照
const previousPost = queryClient.getQueryData(['post', postId]);
// 乐观更新
queryClient.setQueryData(['post', postId], (old) => ({
...old,
liked: true,
likesCount: old.likesCount + 1,
}));
// 返回快照供 onError 使用
return { previousPost };
},
onError: (err, postId, context) => {
// 回滚
queryClient.setQueryData(['post', postId], context.previousPost);
},
onSettled: (postId) => {
// 无论成功失败,最终重新获取服务端数据确保一致性
queryClient.invalidateQueries({ queryKey: ['post', postId] });
},
});
onMutate 里做乐观更新,onError 里回滚,onSettled 里失效缓存强制重新获取。三个回调,20 行代码,覆盖了所有边界情况。而且 useMutation 自带的 isPending 状态可以直接绑到按钮的 disabled 上,用户狂点也没事——同一个 mutation 在 pending 期间不会重复触发。
客户端状态终于可以只做客户端的事了
把异步数据全部迁走之后,你的 Zustand store(或者你用的任何状态管理)会变得异常清爽。它只关心:
- 这个弹窗现在是打开还是关闭
- 用户正在编辑的表单内容(还没提交的那种)
- 侧栏折叠状态
- 主题偏好
这些状态的特征是:完全在浏览器里产生,完全在浏览器里消亡,跟服务端没有半毛钱关系。它们不需要缓存策略,不需要失效时间,不需要重试机制。
我最近重构了一个项目的 store,删掉了 200 多行跟请求相关的 reducer 和 action,剩下的代码不超过 80 行。之前那个 store 里有 users、currentUser、posts、comments 四坨服务端数据,每个都配了 loading 和 error 字段,还有各种 FETCH_USERS_REQUEST、FETCH_USERS_SUCCESS 这种 action type。现在这些全没了,取而代之的是五个 useQuery 和三个 useMutation,各自管自己的事。
一个实际的数据:重构前 bundle 里状态管理相关代码(含请求处理)占 14KB minified,重构后 Zustand 部分只剩 3.2KB,TanStack Query 本身 11KB。总大小没变太多,但代码清晰度完全不在一个量级。
Query Key 设计是唯一需要动脑的地方
TanStack Query 的缓存机制全靠 queryKey。key 设计得烂,缓存失效就会变成灾难。几个我踩过的坑和总结的规则:
把最具体的参数放前面。['posts', postId, 'comments', commentId] 而不是 ['comments', commentId, 'posts', postId]。这样你可以在 ['posts'] 这个层级一次性失效所有跟 posts 相关的查询。
用常量管理 key 字符串。别在十个文件里散落着 ['users'],万一改成 ['accounts'] 你得全局搜索。搞一个 queryKeys 对象:
export const queryKeys = {
users: {
all: ['users'] as const,
detail: (id: string) => ['users', id] as const,
posts: (userId: string) => ['users', userId, 'posts'] as const,
},
posts: {
all: ['posts'] as const,
detail: (id: string) => ['posts', id] as const,
},
};
as const 确保 TypeScript 推导出字面量类型而非 string[],方便后续做类型体操。
失效粒度要匹配数据结构。新增一条评论后,你只需要 invalidateQueries({ queryKey: ['posts', postId, 'comments'] }),而不是粗暴地 invalidateQueries({ queryKey: ['posts'] }) 把整个帖子详情也干掉了。精确失效减少不必要的请求,用户感知到的就是“快”。
常见问题
我项目里已经有 Redux/Zustand 了,迁到 TanStack Query 要全量替换吗?
不需要。两者管的是不同层面的东西。你可以在一个组件里同时用 useQuery 拿服务端数据和 useStore 拿 UI 状态。迁移策略是渐进式的:新功能直接用 TanStack Query,旧代码每次改到相关模块时顺手迁掉。我最近的一个项目就是这样干了三个月,Redux store 体积缩减了 60%,没有一天停摆。
服务端数据之间有依赖关系怎么办?比如先拿用户 ID 才能查他的订单?
TanStack Query 有个 enabled 选项专门干这个。第二个查询的 enabled 设为 !!userId,userId 没拿到之前它不会执行。v5 里类型推导也更好了,如果 enabled 为 false 时访问 data,TypeScript 会知道它可能是 undefined。
const { data: user } = useQuery({ queryKey: ['user', userId], queryFn: ... });
const { data: orders } = useQuery({
queryKey: ['orders', user?.id],
queryFn: () => fetch(`/api/users/${user.id}/orders`),
enabled: !!user?.id,
});
TanStack Query 的 devtools 在生产环境会打包进去吗?
不会,只要你按文档推荐的动态导入方式。@tanstack/react-query-devtools 在 process.env.NODE_ENV === 'production' 时整个模块都会被 tree-shaking 掉,最终 bundle 里不留痕迹。
什么时候不该用 TanStack Query?
WebSocket 实时数据、纯客户端状态、跟服务端完全无关的数据流。不过 TanStack Query 也支持 subscription 模式,你可以用 queryClient.setQueryData 从 WebSocket 推送更新缓存,所以其实连实时数据也能部分接管。另外就是小而美的脚本或 demo 页面,一个 fetch 就够,没必要引入 11KB 的依赖。