乐观更新与回滚
问题:服务器没确认,为什么可以先显示
乐观更新把“成功概率高”的预期结果立即放进界面,减少感知等待。但它不是提前修改权威数据:Action 完成后,UI 会再次以真实 state/props 为基础;若请求失败且基础值未改变,临时结果自然消失。
完整实验:新增任务的确认与撤回
import { useOptimistic, useState } from 'react'
import { useFormStatus } from 'react-dom'
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
let nextServerId = 2
async function createTask(title) {
await wait(700)
if (title.toLowerCase() === 'fail') throw new Error('server rejected task')
return { id: String(nextServerId++), title }
}
function Submit() {
const { pending } = useFormStatus()
return <button disabled={pending}>{pending ? '发送中…' : '新增'}</button>
}
export default function App() {
const [title, setTitle] = useState('')
const [tasks, setTasks] = useState([{ id: '1', title: 'Read docs' }])
const [error, setError] = useState('')
const [optimisticTasks, addOptimisticTask] = useOptimistic(
tasks,
(current, draft) => [...current, draft],
)
async function addTask(formData) {
const title = String(formData.get('title') ?? '').trim()
if (!title) {
setError('标题不能为空')
return
}
const draft = {
id: `temp-${crypto.randomUUID()}`,
title,
pending: true,
}
addOptimisticTask(draft)
setError('')
try {
const saved = await createTask(title)
setTasks((current) => [...current, saved])
} catch (reason) {
setError(reason.message)
}
}
return (
<main>
<h1>任务</h1>
<form action={addTask}>
<input name="title" aria-label="标题" value={title}
onChange={event => setTitle(event.target.value)} />
<Submit />
</form>
{error && <p role="alert">{error}</p>}
<ul>
{optimisticTasks.map((task) => (
<li key={task.id}>
{task.title} {task.pending && <small>发送中…</small>}
</li>
))}
</ul>
</main>
)
}提交后,addOptimisticTask 在 form Action 上下文中叠加临时层。成功时权威 tasks 加入服务端对象,Action 结束后临时层由真实项替代;失败时基础 tasks 没变,临时层撤回,错误保留。
临时 id 在调用 optimistic setter 之前生成;更新函数应保持纯净,不能在其中调用随机数或产生副作用。稳定 id 也避免多项并发提交时 key 串位。
把显示值拆成基础数组和临时操作
先提交普通标题,观察同一项从带“发送中”变成正式项;再提交 fail,观察它先出现、随后消失。输入保持受控,失败后仍可编辑并重试。本例保留输入到下一次手动修改,避免清空期间用户新写的草稿。
以原数组 [Read docs] 为例,可以逐步推导:
| 时刻 | 权威 tasks | 临时操作 | 界面 |
|---|---|---|---|
| 提交前 | 原数组 | 无 | 原数组 |
| 开始 Action | 原数组 | 追加 temp 项 | 原数组与发送中项 |
| 请求成功并完成提交 | 含服务端新项 | 结束 | 正式新项 |
| 请求失败并完成 Action | 仍是原数组 | 结束 | 原数组及错误提示 |
失败路径没有执行“删除刚才添加的正式项”,因为正式数组从未添加它。这个区别能避免误删别人刚刚提交的数据。useOptimistic 的 reducer 收到当前基础上需要计算的状态;基础值变化时,React 可以重新应用尚在等待的乐观操作,所以 reducer 必须纯净,也不能闭包捕获某次旧 tasks 再替换全部列表。
若直接在开始时 setTasks 追加草稿,再在失败时 setTasks(oldTasks),就把“回滚”变成恢复整份旧快照。等待期间另一处成功新增的数据可能一起消失。正确的权威更新应基于服务端确认值,并以操作身份定位,乐观层只负责当前展示。
本例禁用同一表单的提交按钮以便逐次观察;多个入口仍可能并发。临时 id 与服务端 id 不同意味着确认时那一行可能重新挂载,不能把必须保留的局部编辑状态只藏在临时行内部。更复杂列表应约定客户端操作 id 如何与服务端结果关联,并把错误绑定到操作,而不是只显示最后一个全局字符串。
关键变体
- 编辑可把旧值与 draft 一起传入更新函数,失败后基础 state 自动恢复旧值。
- 删除时先从 optimistic 列表过滤;失败后基础列表重新出现该项。
- 多个提交可以同时形成多个乐观层,错误信息最好关联具体操作,而不是只有一个全局字符串。
- 高风险动作(付款、权限变更)通常不适合假装成功;可以先显示“处理中”,等权威确认。
边界
useOptimistic 只解决客户端呈现,不保证请求送达、执行一次或按期望顺序落库。服务端仍负责权限、幂等与冲突策略。若失败后需要保留草稿供重试,应把草稿作为明确 state 管理,而不是依赖已经结束的 optimistic 层。
练习:契约任务
实现乐观删除:
- 初始项为
a、locked、c;点击删除立即隐藏并显示全局“删除中”。 - 删除
locked在 500ms 后拒绝并显示item is locked,该项必须恢复原位置。 - 其他 id 成功后从权威 state 移除。
- 乐观 reducer 必须纯净,不修改输入数组;答案需用断言验证成功和失败基础状态。
完成后对照答案 06-02。
下一篇
最后一篇把 React form Action 放回浏览器与服务端边界 中判断。