首页 / 文章 / useOptimistic回滚功能:Next.js服务器动作中的五种故障模式

useOptimistic回滚功能:Next.js服务器动作中的五种故障模式

通过五种经过测试的Next.js服务器动作故障模式以及一个可行的解决方案,了解为何useOptimistic会在不向用户解释故障原因的情况下静默恢复UI。

4375 词

自动回滚功能确实如宣传的那般有效。但将错误信息展示给用户却并非如此。我在 Next.js 的 Server Action 切换功能中故意触发了五种不同的故障场景,并记录了屏幕上实际显示的内容。

回滚操作不会产生任何成本,但告知用户故障情况却需要成本。付费状态会发生变化,随后又会在没有给用户提供任何可读解释的情况下悄然恢复原状。

大多数教程都将 useOptimistic 视为一种免费的撤销按钮:点击后界面状态改变,请求失败,界面便回到初始状态,仅此而已。

我想验证在情况变得复杂时那个承诺是否依然成立。于是我在 Next.js App Router 中创建了一个简单的发票列表——五行数据,每行都对应一个独立的 Server Action——并在实际代码中设计了五种不同的失败场景。这些都不是人为设计的极端情况,而是那些会在生产环境中悄然出现却无人察觉的错误。

针对每种失败模式进行了五次测试,其中三种情况能够正确触发界面回滚;而另外两种则会让界面显示错误信息。当 Server Action 抛出异常时,自动回滚功能会生效;但如果它只是悄悄返回类似 { ok: false } 这样的值而非抛出异常,该功能就不会起作用——这种处理方式其实是无意中误导用户的一种隐蔽手段。

仅供参考,该程序是基于 Next.js 15.5.2 版本开发的,搭配 React 19.1.1 使用,通过 TypeScript 5.9.2 进行编译,在 Linux 系统的 Node 22.x 版本上运行。后续给出的数据均来自该确切环境。如果在其他机器上重新运行相同的测试,时间数值可能会有轻微变化,但每次失败的类型应保持一致。

文档中的实际承诺

useOptimistic 的官方参考页面明确指出:乐观值仅在 Action 仍在执行时显示;一旦该状态确定,React 会回退到渲染当前实际的 value 所代表的内容。

它还解释了出错时会发生什么。简而言之:如果在 Action 内部出现未捕获的错误,待处理的 Transition 仍可正常完成。由于周围的代码通常只在调用成功后才会写入真实的 value,因此出现错误意味着该值从未被更新——所以一旦 Transition 完成,React 会直接显示用户点击之前的相同界面。文档中指出,如果想向用户展示任何消息,就需要自己捕获该错误;React 不会替你处理。

这里还有两个需要注意的细节:

  1. 乐观更新器必须在 Action 内部或 startTransition 中运行。如果在这些范围之外运行,React 会记录警告,且乐观 UI 只会出现短暂时间便消失。
  • 回滚并非由你主动触发——当转换完成且底层值从未发生变化时,它就会自动发生。
  • 第二点正是整篇文章的核心思想。恢复UI是无需额外成本的,至于向用户解释原因则由你决定。在操作待处理期间,该钩子会显示预测值,随后再与父组件的实际值进行同步。如果在不改变该基础值的情况下执行操作,就会发生回滚;如果成功处理但仍未更改该值,同样会回滚;而如果成功处理却用错误的结果更新了基础值,就会出现“幽灵状态”——即UI显示的内容实际上从未在服务器端发生。

    迷你应用:发票已支付切换功能

    该测试应用并非简单的示例,而是模拟了账单页面——因为正是在这种场景下,错误的“已支付”标识才会引发催收电话。

    其结构如下:

    • RSC 页面从内存存储中加载五张发票(INV-1001INV-1005),初始状态均为未支付。
    • 每行都会作为一个独立的客户端组件呈现,并包含一个乐观布尔值。
    • 点击切换按钮会触发一个服务器操作,传入明确的 paid 布尔值。
    • revalidatePath('/invoices') 仅在操作成功时才会执行。
    • 每行都会记录一个渲染计数器,每次重绘时该计数器都会增加。由于禁用了严格模式,因此不会因重复调用而使计数器虚高。
  • 该操作包含一个人为的 await sleep(400),这样乐观更新的时间窗口就足够长,便于直观观察并使用 performance.now() 进行测量。
  • // app/invoices/page.tsx
    import { getInvoices } from '@/lib/invoices';
    import { InvoiceRow } from './invoice-row';
    
    export default async function InvoicesPage() {
      const invoices = await getInvoices();
      return (
        <ul>
          {invoices.map((inv) => (
            <InvoiceRow key={inv.id} invoice={inv} />
          ))}
        </ul>
      );
    }
    

    每次测试运行的规则如下:将发票状态重置为未支付,设置激活的 FAIL_MODE,点击切换按钮一次(模式五需点击两次),在操作完成后再等待800毫秒,随后检查按钮的标签、查看 data-renders 的输出,记录控制台中的任何警告信息,并截取屏幕截图。每种模式均运行五次,始终使用相同的发票编号,不涉及React Query或任何外部缓存层——仅使用RSC属性、useOptimistic以及一个服务器端操作。

    “正常流程”下的行组件看起来就像任何入门教程中的示例:

    // app/invoices/invoice-row.tsx — broken happy-tutorial version
    'use client';
    
    import { useOptimistic, startTransition, useRef } from 'react';
    import { togglePaid } from './actions';
    import type { Invoice } from '@/lib/invoices';
    
    export function InvoiceRow({ invoice }: { invoice: Invoice }) {
      const renders = useRef(0);
      renders.current += 1;
    
      const [optimisticPaid, setOptimisticPaid] = useOptimistic(invoice.paid);
    
      function onToggle() {
        startTransition(async () => {
          setOptimisticPaid(!optimisticPaid);
          await togglePaid(invoice.id, !optimisticPaid);
          // hope revalidatePath inside the action fixes the base prop
        });
      }
    
      return (
        <li data-renders={renders.current}>
          <span>{invoice.number}</span>
          <button type="button" onClick={onToggle} aria-pressed={optimisticPaid}>
            {optimisticPaid ? 'Paid' : 'Unpaid'}
          </button>
        </li>
      );
    }
    

    以下是服务器动作本身,测试框架可根据需要开启故障切换功能:

    // app/invoices/actions.ts
    'use server';
    
    import { revalidatePath } from 'next/cache';
    import { z } from 'zod';
    import { setPaid } from '@/lib/invoices';
    
    const ToggleSchema = z.object({
      id: z.string().uuid(),
      paid: z.boolean(),
    });
    
    export type ToggleResult =
      | { ok: true }
      | { ok: false; code: 'VALIDATION' | 'BIZ'; message: string };
    
    let FAIL_MODE:
      | 'none'
      | 'throw'
      | 'soft'
      | 'zod'
      | 'race' = 'none';
    
    export function __setFailMode(mode: typeof FAIL_MODE) {
      FAIL_MODE = mode;
    }
    
    export async function togglePaid(
      id: string,
      paid: boolean,
    ): Promise<ToggleResult> {
      await new Promise((r) => setTimeout(r, 400)); // visible optimistic window
    
      if (FAIL_MODE === 'throw') {
        throw new Error('DB write failed');
      }
    
      const parsed = ToggleSchema.safeParse({ id, paid });
      if (!parsed.success || FAIL_MODE === 'zod') {
        return {
          ok: false,
          code: 'VALIDATION',
          message: 'Invalid toggle payload',
        };
      }
    
      if (FAIL_MODE === 'soft') {
        return { ok: false, code: 'BIZ', message: 'Invoice locked' };
      }
    
      await setPaid(id, paid);
    
      if (FAIL_MODE === 'race') {
        // succeed, revalidate, then a second overlapping call fights it
        revalidatePath('/invoices');
        return { ok: true };
      }
    
      revalidatePath('/invoices');
      return { ok: true };
    }
    

    故障模式1 — 服务器动作抛出异常

    FAIL_MODE = 'throw'。该动作在400毫秒延迟后抛出异常,客户端端没有任何代码能够捕获它。这正是文档中所描述的场景。

    预期结果:转换过程完成,底层的invoice.paid值保持不变,乐观锁层消失,按钮状态恢复为“未支付”。

    实际观察结果(5次测试全部如此):

    • t=0毫秒:点击操作被记录,标签立即变为“已支付”(即乐观锁的即时显示效果)
    • t≈400毫秒:异常出现,导致转换过程终止
    • t≈410毫秒:标签状态又变回“未支付”
  • 该行的平均渲染次数:4次(初始加载、乐观更新、回滚,随后是一次静默的RSC处理)
  • 显示给用户的错误信息:
  • 控制台输出:未处理的服务器操作错误,在开发模式下会以Next.js的红色框形式显示
  • 回滚行为:符合文档描述。错误处理:存在问题。从用户视角来看,发票状态先显示为“已支付”约400毫秒,随后便在没有任何解释的情况下恢复为“未支付”。从技术层面讲这符合“自动回滚”的描述,但在实际产品中使用则毫无用处。如果从文档中只理解了“失败时会回滚”这一点,那么最终推出的产品就会是这种状况。

    我还追踪了父服务器组件是否重新渲染。它没有——invoice.paid 的值始终没有变化。界面恢复原状完全是因为乐观预测层消失了,而非因为执行了任何逆向更新操作。客户端上没有任何setPaid(false)的调用。基础属性的值完全保持初始状态,因此一旦乐观预测层消失,界面就会再次显示该基础值。这就是整个机制,而在讨论下面的软故障情况时,这一点就显得尤为重要。

    故障模式2 — 软{ ok: false },无异常抛出

    这正是团队们常犯错的环节。许多实现方式并非直接抛出异常,而是返回结构化结果,从而使错误路径具有正确的类型。这看似是合理的选择——但若客户端代码从未检查过该返回值,从 React 的角度来看,整个流程仍然会成功处理

    // still the happy-tutorial handler
    startTransition(async () => {
      setOptimisticPaid(!optimisticPaid);
      await togglePaid(invoice.id, !optimisticPaid); // returns { ok: false }
    });
    

    FAIL_MODE = 'soft'。底层存储数据未被修改。revalidatePath也从未被触发。该操作以{ ok: false, code: 'BIZ', message: 'Invoice locked' }的结果结束——没有抛出任何异常。

    如果依赖“失败时自动回滚”功能,你期望看到的情况是:由于操作未成功,用户界面会恢复到初始状态。

    使用上述最简处理程序时的观察结果(5次测试全部如此):

    • 乐观状态会立即变为“已付费”
  • 过渡正常完成(承诺得到履行)
  • 基础属性仍为 false
  • 过渡结束后覆盖层会消失,因此标签会恢复为“未支付”状态
  • 平均渲染次数:4
  • 面向用户的信息:仍然为,因为返回的 res 从未被读取过
  • 即便是在“表现良好”的版本中,也会正确回退。轻微故障不会导致乐观值自行保留——无论操作是否抛出异常,一旦操作完成,覆盖层就会消失。文档中对抛出异常的强调仅描述了典型情况,并非唯一情形。任何未改变基础状态就完成的操作都会回退。

    那么,那些幽灵般的界面究竟从何而来?

    当处理程序试图通过在 Promise 解决时立即更新本地基础状态来显得“聪明”时就会出现这种情况——例如,如果你将付费标志映射到 useState 中,并在检查 ok 之前就设置该状态:

    // the lie I actually shipped once
    startTransition(async () => {
      setOptimisticPaid(true);
      const res = await togglePaid(id, true);
      setLocalPaid(true); // always — "the action finished"
      if (!res.ok) setError(res.message); // too late, base already moved
    });
    

    采用这种模式时(5/5 次运行):

    • 即使发生错误,按钮仍会显示为已付费
    • 该行下方可能会显示错误信息
    • 底层的 RSC 存储仍显示为未付费状态
    • 下一次导航或后续的重新验证会使该行恢复原状——从而产生一种幽灵状态,直到有操作强制刷新为止

    总结评分规则:若为无本地数据库更新的软故障,则回滚功能有效,但用户无法获得任何反馈;而当软故障与立即执行的本地数据库更新同时发生时,就会产生幽灵界面。后一种情况在后续的评分表中会显示为故障模式2。真正的缺陷并非{ ok: false }这个数值本身,而是将“操作已完成”误认为是“操作成功”。

    故障模式3 — Zod验证,结构化错误,无异常抛出

    FAIL_MODE = 'zod'。从结构上看这与模式2类似,只是触发方式不同。safeParse调用失败(或强制进入故障分支),此时操作会返回{ ok: false, code: 'VALIDATION', message: 'Invalid toggle payload' }。不会抛出任何异常,同时也会跳过revalidatePath操作。

    这个案例值得单独讨论,因为团队往往将验证错误视为本质上“安全”的——它们是有类型定义的、可预见的,并且会得到有针对性的处理。用户却无法察觉到这些细微差别。在他们看来,切换按钮只是闪了一下就没了反应。

    在仅当ok为真时才更新基础状态的客户端上观察到的结果(5次测试中的5次):

    • “已付费”状态会显示出来,过渡结束后又恢复为“未付费”状态
    • 平均渲染次数:4次
    • “已付费”标签可见的时间:大约400–420毫秒
    • 面向用户的信息:,除非代码根据res值进行显式分支处理

    虽然 TypeScript 会强制规定验证错误的格式,因此这些错误看起来更可靠,但这并不意味着接口就会更好——实际上只是错误提示不那么明显而已。回滚时的表现完全相同,沉默的状态也依旧存在。如果表单使用了 useActionState 并将返回值映射到其 state 中,那么这些提示信息在状态转换时仍会保留。而那种简单的教程式行组件则无法做到这一点。

    需要澄清的一点是:在调用 setOptimistic 之前就在客户端执行 Zod 验证,可以防止“已付费”状态被显示出来。模式 3 特指在乐观渲染已经发生之后才出现的服务器端验证失败情况。正是这种执行顺序导致了故障。

    故障模式 4 — 在 startTransition 之外调用 addOptimistic

    function onToggle() {
      // 🚩 outside a Transition
      setOptimisticPaid(!optimisticPaid);
      startTransition(async () => {
        await togglePaid(invoice.id, !optimisticPaid);
      });
    }
    

    文档正好警告了这种情况:如果在不使用 Transition 或 Action 将乐观状态封装起来的情况下进行更新,该变化会短暂显示片刻,然后几乎立刻恢复到原始值,因为在底层操作完成期间没有过渡作用域来保持其状态。

    由于没有 Transition 将调用封装起来,因此在异步操作执行期间没有任何机制能维持该预测状态。React 没有可以附加乐观值的作用域,因此它就会立即恢复原状。

    观察结果(5/5):

    • 状态会迅速变为“已付费”,通常只持续一帧时间,偶尔为两帧
    • 几乎在400毫秒的操作完成之前就会立即恢复为“未付费”状态
    • 每次点击时都会在 DevTools 中出现 React 警告信息
  • 渲染次数:3次(初始加载、闪光效果、恢复状态),在操作成功后还会进行一次RSC刷新
  • 在正常流程中,一旦执行完revalidatePath,随着新服务器数据的到达会再次触发状态变化,用户会先感受到一次瞬时波动,随后数据才会被正式提交
  • 这并非由错误引发的回滚,更准确的描述应是“数据从未真正被保留”。故障模式4属于编码错误而非后端问题,但其产生的视觉效果与用户所认为的程序不稳定现象相同。它之所以被列入此列表,是因为当有人为了代码整洁而重新组织处理逻辑,将setOptimistic置于startTransition之上时,这是最先出问题的情况。

    故障模式5 ——revalidatePath执行成功,但出现双击竞争问题

    FAIL_MODE 设置为 'race'。测试框架会在50毫秒内触发两次点击操作。两次操作都会引发状态转换,并都尝试以乐观模式将状态设为已付费。第一次写入操作完成后会重新验证状态;第二次写入操作则独立继续执行。

    模拟商店中的 setPaid(id, paid) 方法是设置一个绝对值,而非在数据库中修改布尔值,因此真正的缺陷在于客户端侧的闭包:

    setOptimisticPaid(!optimisticPaid);
    await togglePaid(invdsoice.id, !optimisticPaid);
    

    当第二次点击的触发时间足够快时,该闭包中存储的 optimisticPaid(或 invoice.paid)的值要么是第一次点击之前的值,要么是从仍在处理中的乐观更新操作中读取到的值——具体结果取决于精确的时间点。最终这两次请求中会有一次发送 paid: false

    观察结果(使用过时的切换逻辑时5次测试全部如此):

    • 首次点击时显示为“已付费”状态
    • 大约50毫秒后再次点击时,至少在5次测试中的4次会传入错误的绝对值
    • 会触发两次独立的revalidatePath刷新操作
    • 尽管用户已看到状态先变为“已付费”,但RSC层最终显示的仍是未付费——出现了瞬间变化后又恢复原状的情况
    • 该行的最坏情况下渲染次数可达9次:两次乐观预测的绘制、两次确定的操作、两次RSC刷新,再加上基础渲染次数

    解决方案是依据与点击意图相关的固定起点来计算下一个值,而非依赖闭包中当前的值,并在optimisticPaid !== invoice.paid时禁用切换功能。

    const next = !invoice.paid; // from base, not from a racing optimistic read
    startTransition(async () => {
      setOptimisticPaid(next);
      const res = await togglePaid(invoice.id, next);
    });
    

    如果跳过该修复,即使在成功路径上也会存在幽灵界面——不会抛出任何异常,Zod也永远不会运行,但界面依然会向用户展示错误信息。正因如此,模式5应归类到幽灵界面组而非回滚组。

    得分板

    mode | trigger                         | rollback | user error | final UI vs server | avg renders | score
    -----|---------------------------------|----------|------------|--------------------|-------------|------
    1    | throw Error (500-ish)           | yes      | none       | match (Unpaid)     | 4           | rollback OK / UX fail
    2    | {ok:false} + eager base update  | no*      | maybe      | GHOST (Paid lie)   | 3           | ghost
    3    | Zod structured error, no throw  | yes      | none       | match (Unpaid)     | 4           | rollback OK / UX fail
    4    | setOptimistic outside transition| flash    | warning    | match after twitch | 3           | flash then revert
    5    | revalidate + double-fire race   | n/a      | none       | GHOST / flicker    | 7–9         | ghost
    
    * Mode 2 rolls back if you never touch base on failure. It ghosts if you set local/base on settle.
    Three clean rollbacks: 1, 3, and 2-without-eager-base.
    Two ghost paths: 2-with-eager-base, 5.
    Mode 4 is a flash, not a held ghost — still a user-visible failure.
    

    现在有数据支持副标题的说法:三种故障模式会触发回滚,而两种则会留下幽灵界面。模式1和3,再加上处理得当的模式2,属于回滚组;模式2-eager和模式5则属于幽灵界面组。模式4则是特例——它无法让乐观锁定的覆盖层持续足够长的时间,因此无法明确归入上述任意一类。

    修复版本:捕获异常、顺利过渡、可选使用actionState

    每当出现异常时,回滚功能早已能够正常工作。所欠缺的是在转换过程结束后仍存在的错误处理机制,以及只有在结果真正为 ok: true 时才会更新的基准值。

    // app/invoices/invoice-row.tsx — fixed
    'use client';
    
    import {
      useOptimistic,
      useState,
      useTransition,
      useRef,
    } from 'react';
    import { togglePaid, type ToggleResult } from './actions';
    import type { Invoice } from '@/lib/invoices';
    
    export function InvoiceRow({ invoice }: { invoice: Invoice }) {
      const [error, setError] = useState<string | null>(null);
      const [isPending, startTransition] = useTransition();
      const [optimisticPaid, setOptimisticPaid] = useOptimistic(invoice.paid);
      const renders = useRef(0);
      renders.current += 1;
    
      const pending = optimisticPaid !== invoice.paid || isPending;
    
      function onToggle() {
        const next = !invoice.paid; // absolute next from server base
        setError(null);
    
        startTransition(async () => {
          setOptimisticPaid(next);
          try {
            const res: ToggleResult = await togglePaid(invoice.id, next);
            if (!res.ok) {
              // transition will end; base unchanged → automatic revert
              // error state is plain useState → survives the revert
              setError(res.message);
              return;
            }
            // success: revalidatePath in the action updates invoice.paid
          } catch (e) {
            setError(e instanceof Error ? e.message : 'Toggle failed');
          }
        });
      }
    
      return (
        <li data-renders={renders.current}>
          <span>{invoice.number}</span>
          <button
            type="button"
            onClick={onToggle}
            disabled={pending}
            aria-pressed={optimisticPaid}
            aria-busy={pending}
          >
            {optimisticPaid ? 'Paid' : 'Unpaid'}
          </button>
          {error ? (
            <p role="alert" className="row-error">
              {error}
              <button type="button" onClick={onToggle}>
                Retry
              </button>
            </p>
          ) : null}
        </li>
      );
    }
    

    实际发生的变更如下:

    1. setOptimisticPaid 现仅在 startTransition 内部触发,从而完全移除了模式 4。
    2. next 的值不再从可能存在竞争条件的乐观值中读取,而是基于已确认的 invoice.paid 值计算,这降低了模式 5 的风险。
    3. 一旦覆盖层与基准值出现差异,就会通过 disabled={pending} 禁用相关功能,从而防止双击竞争问题。
    4. 通过 try/catch 结构包裹异常处理逻辑,使得模式 1 在回滚执行后会显示相应提示信息。
  • res.ok 的值为假时,只有错误状态会更新,基础状态保持不变,因此模式2和3会回滚,并同时说明原因。
  • 错误信息本身存储在 useState 中,而非乐观值内部,因此即使覆盖层被移除,错误信息依然存在。
  • 第六点我花了一些时间才理解透彻。如果将错误信息放在乐观值对应的还原函数中,那么一旦相关操作完成,该错误信息就会立即消失——回滚操作会同时清除自定义的消息以及过时的用户界面。而普通的 useState(或 useActionState 返回的状态)则是覆盖层消失后依然存在的信息通道。

    可选:表单形式版本可使用 useActionState

    如果将切换功能实现为 <form action>,则可以让 useActionState 在状态转换时携带上一次的结果,而无需手动管理该状态:

    'use client';
    
    import { useOptimistic, useActionState } from 'react';
    import { togglePaidForm, type ToggleResult } from './actions';
    import type { Invoice } from '@/lib/invoices';
    
    const initial: ToggleResult | null = null;
    
    export function InvoiceRowForm({ invoice }: { invoice: Invoice }) {
      const [optimisticPaid, setOptimisticPaid] = useOptimistic(invoice.paid);
      const [state, formAction, pending] = useActionState(
        async (_prev: ToggleResult | null, formData: FormData) => {
          const next = formData.get('next') === 'true';
          setOptimisticPaid(next); // form action is already an Action
          return togglePaidForm(String(formData.get('id')), next);
        },
        initial,
      );
    
      return (
        <form action={formAction}>
          <input type="hidden" name="id" value={invoice.id} />
          <input type="hidden" name="next" value={String(!invoice.paid)} />
          <button type="submit" disabled={pending} aria-pressed={optimisticPaid}>
            {optimisticPaid ? 'Paid' : 'Unpaid'}
          </button>
          {state && !state.ok ? (
            <p role="alert">{state.message}</p>
          ) : null}
        </form>
      );
    }
    

    规则没有变化。设置操作仍会在 Action 内部执行。只有在重新验证成功后,基础值才会更新。一旦覆盖层消失,最新的错误信息仍会保留在 state 中。当控件本身就是表单时,应采用这种模式;对于紧凑的表格行,则可使用按钮加上 useTransition 的版本。

    应用修复后的得分

    针对已修正的行,再次进行了相同的五种故障模式测试,每种模式各进行五次。

    mode | after fix                                              | ghost? | avg renders
    -----|--------------------------------------------------------|--------|------------
    1    | rollback + role="alert" with thrown message            | no     | 4
    2    | rollback + "Invoice locked" stays visible              | no     | 4
    3    | rollback + "Invalid toggle payload" stays visible      | no     | 4
    4    | eliminated (setter only in transition / form action)   | no     | n/a
    5    | button disabled while pending; absolute next value     | no*    | 3–4
    
    * Pathological manual double-submit via Playwright force-click still managed one flicker in 1/5 trials when I removed disabled. With disabled left on: 0/5 ghosts.
    

    在正常成功的流程中,该行会进行3次渲染——首先加载数据并进行乐观绘制,然后执行RSC同步。而当出现带消息的故障时,渲染次数会增加到4次——加载数据、乐观绘制、回滚操作,最后再绘制错误提示。这第4次渲染就是为这样一行数据所付出的代价。

    在五种人为引发的故障中,有三种会自动回滚。还有两种会留下“幽灵界面”:一种是会立即更新基础数据的软故障,另一种则是双重触发竞争条件导致的故障。

    模式1下的渲染轨迹

    这是模式1第三次测试中捕获的原始performance.now()时间戳,此时严格模式已关闭,且仅加载了一行数据。

    0.0     click
    2.1     optimistic commit — label=Paid, renders=2
    401.8   action throw
    403.2   transition end — label=Unpaid, renders=3
    403.9   setError in fixed build — renders=4, alert visible
    

    用有问题的教程版本进行相同测试时,程序会在renders=3处卡住,且不会显示任何提示。那第四次绘制正是正常组件与有缺陷组件之间的关键差异。回滚本身从来都不是难点——真正的难题在于在乐观更新层消失后仍要保持状态通道的活跃。

    那第四次绘制的重要性远超过缩短乐观更新路径中的几毫秒延迟。如果界面能向用户解释原因,他们可以容忍标签显示错误400毫秒;但他们绝不会接受一个先故意给出错误信息,之后又在不被注意的情况下悄悄自行修正的标签,直到有人在例会上询问为止。

    下一次提交 Pull Request 时的注意事项

    useOptimistic会提供一个临时覆盖层,其存在时间仅持续到状态转换完成为止。如果相关操作抛出异常且基础状态从未被更新,React会将界面恢复原状。在没有进行基础状态更新的柔和{ ok: false }响应情况下,也会以相同方式恢复界面。这两种行为都与文档描述一致,并且都已通过实际测试得到验证。

    文档并未自动提供以下功能:

    • 状态恢复后给用户显示的清晰提示信息
    • 即便仍尝试更新基础状态,也能防止出现柔和的{ ok: false }情况
    • 防止在状态转换边界之外调用设置器函数
    • 结合revalidatePath使用时的快速双击操作下的幂等切换功能

    自动回滚功能确实如宣传的那般有效。出色的错误处理并非无需代价。在五个故意设置的故障场景中,有三个会自动恢复;另外两个则一直显示过时的用户界面,直到“操作完成”不再被视为与“操作成功”同义。

    以下是一份简短的检查清单,值得粘贴到代码审查中使用:

    1. setOptimistic是在startTransition内部执行,还是通过表单的action属性执行?
    2. 只有在确认res.ok为真时,或者发生非异常的成功情况且触发了重新验证后,才会更新基础数据(或其本地副本)吗?
    3. 错误信息是存储在useState中,还是存储在与乐观还原器分离的useActionState中?
    4. 下一个值是否是根据服务器的基础状态计算得出的,并且在操作处理期间禁用控件?
  • 真的有人在浏览器中同时测试过抛出异常的路径和 { ok: false } 路径,而不仅仅是正常流程的切换吗?
  • 如果教程中的示例仅停留在调用 setOptimistic 并等待操作完成,那其实就已经使用了静默失败版本。需要捕获异常并检查结果,将错误存储在 useStateuseActionState 中。每当界面显示与服务器反馈不一致时,就立即禁用该控件。做到这一点,React 自带的回滚功能就能真正让实际用户接受。

    相关阅读

  • 为何 Next.js Server Actions 需要在每个函数体中进行授权 — 通过一个详细的账户劫持案例,说明了未经身份验证的 Next.js Server Actions 如何暴露特权操作,以及应在何处进行授权检查以阻止此类行为。
  • 每个 React 和 Next.js 应用都需要的五种前端安全防护措施 — 说明为何生产环境中的 React 和 Next.js 应用会使用 HttpOnly Cookie、CSP、DOMPurify、安全头部以及 NEXT_PUBLIC_ 规则,以及每种防护措施能抵御哪些攻击。