
一句话:乐观更新是「在请求返回之前先假设它会成功、立刻更新界面」,失败时再回滚——它显著提升「操作手感」,但只适合「大概率成功、可回滚」的操作。
一、什么是乐观更新
用户「点赞」:
❌ 悲观(默认):点击 → 按钮转圈 → 等接口返回 → 变「已赞」(几十~几百 ms 的等待)
✅ 乐观: 点击 → 立刻变「已赞」→ 后台请求 → 失败才变回来
用户感受:
· 悲观 → 「有点卡」(每次操作都要等)
· 乐观 → 「秒响应」(操作立刻生效)
代价:
· 需要「回滚」逻辑(失败时把界面改回去)
· 需要「提示失败」(否则用户不知道其实没成功)
· 只适合「成功率很高」的操作(否则回滚会频繁发生,体验更差)
⭐ 适用范围:
✅ 适合:点赞/收藏、开关切换、勾选完成、删除列表项、快捷编辑标题
(特点:操作简单、成功率高、失败易回滚、幂等性好做)
❌ 不适合:支付、下单、转账、涉及金额或不可逆的操作
(特点:失败代价大、需要明确确认与结果反馈)
二、手动实现(TanStack Query)
function useToggleLike() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (postId: string) => api.toggleLike(postId),
// ① 请求发出【前】:乐观更新
onMutate: async (postId) => {
// ⭐ 关键:先取消「进行中的相关查询」,否则它们返回后会覆盖乐观数据
await queryClient.cancelQueries({ queryKey: ['post', postId] });
// ⭐ 保存旧数据,供失败时回滚
const previous = queryClient.getQueryData<Post>(['post', postId]);
// ⭐ 立刻写入「乐观结果」
queryClient.setQueryData<Post>(['post', postId], (old) =>
old ? { ...old, liked: !old.liked, likeCount: old.likeCount + (old.liked ? -1 : 1) } : old
);
// 返回值会作为 context 传给 onError
return { previous };
},
// ② 失败时:回滚
onError: (_err, postId, context) => {
if (context?.previous) {
queryClient.setQueryData(['post', postId], context.previous);
}
toast.error('操作失败,请重试');
},
// ③ 无论成败:以服务端为准,重新拉取一次(保证最终一致)
onSettled: (_data, _err, postId) => {
queryClient.invalidateQueries({ queryKey: ['post', postId] });
},
});
}
// 使用
function LikeButton({ post }: { post: Post }) {
const { mutate, isPending } = useToggleLike();
return (
<button onClick={() => mutate(post.id)} aria-pressed={post.liked} disabled={isPending}>
{post.liked ? '❤️' : '🤍'} {post.likeCount}
</button>
);
}
三个回调的职责(务必理解):
onMutate → 取消进行中的查询 + 保存旧数据 + 写入乐观数据(返回用于回滚的 context)
onError → 用 context 回滚
onSettled → 无论成功失败都「失效并重新拉取」(最终以服务端为准)
⚠️ 为什么 onSettled 里还要 invalidate?
因为乐观更新是「猜的」——服务端可能做了额外处理
(如服务端返回的 likeCount 与本地计算不同)。
invalidate 一次能保证「最终数据正确」。
三、「列表项」的乐观更新
// ✅ 场景:在列表里删除一项
function useDeleteTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => api.deleteTodo(id),
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previous = queryClient.getQueryData<Todo[]>(['todos']);
queryClient.setQueryData<Todo[]>(['todos'], (old = []) => old.filter((t) => t.id !== id));
return { previous };
},
onError: (_e, _id, ctx) => {
queryClient.setQueryData(['todos'], ctx?.previous);
toast.error('删除失败');
},
onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
});
}
// ✅ 场景:新增一项(用临时 ID 占位)
function useAddTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (text: string) => api.addTodo({ text }),
onMutate: async (text) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previous = queryClient.getQueryData<Todo[]>(['todos']);
const optimistic: Todo = {
id: `temp-${crypto.randomUUID()}`, // ⭐ 临时 ID
text,
done: false,
pending: true, // ⭐ 标记「待确认」(UI 上可以半透明)
} as Todo;
queryClient.setQueryData<Todo[]>(['todos'], (old = []) => [...old, optimistic]);
return { previous };
},
onError: (_e, _text, ctx) => {
queryClient.setQueryData(['todos'], ctx?.previous);
toast.error('添加失败');
},
onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
});
}
// UI 上「标记待确认」的做法
{todos.map((t) => (
<li key={t.id} style={{ opacity: t.pending ? 0.5 : 1 }}>
{t.text}
</li>
))}
四、React 19 的 useOptimistic
// ✅ React 19 提供了原生的乐观更新 Hook(配合 Actions)
import { useOptimistic, useActionState } from 'react';
function CommentList({ comments, addComment }: { comments: Comment[]; addComment: (text: string) => Promise<void> }) {
const [optimisticComments, addOptimistic] = useOptimistic(
comments, // 真实数据
(current, newText: string) => [ // 如何「乐观地」计算新状态
...current,
{ id: `temp-${Math.random()}`, text: newText, pending: true },
]
);
async function formAction(formData: FormData) {
const text = formData.get('text') as string;
addOptimistic(text); // ⭐ 立刻更新 UI
await addComment(text); // 后台提交(失败时 React 自动回滚)
}
return (
<>
<ul>
{optimisticComments.map((c) => (
<li key={c.id} style={{ opacity: c.pending ? 0.5 : 1 }}>{c.text}</li>
))}
</ul>
<form action={formAction}>
<input name="text" />
<button>发送</button>
</form>
</>
);
}
`useOptimistic` 的特点:
① 它基于「真实数据 + 乐观变换函数」——乐观状态是「临时叠加」在真实数据上的
② 「回滚」是【自动的】:一旦 action 结束(无论成败),乐观状态就被丢弃,
界面回到「真实数据」的渲染结果
③ 必须在 Action(或 transition)内部调用「添加乐观更新」的函数
④ 与 `useActionState` / `<form action>` 配合最好
对比「手动实现」(TanStack Query):
· useOptimistic 更简洁(不用管回滚)
· TanStack Query 的 onMutate 更灵活(可以精细控制缓存与失效)
| 方案 | 回滚 | 适用 |
|---|---|---|
TanStack Query onMutate |
手动(保存 previous +onError 回滚) |
用 Query 管理数据的项目 |
React 19 useOptimistic |
自动(action 结束即丢弃乐观状态) | 用 Actions/表单的项目 |
手写(useState + 回滚) |
手动 | 不想引入依赖的简单场景 |
五、失败处理策略
// ✅ 三层处理:回滚 → 提示 → 可选重试
onError: (err, variables, context) => {
// ① 回滚
queryClient.setQueryData(['todos'], context?.previous);
// ② 明确提示(用户需要知道「刚才那下没成功」)
toast.error({
message: '操作失败,已恢复原状',
action: { label: '重试', onClick: () => mutate(variables) }, // ③ 提供重试入口
});
}
⚠️ 乐观更新的失败处理必须「让用户知道」:
· 因为界面已经「看起来成功了」,如果不提示,用户会以为操作生效了
· 常见做法:回滚 + toast「操作失败,已恢复」
· 更好的做法:toast 里带「重试」按钮
· 对于「列表项」:可以让它保留为「失败状态」(红色 + 重试按钮)而不是直接消失
// ✅ 更友好的做法:让「失败的那一项」留在列表里,标记失败并提供重试
// (而不是直接回滚消失——用户可能不知道刚才点了什么)
{todos.map((t) => (
<li key={t.id}>
{t.text}
{t.status === 'failed' && (
<span className="error">
发送失败 <button onClick={() => retry(t.id)}>重试</button>
</span>
)}
</li>
))}
六、常见误区
① 对「不可逆操作」用乐观更新
❌ 支付、下单、转账、删除账号
→ 失败代价大,必须明确确认与结果反馈
② 只用 onMutate 不用 onSettled
→ 服务端可能与本地计算不一致(如 likeCount),数据会「漂移」
→ 必须 invalidate 一次保证最终一致
③ 忘记 cancelQueries
→ 进行中的查询返回后会「覆盖乐观数据」→ 界面闪回旧状态
④ 忘记保存 previous
→ 失败时无法回滚
⑤ 有多个「乐观更新的字段」时,只回滚其中一个
→ 要整体替换(用 previous 整个对象)
⑥ 在「乐观更新的同时」显示 loading 遮罩
→ 与乐观更新的意义矛盾(应该不阻塞用户)
⑦ 对「需要服务端生成的 ID/时间戳」的场景直接乐观
→ 要用「临时 ID + 标记 pending」,等服务端返回后替换
⑧ 并发点击多次(乐观状态叠加出错)
→ 用 `isPending` 禁用按钮,或用「基于当前乐观值计算」的变换函数
⑨ 认为「乐观更新 = 不用处理错误」
→ 恰恰相反:它把「错误处理」变成了必须项
⑩ 在列表里用「乐观更新 + index 作 key」
→ 临时项与真实项混淆(必须用稳定 ID,临时项也要有唯一 ID)
七、常见坑
// ① 没取消进行中的查询
onMutate: async () => {
queryClient.setQueryData(['x'], optimistic); // ⚠️ 可能在 100ms 后被旧请求覆盖
}
// ✅ await queryClient.cancelQueries({ queryKey: ['x'] });
// ② 没保存 previous
// ③ 没在 onSettled 里 invalidate(数据可能漂移)
// ④ 用「闭包里的旧数据」计算乐观值
onMutate: () => {
queryClient.setQueryData(['todos'], [...todos, item]); // ⚠️ todos 可能是旧快照
}
// ✅ 用函数式:setQueryData(['todos'], (old = []) => [...old, item])
// ⑤ 乐观值算成「非幂等」(重复点击导致重复添加)
// ⑥ 用 Math.random() 作临时 ID(可能与真实 ID 冲突/难以去重)
// ✅ 用 `temp-${crypto.randomUUID()}`
// ⑦ 失败回滚后「不清除 pending 标记」(列表项一直半透明)
// ⑧ 对「带金额的操作」做乐观更新
// ⑨ 在 useOptimistic 里「不在 action 中调用 addOptimistic」
// → React 19 要求在 action/transition 内调用
// ⑩ 把 useOptimistic 的数据「当成真实数据」用于后续计算(它随时会被丢弃)
// ⑪ 在「多页面共享同一份缓存」时只更新了当前页面的 key
// → 用 invalidateQueries 的前缀匹配统一刷新
// ⑫ 忘记「失败时的可访问性提示」(role="alert")
// ⑤ 的修复:用「基于当前乐观值」的变换 + 禁用按钮
const { mutate, isPending } = useToggleLike();
<button onClick={() => mutate(post.id)} disabled={isPending}> {/* ⭐ 防连点 */}
{post.liked ? '❤️' : '🤍'}
</button>
八、面试延伸
- 「什么是乐观更新?」
乐观更新指「在请求返回之前就假设它会成功、立刻更新界面」——用户点击后界面立即响应(而不是等接口返回),如果请求失败再把界面回滚并提示用户。它的价值是显著提升操作手感(把「几十到几百毫秒的等待」变成「瞬间响应」),代价是「需要回滚逻辑 + 必须处理失败提示」。它只适合「成功率很高、失败可回滚、操作不可逆性低」的场景(如点赞、收藏、勾选、删除列表项)。 -
「TanStack Query 里怎么实现乐观更新?」
用useMutation的三个回调:①onMutate——先await queryClient.cancelQueries(...)(取消进行中的查询,否则它们返回后会覆盖乐观数据)、保存旧数据(getQueryData,供回滚)、写入乐观数据(setQueryData),并return旧数据作为 context;②onError——用 context 里保存的旧数据回滚,并提示用户;③onSettled——无论成败都invalidateQueries(重新拉取,保证与服务端最终一致,因为乐观值是「猜的」)。 -
「为什么
onMutate里要cancelQueries?」
因为「乐观更新」是「在本地缓存上先改一步」,但可能有正在进行的查询(如「组件挂载时触发的重新验证」)。如果那个请求在你乐观更新之后返回,它会用旧数据覆盖你刚写入的乐观数据——界面会「闪回旧状态」。所以标准做法是在onMutate里先await cancelQueries(...)(把进行中的请求取消掉),再写乐观数据。 -
「为什么
onSettled里还要invalidateQueries?」
因为乐观更新的数据是「客户端猜的」——服务端可能做了额外处理(如服务端计算的likeCount、生成的id、时间戳、排序)。如果只依赖乐观值,数据会「漂移」直到下次刷新。所以在onSettled(无论成败)里做一次invalidateQueries,能保证最终以服务端数据为准。这是「乐观更新 + 最终一致」的标准组合。 -
「React 19 的
useOptimistic有什么不同?」
它把「回滚」变成自动的:useOptimistic(realData, updateFn)返回「乐观状态」——它是「临时叠加在真实数据上的」。你在 Action 里调用「添加乐观更新」的函数后,界面立刻更新;一旦 action 结束(无论成功失败),乐观状态就被丢弃,界面自动回到「真实数据」的渲染结果(失败时就是「回滚」)。它比手动方案更简洁(不用管previous与cancelQueries),但灵活性不如 TanStack Query 的onMutate(后者能精细控制缓存与失效时机)。 -
「什么操作不适合乐观更新?」
四类:① 不可逆/高价值操作——支付、下单、转账、删除账号(失败代价大,必须明确确认与结果反馈);② 结果不确定或需要服务端决策的操作——如「抢购」「审批」(服务端可能拒绝);③ 成功率低的操作(回滚会频繁发生,反而更乱);④ 需要「服务端生成的关键数据」才能继续的操作(如创建后要跳转到生成的 ID 页面)。判断标准是「假设它成功,风险可接受吗」。 -
「乐观更新失败时,怎么让用户知道?」
三层:① 回滚(把界面改回真实状态);② 明确提示(toast「操作失败,已恢复原状」——必须提示,因为用户看到过「成功」的样子);③ 提供重试入口(toast 里带「重试」按钮,或让「失败的那一项」留在列表里并标记失败 + 重试)。对于「列表项」,更友好的做法是「不直接回滚消失,而是标记为「发送失败」+ 重试按钮」——因为用户可能不记得刚才点了什么。 -
「怎么处理『乐观更新 + 并发点击』?」
两个手段:① 禁用按钮——用isPending(TanStack Query)或在提交中禁用(React 19 的useFormStatus/useActionState的isPending),避免重复提交;② 用「基于当前值」的变换函数——写乐观数据时用函数形式setQueryData(key, (old) => f(old))而不是「闭包里的旧快照」,这样多次调用会依次叠加而不是互相覆盖。此外「临时项的 ID」要用唯一值(如temp-${randomUUID()}),避免去重时冲突。
一句话速记
乐观更新 = 「先假设成功、立刻更新界面,失败再回滚」(提升手感,但需回滚 + 必须提示失败);只适合「成功率高、可回滚、低风险」的操作(点赞/收藏/勾选/删除),不适合支付/下单/转账;TanStack Query 的标准实现是「
onMutate:await cancelQueries→ 保存previous→setQueryData写乐观值 →onError回滚 →onSettledinvalidateQueries保证最终一致」;React 19 的useOptimistic回滚是自动的(action 结束即丢弃乐观状态);失败要「回滚 + toast(带重试)」,列表项可「保留为失败态 + 重试」;并发点击要禁用按钮并用「函数式setQueryData」避免覆盖。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!