你的状态管理有一半在帮倒忙——试试把异步数据全交给 TanStack Query

我们总在重复造一个叫“请求管理”的轮子,而且造得稀烂。

打开一个典型的中型 React 项目,useEffect 里躺着 fetch,外面包着三层 useState——dataloadingerror。每个页面都来一套,稍微复杂点的场景还要手动处理缓存失效、请求去重、窗口聚焦刷新、乐观更新……写到最后,状态管理库(不管是 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 更简洁,isLoadingisFetching 的语义也更清晰——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,失败了再回滚。手写这个逻辑大约需要:

  1. 保存旧数据快照
  2. 立即更新缓存
  3. 发请求
  4. 成功就保留,失败就用快照恢复
  5. 处理并发情况(连续点两次怎么办)

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 里有 userscurrentUserpostscomments 四坨服务端数据,每个都配了 loadingerror 字段,还有各种 FETCH_USERS_REQUESTFETCH_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 设为 !!userIduserId 没拿到之前它不会执行。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-devtoolsprocess.env.NODE_ENV === 'production' 时整个模块都会被 tree-shaking 掉,最终 bundle 里不留痕迹。

什么时候不该用 TanStack Query?

WebSocket 实时数据、纯客户端状态、跟服务端完全无关的数据流。不过 TanStack Query 也支持 subscription 模式,你可以用 queryClient.setQueryData 从 WebSocket 推送更新缓存,所以其实连实时数据也能部分接管。另外就是小而美的脚本或 demo 页面,一个 fetch 就够,没必要引入 11KB 的依赖。