Inicio / Artículos / Reversión de useOptimistic: Cinco modos de fallo en las acciones del servidor de Next.js

Reversión de useOptimistic: Cinco modos de fallo en las acciones del servidor de Next.js

Entienda por qué useOptimistic reversiona silenciosamente la interfaz de usuario sin explicar los fallos a los usuarios, a través de cinco modos de fallo probados en las acciones del servidor y una solución funcional.

4375 palabras

El retroceso automático funciona tal como se prometió. Pero mostrar el mensaje de error al usuario no lo hace. Intencionalmente activé cinco rutas diferentes de fallo en un botón de acción de servidor en Next.js y registré lo que realmente apareció en la pantalla.

El retroceso no cuesta nada. Comunicar el fallo sí lo hace. El estado de pago cambia y luego vuelve a su estado original en silencio, sin ninguna explicación que el usuario pueda leer.

La mayoría de los tutoriales tratan a useOptimistic como un botón de deshacer gratuito. Haces clic, la interfaz cambia, la solicitud falla y la interfaz vuelve al punto de partida. Nada más.

Quería comprobar si esa promesa se mantiene cuando las cosas se complican. Por eso creé una pequeña lista de facturas en Next.js App Router: cinco filas, cada una conectada a su propia acción de servidor, e implementé cinco formas diferentes en el código real en las que esa acción podía fallar. No casos extremos inventados, sino los errores que llegan a producción sin que nadie se dé cuenta.

En cinco ejecuciones por modo de fallo, tres de ellas revertieron correctamente la interfaz. Dos dejaron que la interfaz mostrara información incorrecta. La reversión automática funciona cuando la acción lanza un error. No funciona cuando la acción devuelve silenciosamente algo como { ok: false } en lugar de lanzar un error; ese patrón es una forma sutil de engañar al usuario sin intención.

Para referencia, esto se desarrolló con la versión 15.5.2 de Next.js, junto con React 19.1.1, compilado con TypeScript 5.9.2, y ejecutándose bajo la línea 22.x de Node en una máquina Linux. Las cifras que siguen provienen de ese entorno exacto. Si vuelve a ejecutar el mismo conjunto de pruebas en una máquina diferente, los tiempos podrían variar ligeramente, pero la forma de cada fallo debería mantenerse igual.

Lo que realmente prometen los documentos

La página de referencia oficial para useOptimistic establece claramente que el valor optimista solo se muestra mientras una acción está en ejecución; una vez que esta finaliza, React recurre a mostrar el valor real que contiene actualmente el value.

También explica qué sucede cuando algo falla. En resumen: un error no capturado dentro de la Action sigue permitiendo que la Transición pendiente se complete normalmente. Dado que el código circundante suele escribir en el value real solo después de una llamada exitosa, un error significa que ese valor nunca se modificó; por lo tanto, una vez finalizada la Transición, React simplemente muestra la misma interfaz que el usuario veía antes de que ocurriera el clic. La documentación indica que se espera que usted mismo capture ese error si desea mostrar algún tipo de mensaje al usuario; React no lo hará por usted.

Hay dos detalles adicionales importantes aquí:

  1. El configurador optimista debe ejecutarse dentro de una Action o dentro de startTransition. Si se ejecuta fuera de ellos, React registra una advertencia, y la interfaz optimista solo aparece brevemente antes de desaparecer.
  • El retroceso no es algo que se active manualmente; simplemente ocurre por defecto cuando la transición finaliza y el valor subyacente nunca se modificó.
  • Ese segundo punto es, en realidad, la idea central de todo esto. Revertir la interfaz de usuario se hace automáticamente. Decidirle al usuario por qué ocurre está en tus manos. El mecanismo muestra un valor predicho mientras haya una acción pendiente, y luego se ajusta al valor real del elemento padre. Si ejecutas una acción sin cambiar ese valor base, se produce un retroceso. Si la resuelves con éxito sin modificarlo, también hay retroceso. Pero si la resuelves con éxito y actualizas el valor base con un resultado incorrecto, entonces se genera un estado fantasma: una interfaz que muestra algo que en realidad nunca ocurrió en el servidor.

    Miniapp: alternador para facturas pagadas

    En lugar de ser un ejemplo trivial, la aplicación de prueba imita una pantalla de facturación, ya que es allí precisamente donde una etiqueta “Pagado” incorrecta provoca una llamada telefónica del departamento de cobros.

    Así es como está estructurada:

    • Una página RSC carga cinco facturas desde un almacenamiento en memoria (INV-1001 hasta INV-1005), todas sin pagar al inicio.
    • Cada fila se muestra como un componente cliente independiente que contiene un valor booleano de tipo optimista.
    • Al hacer clic en el botón se llama a una acción del servidor con un valor booleano paid explícito.
    • revalidatePath('/invoices') solo se ejecuta cuando la acción tiene éxito.
    • Cada fila cuenta con un contador de renderizado que aumenta en cada actualización. El Modo Estricto está desactivado para evitar que este contador se infle debido a llamadas duplicadas.
  • La acción incluye un await sleep(400) artificial para que el período optimista sea lo suficientemente largo como para poder observarlo visualmente y medirlo con 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>
      );
    }
    

    La regla para cada ejecución de prueba: restablecer el almacén de facturas a estado no pagado, establecer FAIL_MODE como activo, hacer clic en el botón de alternancia una vez (dos veces para el modo cinco), esperar 800 ms después de que la acción se resuelva, luego verificar el texto del botón, revisar data-renders, anotar cualquier advertencia en la consola y tomar una captura de pantalla. Cada modo se ejecutó cinco veces, siempre con el mismo ID de factura, sin utilizar React Query ni ninguna capa de caché externa; solo se emplearon propiedades RSC, useOptimistic y una única acción del servidor.

    El componente de la fila del “camino feliz” parece sacado de cualquier tutorial introductorio:

    // 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>
      );
    }
    

    Y aquí está la acción del servidor en sí, con un interruptor de fallo que el conjunto de pruebas puede activar según sea necesario:

    // 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 };
    }
    

    Modo de fallo 1: la acción del servidor lanza una excepción

    FAIL_MODE = 'throw'. La acción lanza una excepción después de un retraso de 400 ms. Nada en el cliente la captura. Este es exactamente el escenario que se explica en la documentación.

    Esperado: la transición finaliza, el valor de invoice.paid no cambia nunca, la capa optimista desaparece y el botón vuelve a mostrar “Sin pagar”.

    Observado (5 de 5 ejecuciones):

    • t=0 ms: al hacer clic se registra la acción, y la etiqueta cambia inmediatamente a “Pagado” (el dibujo optimista)
    • t≈400 ms: surge la excepción, lo que pone fin a la transición
    • t≈410 ms: la etiqueta vuelve a mostrar “Sin pagar”
  • Cantidad promedio de renderizaciones para la fila: 4 (montaje inicial, actualización optimista, reversión, y luego un proceso RSC silencioso)
  • Mensaje de error visible para el usuario: ninguno
  • Salida de la consola: un error de Acción del Servidor no manejado, mostrado como un cuadro rojo de Next.js en modo desarrollo
  • Comportamiento de reversión: funciona según lo documentado. Manejo de errores: defectuoso. Desde la perspectiva del usuario, la factura mostró “Pagado” durante aproximadamente 400 milisegundos y luego volvió a “No pagado” sin ninguna explicación. Técnicamente esto coincide con la opción de “reversión automática”. En la práctica, es inutilizable en un producto real. Si lo único que tomaste de la documentación fue “se realiza una reversión en caso de error”, este es el resultado que terminarías entregando.

    También verifiqué si el componente del servidor padre se volvía a renderizar. No lo hizo: invoice.paid nunca cambió de posición. El retorno a la situación anterior se produjo únicamente porque el overlay optimista desapareció, y no porque se ejecutara alguna actualización inversa. No hubo ninguna llamada a setPaid(false) en el cliente. La propiedad base permaneció exactamente donde estaba al principio, por lo que una vez que la capa optimista desapareció, la interfaz de usuario simplemente mostró nuevamente el valor base. Ese es todo el mecanismo, y resulta importante cuando analicemos el caso de fallo leve a continuación.

    Modo de fallo 2 — Fallo leve { ok: false }, sin lanzamiento de excepción

    Aquí es donde los equipos suelen cometer errores. En lugar de lanzar una excepción, muchas implementaciones devuelven un resultado estructurado para que la ruta de error tenga un tipo adecuado. Es una elección razonable, excepto que si el código del cliente nunca inspecciona ese valor devuelto, la transición sigue resolviéndose con éxito desde el punto de vista de React.

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

    FAIL_MODE = 'soft'. El almacén subyacente permanece sin cambios. revalidatePath nunca se ejecuta. La acción se resuelve con { ok: false, code: 'BIZ', message: 'Invoice locked' }; no se lanza ninguna excepción.

    Lo que esperarías si confías en la opción de “reversión automática en caso de fallo”: la interfaz de usuario vuelve a su estado anterior porque la mutación no tuvo éxito.

    Observado con el manejador básico anterior (5/5 ejecuciones):

    • El estado optimista cambia a “Pagado”
  • La transición se completa normalmente (la promesa se cumple)
  • El valor de la propiedad base permanece en false
  • La superposición desaparece una vez finalizada la transición, por lo que la etiqueta vuelve a mostrar “Sin pagar”
  • Número promedio de renderizaciones: 4
  • Mensaje para el usuario: sigue siendo ninguno, ya que el valor res devuelto nunca se leyó
  • Por lo tanto, incluso la versión “bien comportada” vuelve a su estado inicial correctamente. Un fallo leve no hace que el valor optimista permanezca por sí solo; la superposición desaparece en cuanto la acción se resuelve, independientemente de si hubo error o no. El énfasis en los errores en la documentación describe el caso típico, no el único. Cualquier acción que se resuelva sin afectar el estado base volverá a su valor original.

    Entonces, ¿de dónde proviene realmente esa interfaz fantasma?

    Aparece cuando el manejador intenta ser “inteligente” al actualizar el estado base local cada vez que se resuelve la promesa; por ejemplo, si reflejas el estado de pago en un useState y lo estableces antes de verificar 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
    });
    

    Con ese patrón implementado (5/5 ejecuciones):

    • El botón permanece fijo en Paid incluso después del fallo
    • Puede aparecer un mensaje de error debajo de la fila
    • El almacén RSC subyacente sigue mostrando Unpaid
    • La próxima navegación o cualquier revalidación posterior vuelve a establecer la fila en su estado anterior, generando un estado fantasma que persiste hasta que algo fuerce una actualización

    En resumen de la puntuación: un fallo leve sin actualización local de la base significa que el retroceso funciona, pero no se obtiene retroalimentación del usuario. Un fallo leve combinado con una actualización local inmediata de la base produce una interfaz fantasma. Ese segundo caso es el que aparece como modo de fallo 2 en la tabla de puntuaciones más tarde. El verdadero defecto no es la forma { ok: false } en sí, sino tratar “la acción finalizó” como equivalente a “la acción tuvo éxito.”

    Modo de fallo 3: validación Zod, error estructurado, sin lanzamiento de excepción

    FAIL_MODE = 'zod'. Estructuralmente esto es similar al modo 2, solo que se activa de manera diferente. Una llamada a safeParse falla (o se fuerza la rama de fallo), y la acción devuelve { ok: false, code: 'VALIDATION', message: 'Invalid toggle payload' }. No se lanza ninguna excepción, y se omite revalidatePath.

    Este caso merece un tratamiento aparte, ya que los equipos tienden a considerar los errores de validación como inherentemente “seguros”: son predecibles, se anticipan y se manejan de forma intencionada. Los usuarios no perciben ninguna de estas sutilezas. Desde su punto de vista, el botón simplemente parpadeó y luego dejó de funcionar.

    Observado (5/5 ejecuciones) con un cliente que actualiza únicamente el estado base cuando ok es verdadero:

    • Aparece el estado “Optimistic Paid”, la transición finaliza y luego vuelve al estado “Unpaid”
    • Número promedio de renderizaciones: 4
    • Duración durante la cual estuvo visible la etiqueta “Paid”: aproximadamente 400–420 ms
    • Mensaje para el usuario: ninguno, a menos que el código tome un camino distinto en función de res

    Los errores de validación pueden parecer más fiables porque TypeScript impone su estructura, pero eso no se traduce en una mejor interfaz; de hecho, solo son más silenciosos. El proceso de reversión funciona de manera idéntica, y el silencio es el mismo. Si el formulario hubiera utilizado useActionState y mapeado el valor devuelto a su state, el mensaje podría haber sobrevivido a la transición. El componente de fila en estilo tutorial simple no hace eso.

    Una aclaración importante: ejecutar la validación con Zod en el cliente *antes* de llamar a setOptimistic evitaría que el estado Paid se mostrara nunca. El modo 3 aborda específicamente la validación del lado servidor que falla *después* de que ya se haya producido la visualización optimista. Ese orden es precisamente lo que causa el problema.

    Modo de fallo 4: llamar a addOptimistic fuera de startTransition

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

    La documentación advierte exactamente sobre esta situación: si actualizas el estado optimista sin envolverlo en una Transición o una Acción, el cambio aparecerá por un momento y luego volverá casi al instante a su valor original, ya que no hay un ámbito de transición que lo mantenga en su lugar mientras se completa el trabajo subyacente.

    Sin una Transición que envuelva la llamada, no hay nada que mantenga viva la predicción mientras se ejecuta el trabajo asíncrono. React no cuenta con un ámbito al que pueda asociar el valor optimista, por lo que simplemente vuelve a su estado original.

    Observado (5/5):

    • Un breve destello hacia “Pagado”, a menudo solo un único frame, ocasionalmente dos actualizaciones
    • Una reversión inmediata a “No pagado”, que ocurre antes de que se resuelva siquiera la acción de 400 ms
    • Una advertencia de React en DevTools con cada clic
  • Cantidad de renderizaciones: 3 (montaje inicial, el destello, la reversión), seguidas posteriormente por una actualización RSC una vez que la acción tiene éxito
  • En el escenario favorable, una vez que se ejecuta revalidatePath, hay un segundo cambio al llegar datos frescos del servidor, lo que provoca una pequeña interrupción en la interfaz seguida de un commit diferido
  • Esto no es un retroceso provocado por un error. Es mejor describirlo como “nunca se mantiene realmente”. El modo de fallo 4 es un error de programación y no un problema del backend, pero el resultado visual es la misma interrupción que el usuario atribuiría a inestabilidad. Ocupa un lugar en esta lista porque es lo primero que falla cuando alguien refactorea un manejador y coloca setOptimistic antes que startTransition en aras de la organización.

    Modo de fallo 5: revalidatePath tiene éxito, pero hay una competencia por doble clic

    Establezca FAIL_MODE en 'race'. El conjunto de pruebas dispara dos clics dentro de 50 ms el uno del otro. Ambos provocan transiciones y ambos intentan actualizar la información hacia el estado “Pagado” de forma optimista. La primera escritura se completa y vuelve a validar la información; la segunda escritura continúa de forma independiente.

    La función setPaid(id, paid) de la tienda simulada establece un valor absoluto en lugar de modificar un booleano en la base de datos, por lo que el verdadero defecto se encuentra en el cierre del lado del cliente:

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

    Cuando el segundo clic se dispara lo suficientemente rápido, lo que quiera que contenga optimisticPaid (o invoice.paid) dentro de ese cierre es o bien el valor anterior al primer clic, o bien un valor leído mientras aún se procesa la actualización optimista; el resultado depende del momento exacto. Una de las dos solicitudes termina enviando paid: false.

    Observado (5/5 con la lógica de conmutación obsoleta):

    • El primer clic muestra “Pagado”
    • El segundo clic, aproximadamente 50 ms después, envía el valor absoluto incorrecto en al menos 4 de cada 5 pruebas
    • Se generan dos oleadas separadas de actualización con revalidatePath
    • La propiedad final proveniente de la capa RSC indica Por pagar, a pesar de que el usuario vio cómo pasaba a “Pagado” primero; es un resultado con parpadeo seguido de imagen fantasma
    • En el peor caso, la cantidad de renderizaciones en esa fila llega a 9: dos pinturas optimistas, dos acciones finalizadas, dos actualizaciones de RSC, más las renderizaciones base

    La solución consiste en calcular el valor siguiente a partir de un punto de partida fijo relacionado con la intención del clic, y no según lo que suceda accidentalmente en el cierre, además de desactivar el interruptor mientras 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);
    });
    

    Si se omite esa corrección, la interfaz fantasma persiste incluso en el camino de éxito: no se genera ningún error, Zod nunca se ejecuta, y aun así la interfaz sigue engañando al usuario. Por eso el modo 5 debe clasificarse en la columna de interfaz fantasma y no en la de reversión.

    Puntuación

    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.
    

    La afirmación del subtítulo, ahora respaldada por cifras: tres modos de fallo provocan una reversión, mientras que dos dejan atrás la interfaz fantasma. Los modos 1 y 3, además del modo 2 si se maneja con cuidado, forman el grupo de reversión. Los modos 2-eager y 5 componen el grupo de interfaz fantasma. El modo 4 es un caso especial: nunca mantiene la superposición optimista el tiempo suficiente como para clasificarse claramente en ninguna de las categorías.

    Versión corregida: capturar errores, sobrevivir a la transición, uso opcional de actionState

    El retroceso ya funcionaba cada vez que ocurría algún error. Lo que faltaba era un error que persistiera más allá del final de la transición, además de un valor base que solo avanzara cuando el resultado fuera realmente 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>
      );
    }
    

    Esto es lo que realmente cambió:

    1. setOptimisticPaid ahora se ejecuta únicamente dentro de startTransition, lo que elimina por completo el modo 4.
    2. next se calcula a partir de invoice.paid, el valor ya confirmado, en lugar de leer un valor optimista que podría estar desactualizado, lo cual reduce la importancia del modo 5.
    3. El control se desactiva con disabled={pending} cada vez que la superposición y el valor base difieren, lo que impide la competencia por doble clic.
    4. Un bloque try/catch envuelve el caso de error, por lo que ahora el modo 1 muestra un mensaje después de que se ejecute el retroceso.
  • Cuando res.ok es falso, solo se actualiza el estado de error; la parte base permanece sin cambios, por lo que los modos 2 y 3 se deshacen manteniendo al mismo tiempo una explicación del motivo.
  • El error en sí se almacena en useState, nunca dentro del valor optimista, por lo que sobrevive una vez que se descarta la superposición.
  • Ese sexto punto requirió una segunda lectura para comprenderlo bien. Si se coloca el error dentro del reductor optimista, desaparece en cuanto finaliza la acción; la reversión elimina tu propio mensaje junto con la interfaz obsoleta. Un useState simple (o el estado devuelto por useActionState) es el canal que sigue existiendo después de que la superposición desaparezca.

    Opcional: useActionState para la versión en forma de formulario

    Si el interruptor se implementa como <form action>, puede permitir que useActionState transporte el último resultado durante la transición en lugar de gestionar ese estado manualmente:

    '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>
      );
    }
    

    Las reglas no cambian. El setter sigue ejecutándose dentro de la Acción. El valor base solo avanza después de una revalidación exitosa. El error más reciente sigue estando en state una vez que se elimina la superposición. Utilice este patrón cuando el control sea naturalmente un formulario; mantenga la versión con botón más useTransition para las filas de tabla compactas.

    Puntuaciones después de aplicar la corrección

    Se volvieron a probar los mismos cinco modos de fallo, con cinco intentos cada uno, en la fila corregida.

    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.
    

    En un camino de éxito sin problemas, se renderizan en promedio 3 veces: montaje, pintado optimista y luego reconciliación RSC. Cuando un fallo incluye un mensaje, esa cantidad asciende a 4: montaje, pintado optimista, deshacer cambios y luego pintado del error. Esa cuarta renderización representa el costo que vale la pena pagar por una fila de tabla como esta.

    De los cinco fallos inducidos, tres se deshacen automáticamente. Dos dejan aún elementos UI fantasma: el fallo leve que actualiza la base rápidamente y la carrera de disparos dobles.

    Rastreo de renderización del modo 1

    Aquí se muestran las marcas brutas de performance.now() obtenidas en la prueba 3 del modo 1, con el Modo Estricto desactivado y una sola fila montada.

    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
    

    Al ejecutar la misma prueba con la versión del tutorial defectuosa, el proceso se detiene en renders=3 sin mostrar ninguna alerta. Esa cuarta actualización visual es la diferencia total entre un componente funcional y uno defectuoso. El propio retroceso nunca fue lo difícil; lo complicado fue mantener activo el canal de estado después de que desapareciera la superposición óptima.

    Esa cuarta actualización visual es más importante que reducir milisegundos del proceso óptimo. Un usuario tolerará que una etiqueta esté incorrecta durante 400 ms si la interfaz le explica por qué. No tolerarán una etiqueta que mienta de forma segura y luego se corrija silenciosamente más tarde, sin que nadie se dé cuenta, hasta que alguien lo mencione en la reunión diaria.

    Consejos para la próxima solicitud de integración

    useOptimistic te proporciona una superposición temporal que dura solo hasta que se resuelve la transición. Si la acción lanza un error y la base nunca se actualizó, React restaura la interfaz de usuario. Una respuesta suave { ok: false } sin actualización de la base también provoca esa restauración. Ambos comportamientos coinciden con lo que indica la documentación, y ambos fueron confirmados aquí mediante pruebas directas.

    Lo que la documentación no proporciona automáticamente:

    • Un mensaje legible para el usuario una vez que se restaura el estado
    • Protección contra un valor suave { ok: false } si de todas formas se actualiza la base
    • Protección contra llamar al setter fuera de los límites de una transición
    • Funcionamiento idempotente al hacer doble clic rápidamente junto con revalidatePath

    El retroceso automático funciona tal como se anuncia. Un buen manejo de errores no es gratuito. Tres de los cinco casos intencionadamente dañados se recuperaron solos; los otros dos siguieron mostrando una interfaz obsoleta hasta que “la acción finalizó” dejó de considerarse sinónimo de “la acción tuvo éxito.”

    Una breve lista de verificación que vale la pena pegar en las revisiones de código:

    1. ¿Se ejecuta setOptimistic dentro de startTransition, o a través de la propiedad action de un formulario?
    2. ¿Se actualiza la base (o su réplica local) solo una vez que se confirma res.ok, o después de un éxito sin errores que también active la revalidación?
    3. ¿El error se almacena en useState o en useActionState, separado del reductor optimista?
    4. ¿El valor siguiente se calcula a partir del estado base del servidor, con el control desactivado mientras la acción está pendiente?
  • ¿Alguien ha probado realmente tanto la ruta de lanzamiento como la ruta { ok: false } en un navegador, y no solo el cambio entre rutas normales?
  • Si el ejemplo de un tutorial se detiene en llamar a setOptimistic y esperar la acción, entonces se está utilizando la versión con fallos silenciosos. Captura el error, inspecciona el resultado y almacena los errores en useState o useActionState. Desactiva el control cada vez que la interfaz y el servidor no estén de acuerdo. Al hacerlo, la función de reversión que React ya ofrece de forma gratuita se convierte en algo con lo que un usuario real realmente puede trabajar.

    Lecturas relacionadas

  • Por qué las acciones del servidor de Next.js necesitan autorización dentro de cada cuerpo de función — Un caso detallado de apropiación de cuentas muestra cómo las acciones del servidor de Next.js sin autenticación exponen operaciones con privilegios, y dónde debe realizarse la verificación de autorización para evitarlo.
  • Cinco defensas de seguridad en el frontend que necesitan todas las aplicaciones React y Next.js — Por qué las aplicaciones React y Next.js en producción utilizan cookies HttpOnly, CSP, DOMPurify, encabezados de seguridad y reglas NEXT_PUBLIC_, y qué tipo de ataques cada uno de ellos bloquea.