useOptimistic 与乐观更新
乐观更新 = 先按「会成功」渲染,失败再回滚。手写容易只写对成功路径,useOptimistic 的价值在于自动回滚。
一、先渲染再回滚
乐观更新是三件事的组合:立刻改 UI、发请求、失败回滚。缺了第三件就不是乐观更新,而是「假装成功」。useOptimistic 的设计正好对应这三步:它接收真实状态与一个合并函数,调用时立即按合并函数算出乐观状态;真实状态来自 props,一旦 action 结束、真实数据回来,乐观值自动被丢弃——不需要手动回滚。
const [optimistic, addOptimistic] = useOptimistic(messages, (state, text) =>
[...state, { id: `tmp-${Date.now()}`, text, pending: true }]
)
async function send(formData) {
addOptimistic(formData.get('text'))
await postMessage(formData) // 只有抛出(reject)才回滚;内部 catch 后正常 return 错误对象是不回滚的
}
前提是:addOptimistic 必须在 Action / Transition 内部调用。脱离 transition,框架无法知道「这次更新什么时候算结束」,也就没法在结束时用真实状态覆盖乐观值。真值来自 props,所以不需要手写回滚——这是它和手写方案最大的区别。顺带一提,同一个 action 里多次 addOptimistic 会被框架按顺序合并,不会互相覆盖——这是手写方案最难做对的地方,也是用它的另一个理由。
二、自动回滚省掉的分支
手写要自己维护三份东西:一个 loading 标志、一份临时列表项、一段失败回滚逻辑。回滚最容易漏——成功路径写完就上线,失败的回滚分支往往忘了测。
// 手写:影子状态 + 回滚,rollback 路径极易遗漏
const [list, setList] = useState(items)
const [pending, setPending] = useState(false)
function add(text) {
const prev = list
setList([...list, { id: `tmp-${Date.now()}`, text, pending: true }])
setPending(true)
submit(text).then((saved) => setList([...list, saved])).catch(() => setList(prev)) // 漏写这里就脏了
setPending(false)
}
// useOptimistic:失败自动回到真实状态,rollback 那一半由框架负责
它省掉的是回滚那一半——不是更快,是更不容易写错。真实项目里手写方案的回滚还容易和错误提示脱节:失败时只回滚了列表却忘了通知用户,界面静默退回上一态,用户以为没生效又点一次,产生重复提交。
三、哪些操作适合乐观
判据是结果是否可预测:点赞、收藏、发评论这类结果确定的操作适合;涉及金额计算、库存校验、风控判断的绝不能乐观——服务端算出来的值和你猜的不一样,用户会看到数字跳变,比等待更伤信任。购物车加购可以乐观,结算金额不行。具体列一下边界更清楚:适合乐观的——点赞、收藏、关注、评论发送、轻量 toggle,这些成功率高、延迟可感知、错了容易补救;不适合的——支付与下单(钱的事不能猜)、库存扣减(超卖比等待严重)、风控判定(猜错就放过风险)、以及任何需要服务端二次校验才能定结果的操作。判据永远回到那句:服务端算出来的值,会不会和你客户端猜的不一样。还有一类灰色地带:乐观更新后服务端返回「成功但内容变了」(比如做了去重或归一化),这时要以服务端返回为准覆盖,而不是保留你猜的版本——useOptimistic 在 action 结束后正是这么做的。乐观更新也不是「免等待」——它只是把等待藏在了已更新的 UI 背后;如果请求本身要两秒,用户看到的是两秒的 pending 态,不是瞬间完成。别把它当成性能优化,它是体验优化。
并发提交下最容易出问题的一条:服务端部分成功,前端却整体回滚:
startTransition(async () => {
addOptimistic({ id: 'a', text: '第一条' })
addOptimistic({ id: 'b', text: '第二条' })
await submitBoth()
})
// submitBoth 里第一条入库成功、第二条失败并抛出:
// → 两条乐观值一起消失(回滚是整体的)
// → 但服务端已经留下了第一条,界面与真实状态不一致
// 正确做法:按条提交,或让服务端返回每一条的真实结果再对齐
四、失败提示与并发提交
四点都不能省:pending 态要有明确视觉标识(灰显、骨架),别和真实数据长得一样;失败要有明确反馈,不要静默消失,最好给重试入口;并发多次提交时,乐观状态要能被逐条替换而不是互相覆盖,临时项用临时 id;重复提交期间禁用入口,避免产生多条真实记录。
// 与 useActionState 配合:拿到 pending / error,失败可重试
import { useOptimistic, useActionState } from 'react'
function Row({ item }) {
const [optimisticQty, setOptimisticQty] = useOptimistic(item.qty)
const [state, action, pending] = useActionState(updateQty, { ok: false })
function change(next) {
startTransition(async () => {
setOptimisticQty(next)
await updateQty(item.id, next) // 失败:乐观值自动回滚
})
}
return <button disabled={pending} onClick={() => change(optimisticQty + 1)}>{optimisticQty}</button>
}
乐观值要有明确边界:它是「猜的」,不是「真的」,UI、统计、排序都要把它和真实数据区分开。否则未确认的项会污染「共 N 条」这类计数,用户看到数字和真实不符,排序也会把临时项插进错误位置;临时 id 只用于占位,绝不能进任何聚合逻辑。
乐观值还要和真实数据区分开,否则统计和排序会被污染:
const [optimistic, addOptimistic] = useOptimistic(items, (state, next) => [
{ ...next, pending: true }, // 打标记,UI 上做半透明
...state,
])
// 渲染时区分对待,避免 pending 项被计入统计
const total = items.length // 只用真实数据
const shown = optimistic.length // 只用于展示
五、回滚只是其中一环
乐观更新不是「先改 state 再发请求」,必须有回滚与失败提示,否则那就是数据不一致。
乐观值也不能直接参与统计——临时数据带的是临时 id,计进总数、排序和去重都会出错。
pending 期间同样不能不管提交按钮。重复提交会产生多条真实记录,而乐观 UI 会让人误以为刚才那一下没生效。
乐观更新的本质是赌一个可预测的结果——凡是服务端可能算得跟你不一样的,都不要赌。
乐观更新的正确用法参考 react.dev:useOptimistic,注意它只在 Action 进行中保持状态。