Next.js 16 乐观 UI 看起来完美。然后有人连点了五次
原文链接:dev.to/shubhradev/…
原文作者:Shubhra Pokhariya
用逐项锁处理竞态条件
一切都正常。我在任务列表上接好了 useOptimistic,一点复选框就立刻翻转,没有转圈,也没有半秒延迟,正是我想要的手感。我自己演示了十几次,然后就继续别的事了。
然后我做了真正上线前本该做的事:我傻傻地连点了五次,就像一个不耐烦的真实用户会做的那样。
复选框在视觉上看起来没问题。数据库没有。五个重叠请求已经发出去了,每一个都在翻转同一个布尔值,并按那天网络心情决定的顺序落地。什么也没崩。什么错误也没记日志。界面只是悄悄地不再匹配服务器上实际发生的事,而我光看屏幕根本无从知晓。
这正是乐观 UI(optimistic UI)特别容易漏掉的 bug,因为乐观 UI 的整个出发点就是它立刻看起来是对的。看起来对,和真的对,并不是同一种主张。
这个修复并不是 useOptimistic 专属的
规则和表单提交中禁用提交按钮一样,只是作用在每一行上,而不是整个表单。跟踪哪一项有请求挂起(pending),并在该请求结束前禁用它。
const [pendingId, setPendingId] = useState<string | null>(null);
function handleToggle(id: string) {
setPendingId(id);
startTransition(async () => {
setOptimisticTask(id);
try {
await toggleTask(id);
} finally {
setPendingId(null);
}
});
}
<input
type="checkbox"
checked={task.completed}
disabled={pendingId === task.id}
onChange={() => handleToggle(task.id)}
/>
一次只处理一项,作用域在每一行,而不是锁住整份列表。同一复选框上的第二次点击现在什么也不做,而不会再发出第二个请求去和第一个请求竞态写入数据库;这也意味着数据库少处理一次本就不该发出的冗余写。
实际看起来是这样:五次连点,只有一个请求。
那个把我带进兔子洞的回滚问题
就在排查这段逻辑时,我去查失败时该如何正确回滚乐观更新,因为显然若切换失败,复选框需要弹回它实际所在的状态。
我找到的几乎每个例子都这么写:
try {
setOptimisticTask(id);
await toggleTask(id);
} catch {
setOptimisticTask(id); // 再翻转一次以实现「撤销」
}
它能用。但它也在解决一个问题——对这种普通切换来说,其实并不需要解决。useOptimistic 并不会给你一块你自己拥有的、独立的第二份状态。它给你的是叠在你传入状态之上的临时值,且只在过渡(transition)挂起期间存在。过渡一结束,无论成功还是失败,React 都会丢掉临时层,再次按真实状态渲染。如果真实状态从未改变——因为请求失败且没有重新拉取——复选框会自己恢复。不需要第二次派发(dispatch)。
有一个真正的例外,而且在你假定自动回退总能兜底之前值得知道。如果乐观值是服务器根本还没确认过的东西——比如你在数据库分配真实 id 之前用占位 id 加进去的一条新评论——你的基础状态里就没有该条目的先前版本可回退。失败的插入不是去恢复某个标志,而是必须删掉只在客户端存在过的东西。那才是你要自己在本地记录新增内容、并在 catch 里自行清掉的唯一情况。切换没有这个问题,因为它翻转的值无论怎样都已存在于服务器上。
我没料到自己会对缓存函数有意见
切换一旦可用,下一个问题就是之后如何真正让缓存失效。Next.js 16 为此提供了三种方式,选错要么让用户看到过期数据,要么迫使站点上每个页面都阻塞在单次写入上。
revalidatePath 使一条路径失效。revalidateTag 使共享同一标签的每一条缓存条目失效,无论用在哪里,并带有 stale-while-revalidate(过期后边用边重新验证)行为。updateTag 仅限服务端操作(Server Actions),会立即过期某个标签,好让用户当前所在页面立刻反映他自己的写入。
对用户正盯着点击的复选框来说,updateTag 才是对的。如果同一任务计数还喂给站点别处的侧栏统计——那种没人实时盯着的——在同一标签上用 revalidateTag,可以让它晚一拍跟上,而不是迫使站点每个页面都阻塞在一次写入上。
若你用的是较新的 Next.js 16 版本,有一点值得知道:revalidateTag 现在需要第二个参数。
revalidateTag("tasks", "max"); // Next.js 16+ 推荐写法
单独写 revalidateTag("tasks") 已弃用,并会抛出 TypeScript 错误。
仅当你的错误来自数据获取时才要紧的错误边界细节
最后一块:请求彻底失败时会发生什么——不是校验错误,而是真正的断连。Next.js 文档写得很清楚:在 startTransition 内抛出的错误仍会冒泡到最近的 error.tsx,与是否处于过渡无关。
我直到去查才知道:从 Next.js 16.2 起,error.tsx 附带第二个恢复用 prop:unstable_retry,与更早的 reset 并列。它们不可互换。reset 会清掉错误,并让同样的子树按原样再渲染一次,不涉及新的 fetch;因此若底层数据一开始就没加载成功,你只是第二次展示同一块坏掉的界面。unstable_retry 会真正回到服务器并重新渲染该段,这才是失败来自 fetch 时能修好东西的那个。
export default function TaskListError({
error,
unstable_retry,
}: {
error: Error & { digest?: string };
unstable_retry: () => void;
}) {
return (
<div role="alert">
<p>Something went wrong loading your tasks.</p>
<button onClick={() => unstable_retry()}>Try again</button>
</div>
);
}
reset 仍有用武之地,特别是当你想清掉错误并重新渲染、却完全不动网络时。对整段工作就是展示已拉取数据的 segment 来说,这种情况很少见。
其余内容在哪里
我把完整版写在自己的站点上,包含完整的前后对比代码、三种重新验证函数的决策表,以及在不阻塞页面其余部分的情况下用 Suspense 边界流式传输变更结果的模式:"I Added Optimistic Updates to a Next.js 16 App. Here's Every Rollback Bug I Hit."。它直接承接我在更早一篇里写的服务端操作(Server Actions)相关 bug;若你想了解 useActionState、以及为何 redirect 不能放在 try/catch 里,可以去看那篇。
还有没有人遇到过乐观 UI 在每次演示里都完美,却在真实用户点得比你快时立刻散架?我真心想知道,快速连点这种情况,是不是一旦你认真找就会觉得它很常见。
注:图片编辑使用了 AI。