Skip to content

乐观更新与回滚 ​

问题:服务器没确认,为什么可以先显示 ​

乐观更新把“成功概率高”的预期结果立即放进界面,减少感知等待。但它不是提前修改权威数据:Action 完成后,UI 会再次以真实 state/props 为基础;若请求失败且基础值未改变,临时结果自然消失。

完整实验:新增任务的确认与撤回 ​

jsx
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 层。

练习:契约任务 ​

实现乐观删除:

  1. 初始项为 a、locked、c;点击删除立即隐藏并显示全局“删除中”。
  2. 删除 locked 在 500ms 后拒绝并显示 item is locked,该项必须恢复原位置。
  3. 其他 id 成功后从权威 state 移除。
  4. 乐观 reducer 必须纯净,不修改输入数组;答案需用断言验证成功和失败基础状态。

完成后对照答案 06-02。

下一篇 ​

最后一篇把 React form Action 放回浏览器与服务端边界 中判断。

用心记录每一步成长