TanStack Query 实战:一个「收藏星标」的乐观更新
收藏功能几乎是每个内容产品的标配,但它恰恰是「手写 fetch 最容易写脏」的一类交互:点一下,UI 要立刻翻转,网络请求在后台跑,失败还得翻回去。这背后其实藏着一串问题——loading 怎么禁按钮?等网络还是先反馈?失败怎么恢复?详情页、列表页、收藏页三处的数据怎么同步?
这篇文章用项目里一个真实的 FavoriteStar 组件,讲清楚 TanStack Query 是怎么把这四件事全部接管的。你会发现:这些逻辑一行都没手写,全靠 useMutation 的生命周期钩子。
需求:一个收藏星标
interface Props {
noteId: number;
isFavorite: boolean;
}
组件长这样:点一下收藏/取消收藏,红心变灰心,图标即时翻转。就这么简单的一个按钮,手写 fetch 要自己处理四件事:
- loading —— 请求期间按钮要禁用/转圈,防重复点击
- 即时反馈 —— 是等网络回来再变,还是先变再同步?(体验差异巨大)
- 失败恢复 —— 网络挂了怎么办?UI 得滚回去
- 三处同步 —— 详情页收藏了,列表页和收藏页也得跟着变
传统做法是往组件里塞一坨 useState + 手写请求 + 各种回调,状态一多就互相打架。
TanStack Query 的划分:useQuery 读,useMutation 写
先看入口。main.tsx 里 QueryClient 是全局缓存管理器,所有 useQuery / useMutation 拿到的数据都缓存在它里面:
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: 1, refetchOnWindowFocus: false },
},
});
retry: 1→ 请求失败自动重试一次(手写 fetch 得自己写循环)refetchOnWindowFocus: false→ 切回浏览器窗口时自动重新拉数据(手写 fetch 完全没有这个能力)
读取页面用 useQuery,它把数据按 key 缓存起来,多个组件共用同一 key 时只发一次请求。写入则用 useMutation——它不缓存数据,但多出一整套生命周期钩子,乐观更新就是在这套钩子上搭出来的。
useMutation 生命周期钩子:先搞清楚执行顺序
一个 useMutation 从上到下会有五个时机,按序执行:
onMutate → mutationFn → onSuccess / onError → onSettled
mutationFn(variables)—— 真正发请求的地方,返回一个 Promise。resolve 代表成功,reject 代表失败。上面例子里的favoritesApi.add(noteId)就写在这里。onMutate(variables)—— 在mutationFn发出之前触发。这是乐观更新的入口:此刻可以先改缓存,让 UI 立刻反馈。它return的值会成为context,一路传给后面的钩子。onSuccess(data, variables, context)—— 请求成功时触发,能拿到服务器返回的数据。onError(error, variables, context)—— 请求失败时触发,通常在这里回滚乐观值、弹错误提示。onSettled(data, error, variables, context)—— 无论成败最后都会执行一次,是「以服务器为准」兜底的时机,最常用的动作就是invalidateQueries。
const toggle = useMutation({
mutationFn: () => favoritesApi.add(noteId), // 真正发请求
onMutate: () => { /* 请求前:改缓存 → return context */ },
onSuccess: (data) => { /* 成功时:data 是服务器返回值 */ },
onError: (e) => { /* 失败时:回滚 + 提示 */ },
onSettled: () => { /* 无论成败:失效重拉 */ },
});
除了这些钩子,useMutation 还会返回 isPending / isError / isSuccess 等布尔状态,组件里直接拿 toggle.isPending 喂给按钮的 loading,请求期间的禁用/转圈就自动管好了。
注意:组件里没写
onSuccess,这不是遗漏而是刻意。因为onSettled里已经invalidateQueries把相关缓存全部失效重拉,服务器数据会自动回来——如果再到onSuccess里手动改一遍缓存,反而多余,还可能和失效重拉互相打架。
核心:乐观更新
FavoriteStar 的完整实现:
export function FavoriteStar({ noteId, isFavorite }: Props) {
const queryClient = useQueryClient();
const toggle = useMutation({
mutationFn: (): Promise<{ isFavorite: boolean }> =>
isFavorite ? favoritesApi.remove(noteId) : favoritesApi.add(noteId),
// ① 在请求发出【前】先改缓存 —— 这就是「乐观」:不等服务器,UI 立刻翻转
onMutate: async () => {
// 1) 取消在途请求,避免旧响应把乐观值覆盖掉
await queryClient.cancelQueries({ queryKey: ['note', noteId] });
// 2) 记下旧值,失败时回滚用
const prev = queryClient.getQueryData<NoteDetail>(['note', noteId]);
// 3) 直接改写缓存:详情页的 isFavorite 就地取反 → React 自动重渲染
queryClient.setQueryData<NoteDetail>(['note', noteId], (old) =>
old ? { ...old, isFavorite: !old.isFavorite } : old,
);
return { prev };
},
// ② 请求失败:把缓存改回旧值(回滚乐观更新)
onError: (e, _vars, ctx) => {
if (ctx?.prev) queryClient.setQueryData(['note', noteId], ctx.prev);
message.error(e instanceof ApiError ? e.message : '操作失败');
},
// ③ 无论成败都执行:以服务器为准,把相关缓存全部标记为「过期」→ 自动重新拉取
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['note', noteId] }); // 详情页
queryClient.invalidateQueries({ queryKey: ['notes'] }); // 笔记列表(含各页/各搜索词)
queryClient.invalidateQueries({ queryKey: ['favorites'] }); // 我的收藏页
},
});
return (
<Button
type="text"
icon={isFavorite ? <HeartFilled style={{ color: '#ff4d4f' }} /> : <HeartOutlined />}
loading={toggle.isPending}
onClick={() => toggle.mutate()}
>
{isFavorite ? '已收藏' : '收藏'}
</Button>
);
}
拆开看,三个钩子各司其职:
① onMutate:先改缓存,UI 立刻翻转
onMutate 在请求发出之前执行。它的三步是有讲究的:
cancelQueries取消在途的详情请求。如果不取消,可能会出现「请求 A 发出 → 乐观改成已收藏 → 请求 A 的旧响应回来 → 把乐观值覆盖回去」的竞态。getQueryData把旧值存进prev,这是回滚的底牌。setQueryData直接改写缓存里的isFavorite。注意这里用的是函数式更新old => ({ ...old, isFavorite: !old.isFavorite }),返回的prev会作为ctx传给后面的钩子。
缓存一变,详情页里所有用到这个数据的地方都会自动重渲染——UI 的即时反馈是「零等待」的。
② onError:失败回滚
网络是不可靠的,所以乐观更新必须能「退得回来」。onError 拿到 ctx(就是 onMutate 返回的 { prev }),把缓存塞回旧值,UI 立刻翻回去,再弹一条错误提示。
③ onSettled:以服务器为准
onSettled 无论成败都会执行,它是「最终真相」的保证者:既然服务器才是唯一权威,那就把相关查询全部标记为失效,让它们自动重新拉取,把缓存校准到服务器状态。这也顺便解决了我开头说的第四件事——三处数据同步。
queryKey 设计:前缀匹配让失效「一次命中全变」
这里最值得记住的一点是 invalidateQueries 的前缀匹配。看项目里三个页面的 key:
['note', noteId] // 详情页 NoteDetail
['notes', search.page, search.keyword] // 列表页 NoteList
['favorites', page] // 收藏页 Favorites
所以:
invalidateQueries({ queryKey: ['note', noteId] })只精确命中当前这篇笔记的详情invalidateQueries({ queryKey: ['notes'] })用前缀匹配,会命中所有['notes', ...]变体——不管你在第几页、搜了什么关键词['favorites']同理,命中整个收藏列表
列表页的 key 里带了 page 和 keyword,这是「URL 即状态源」的体现:翻页、搜索只会改变 URL 上的参数,useQuery 检测到 key 变化自动重新拉取,刷新页面也不丢状态。
扩展:接口缓存 & 其他高频亮点功能
接口缓存:queryKey 就是缓存键
useQuery 会把请求结果按 queryKey 缓存在全局 QueryClient 里。这意味着两件事:
- 多组件共用一份数据:两个组件用同一个 key,只会发一次请求,数据共享一份。上面收藏星标改
['note', noteId]的缓存、详情页自动重渲染,靠的就是这份「共享缓存」。 - 命中缓存不发请求:key 相同的请求直接读缓存,不重复打接口。
缓存有两个时间参数值得一记:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 数据 60s 内算「新鲜」,期间不重新请求
gcTime: 5 * 60 * 1000, // 不再被使用的缓存最多在内存里待 5 分钟(默认值)
},
},
});
staleTime:数据在多长时间内「未过期」。默认0,意味着任何数据一进页面就视为过期、触发重新拉取——但注意,过期了也只是后台刷新,界面上依旧立刻展示旧缓存,不会闪白屏。gcTime:不再被任何组件引用的缓存,在内存里存活多久才被垃圾回收。默认 5 分钟。
后台刷新:数据不闪烁
TanStack Query 的一大亮点是「优先展示缓存,后台悄悄更新」——这正是「数据不闪烁」的来源。配合几个开关:
refetchOnWindowFocus:切回浏览器标签页时自动重新拉数据(默认开,项目里关了)refetchInterval:定时轮询,适合行情、在线状态这类要自动刷新的数据refetchOnReconnect:网络恢复时重新拉取
同时区分两个状态,UI 才不会在每次后台刷新时乱转圈:
isLoading:首次加载、还没有任何数据时——这才该显示大 loadingisFetching:包括后台刷新在内的任何请求进行中——通常让内容照常显示
其他高频能力一览
| 能力 | 一句话说明 | 对应写法 |
|---|---|---|
| 失败自动重试 | 重试 retry 次,自带指数退避(项目配了 retry: 1) | retry |
| 跳转前预取 | 提前把下一页的数据拉进缓存,点击后秒开 | queryClient.prefetchQuery |
| 依赖请求 | 等上一个数据就绪才发,比如「登录后才请求」 | enabled: !!userId |
| 无限滚动 | 滚动到底自动取下一页,页码自动翻 | useInfiniteQuery |
| 请求去重 | 同 key 多组件只发一次请求 | queryKey |
| 缓存调试 | 可视化面板:看所有 key、手动失效/改值 | DevTools |
挑两个最常写的展开:
// 预取:列表 hover 到某条时,把详情页数据提前拉进缓存
queryClient.prefetchQuery({ queryKey: ['note', id], queryFn: () => api.getNote(id) });
// 无限滚动:pageParam 由 TanStack Query 帮你翻页
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ['notes'],
queryFn: ({ pageParam }) => api.listNotes({ page: pageParam }),
initialPageParam: 1,
getNextPageParam: (last) => (last.hasNext ? last.page + 1 : undefined),
});
一句话总结扩展:缓存让你「数据不用每次现拿」,
staleTime/gcTime控制缓存的新鲜度和寿命,预取、轮询、重试、无限滚动都是围着这份缓存做文章的周边能力。 TanStack Query 真正省掉的不是「写 fetch」这一步,而是整套请求状态管理——loading、错误、重试、缓存、同步,一个都不用手写。
小结
回看开头那四件事,在 TanStack Query 里分别对应:
| 手写 fetch 的麻烦 | TanStack Query 的答案 |
|---|---|
| loading 禁用/转圈 | useMutation.isPending 自动驱动按钮 loading |
| 即时反馈 | onMutate 先改缓存,UI 零等待翻转 |
| 失败恢复 | onError 拿回 prev 回滚 |
| 三处数据同步 | onSettled 前缀失效,['notes'] / ['favorites'] 一次全刷 |
这些代码不是「用库解决了一个难题」,而是整个交互本来就该长这样——TanStack Query 只是把每个状态的流转放进了正确的生命周期钩子里。收藏星标只是一个 60 行的组件,但它把 TanStack Query 最「亮眼」的能力——乐观更新——完整演示了一遍。下次再写类似的点赞、关注、购物车按钮,这个模式可以直接照搬。
拓展阅读:横向对比 ahooks useRequest
同样的需求,另一个很流行的答案是 ahooks 的 useRequest。但它和 TanStack Query 的设计哲学很不一样——前者是「server-state 管理库」,后者更像「增强版请求封装」。对比一下同一个收藏星标在两个库里的写法,差异一目了然。
同一个需求在 ahooks 里的写法
const { loading, run, mutate } = useRequest(
() => (isFavorite ? favoritesApi.remove(noteId) : favoritesApi.add(noteId)),
{
manual: true, // 不自动执行,等用户点
onError: () => message.error('操作失败'),
// onFinally: 无论成败最后执行一次,≈ TanStack 的 onSettled
},
);
const toggle = () => {
// 乐观更新:先 mutate 就地翻转 UI,再发请求(失败时再 mutate 回滚)
mutate((old) => ({ ...old, isFavorite: !old.isFavorite }));
run();
};
ahooks 没有独立的 useMutation——读和写都走 useRequest,用 manual: true 手动触发。乐观更新靠 mutate() 主动改数据完成,不是靠配置钩子。
核心差异
| 维度 | TanStack Query | ahooks useRequest |
|---|---|---|
| 定位 | server-state 管理库,缓存是核心 | 请求 hook 封装,管理 loading/data/error |
| 读 / 写 | useQuery(读)+ useMutation(写)分离 | 一个 useRequest 通吃,manual 控制 |
| 全局缓存 | queryKey 全局缓存,天然跨组件/跨页面共享 | 显式 cacheKey 才开启(默认存内存,可持久化 localStorage) |
| 乐观更新 | onMutate 钩子 + setQueryData 改全局缓存 | mutate() 直接改数据,同 cacheKey 组件同步 |
| 生命周期 | onMutate/onSuccess/onError/onSettled(带 ctx) | onBefore/onSuccess/onError/onFinally |
| 失效刷新 | invalidateQueries 前缀匹配,一次全刷 | 无自动失效,refresh() / refreshDeps 手动刷 |
| 特色能力 | 预取、无限滚动、DevTools、SSR | 防抖/节流、轮询、loadingDelay、ready |
三个最值得品的设计差别:
- 缓存是「默认」还是「可选项」。TanStack Query 的一切都建立在全局缓存上——
queryKey一写,跨组件共享、去重、失效全自动。ahooks 的缓存需要显式cacheKey才开启,更像是「顺手加的 SWR 模式」。 - 失效机制的有无。
invalidateQueries的前缀匹配是 TanStack Query 的杀手锏:['notes']一击,所有页、所有搜索词一起刷新。ahooks 没有这套,跨接口同步要靠cacheKey共享或手动refresh()。 - 乐观更新的入口。TanStack Query 在
onMutate里「声明式」改缓存;ahooks 是命令式调用mutate()。效果接近,但前者把回滚上下文(prev)和失败钩子串成了一条链。
怎么选
- 项目里大量跨页面共享、需要后台刷新、列表+详情+收藏多 key 联动的服务器数据 → TanStack Query 的全局缓存 + 失效机制是主场。
- 只需要单个组件内拉个数据、加个防抖节流、手动控制请求 → ahooks 的
useRequest更轻,心智负担小。 - 无论选哪个,核心思路是一样的:把「请求」当成可寻址、可缓存、可刷新的资源,而不是每次现写一遍 fetch。
参考资料
- ahooks useRequest 官方文档(options:manual / ready / cacheKey / staleTime)
- ahooks useRequest 使用与源码解析 - 掘金
- ahooks 中 request 的基本功能实现原理 - 掘金
- TanStack Query 官方文档 - Mutations(乐观更新章节)
- 【V3】useRequest · alibaba/hooks Issue #1173
- how to cache useRequest response? - Stack Overflow
- ahooks useRequest 的 staleTime 阻止 manual refresh / refreshDeps - Stack Overflow
