跳到主要内容

TanStack Query 实战:一个「收藏星标」的乐观更新

· 阅读需 13 分钟

收藏功能几乎是每个内容产品的标配,但它恰恰是「手写 fetch 最容易写脏」的一类交互:点一下,UI 要立刻翻转,网络请求在后台跑,失败还得翻回去。这背后其实藏着一串问题——loading 怎么禁按钮?等网络还是先反馈?失败怎么恢复?详情页、列表页、收藏页三处的数据怎么同步?

这篇文章用项目里一个真实的 FavoriteStar 组件,讲清楚 TanStack Query 是怎么把这四件事全部接管的。你会发现:这些逻辑一行都没手写,全靠 useMutation 的生命周期钩子。

需求:一个收藏星标

interface Props {
noteId: number;
isFavorite: boolean;
}

组件长这样:点一下收藏/取消收藏,红心变灰心,图标即时翻转。就这么简单的一个按钮,手写 fetch 要自己处理四件事:

  1. loading —— 请求期间按钮要禁用/转圈,防重复点击
  2. 即时反馈 —— 是等网络回来再变,还是先变再同步?(体验差异巨大)
  3. 失败恢复 —— 网络挂了怎么办?UI 得滚回去
  4. 三处同步 —— 详情页收藏了,列表页和收藏页也得跟着变

传统做法是往组件里塞一坨 useState + 手写请求 + 各种回调,状态一多就互相打架。

TanStack Query 的划分:useQuery 读,useMutation 写

先看入口。main.tsxQueryClient全局缓存管理器,所有 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 里带了 pagekeyword,这是「URL 即状态源」的体现:翻页、搜索只会改变 URL 上的参数,useQuery 检测到 key 变化自动重新拉取,刷新页面也不丢状态。

扩展:接口缓存 & 其他高频亮点功能

接口缓存:queryKey 就是缓存键

useQuery 会把请求结果按 queryKey 缓存在全局 QueryClient 里。这意味着两件事:

  1. 多组件共用一份数据:两个组件用同一个 key,只会发一次请求,数据共享一份。上面收藏星标改 ['note', noteId] 的缓存、详情页自动重渲染,靠的就是这份「共享缓存」。
  2. 命中缓存不发请求: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首次加载、还没有任何数据时——这才该显示大 loading
  • isFetching:包括后台刷新在内的任何请求进行中——通常让内容照常显示

其他高频能力一览

能力一句话说明对应写法
失败自动重试重试 retry 次,自带指数退避(项目配了 retry: 1retry
跳转前预取提前把下一页的数据拉进缓存,点击后秒开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 Queryahooks useRequest
定位server-state 管理库,缓存是核心请求 hook 封装,管理 loading/data/error
读 / 写useQuery(读)+ useMutation(写)分离一个 useRequest 通吃,manual 控制
全局缓存queryKey 全局缓存,天然跨组件/跨页面共享显式 cacheKey 才开启(默认存内存,可持久化 localStorage)
乐观更新onMutate 钩子 + setQueryData 改全局缓存mutate() 直接改数据,同 cacheKey 组件同步
生命周期onMutate/onSuccess/onError/onSettled(带 ctxonBefore/onSuccess/onError/onFinally
失效刷新invalidateQueries 前缀匹配,一次全刷无自动失效,refresh() / refreshDeps 手动刷
特色能力预取、无限滚动、DevTools、SSR防抖/节流、轮询、loadingDelayready

三个最值得品的设计差别:

  1. 缓存是「默认」还是「可选项」。TanStack Query 的一切都建立在全局缓存上——queryKey 一写,跨组件共享、去重、失效全自动。ahooks 的缓存需要显式 cacheKey 才开启,更像是「顺手加的 SWR 模式」。
  2. 失效机制的有无invalidateQueries 的前缀匹配是 TanStack Query 的杀手锏:['notes'] 一击,所有页、所有搜索词一起刷新。ahooks 没有这套,跨接口同步要靠 cacheKey 共享或手动 refresh()
  3. 乐观更新的入口。TanStack Query 在 onMutate 里「声明式」改缓存;ahooks 是命令式调用 mutate()。效果接近,但前者把回滚上下文(prev)和失败钩子串成了一条链。

怎么选

  • 项目里大量跨页面共享、需要后台刷新、列表+详情+收藏多 key 联动的服务器数据 → TanStack Query 的全局缓存 + 失效机制是主场。
  • 只需要单个组件内拉个数据、加个防抖节流、手动控制请求 → ahooks 的 useRequest 更轻,心智负担小。
  • 无论选哪个,核心思路是一样的:把「请求」当成可寻址、可缓存、可刷新的资源,而不是每次现写一遍 fetch。

参考资料