DEV Community

Cover image for useOptimistic en React 19: deshacer sale gratis, el texto del usuario no
Juan Gómez
Juan Gómez

Posted on

useOptimistic en React 19: deshacer sale gratis, el texto del usuario no

useOptimistic en React 19: deshacer sale gratis, el texto del usuario no

Todas las demos de useOptimistic que he leído incrementan algo que no puede fallar. Un contador de likes. Una tarea que siempre se guarda. La actualización optimista aparece, el servidor está de acuerdo y listo. Es justo el único caso en que el hook no hacía falta.

La interfaz optimista se complica en el otro caso: ya le dijiste al usuario que funcionó, y el servidor responde que no. La nota tiene que salir de la lista, el usuario tiene que enterarse de por qué, y lo que escribió tiene que seguir ahí para que pueda intentarlo otra vez. De eso trata este artículo.

Así que aquí va un formulario que puede fallar por motivos reales. Aurora Coffee Co., la tienda de React Server Components para veteranos de SPA, permite a sus clientes dejar notas de preparación en cada café. Un moderador puede cerrar las notas de un café en cualquier momento, la red se puede caer y el servidor puede responder 500. Escribí el formulario de tres maneras, lo hice pasar en un navegador real por cada uno de esos fallos y registré todo lo que habría visto el usuario. La versión obvia del formulario perdió el texto del usuario en un fallo y la página entera en los otros dos.

Todos los ejemplos usan React 19.3 y Next.js 16.4, con un build de producción.

Una tarjeta de comentario que aparece como enviándose, pierde la conexión con el servidor y deja un hueco vacío en la lista

El montaje: notas en Postgres y una Server Action que puede decir que no

Postgres en Docker, para que los fallos vengan de una base de datos real y no de un mock que falla cuando se lo pides:

services:
  postgres:
    image: postgres:18.6-alpine
    environment:
      POSTGRES_USER: aurora
      POSTGRES_PASSWORD: aurora_local_dev
      POSTGRES_DB: aurora
    ports:
      - "5432:5432"
    volumes:
      - ./seed.sql:/docker-entrypoint-initdb.d/01-seed.sql:ro
      - aurora-pgdata:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U aurora -d aurora"]
      interval: 5s
      timeout: 3s
      retries: 10

volumes:
  aurora-pgdata:
Enter fullscreen mode Exit fullscreen mode

El seed.sql que va al lado crea dos tablas. notes_locked es el interruptor del moderador. El id de cada nota es un UUID que crea el navegador; el motivo solo aparece cuando una petición falla a medias, y tiene su propia sección más abajo. Las restricciones CHECK repiten las reglas de longitud que valida la Server Action, así que un error en la acción no puede guardar una nota que el esquema rechazaría:

CREATE TABLE products (
  id           SERIAL  PRIMARY KEY,
  name         TEXT    NOT NULL,
  origin       TEXT    NOT NULL,
  notes_locked BOOLEAN NOT NULL DEFAULT FALSE
);

CREATE TABLE brew_notes (
  id         UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
  product_id INTEGER     NOT NULL REFERENCES products (id),
  author     TEXT        NOT NULL CHECK (char_length(author) BETWEEN 1 AND 40),
  body       TEXT        NOT NULL CHECK (char_length(body) BETWEEN 1 AND 500),
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX brew_notes_product_idx ON brew_notes (product_id, created_at);

INSERT INTO products (name, origin) VALUES
  ('Yirgacheffe Reserve', 'Ethiopia'),
  ('Huila Comunitario',   'Colombia');

INSERT INTO brew_notes (product_id, author, body, created_at) VALUES
  (1, 'Marta', 'V60, 15 g to 250 g at 93 °C. Jasmine all the way through.',      now() - interval '2 days'),
  (1, 'Luis',  'Too bright as espresso for me. Great as an AeroPress, 2 minutes.', now() - interval '1 day');
Enter fullscreen mode Exit fullscreen mode

La tienda está en inglés, así que los textos de la interfaz también. Los dejé así a propósito: los registros que verás más abajo son la salida real de las pruebas, y traducirlos los convertiría en otra cosa.

La aplicación es un proyecto estándar de Next.js 16 con el driver de Postgres añadido, más un .env.local con DATABASE_URL=postgres://aurora:aurora_local_dev@localhost:5432/aurora:

{
  "name": "aurora-storefront",
  "private": true,
  "engines": { "node": ">=24" },
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "next": "16.4.3",
    "pg": "^8.23.1",
    "react": "19.3.1",
    "react-dom": "19.3.1"
  },
  "devDependencies": {
    "@types/node": "^24.19.2",
    "@types/pg": "^8.23.1",
    "@types/react": "^19.3.0",
    "@types/react-dom": "^19.3.0",
    "typescript": "^5.9.3"
  }
}
Enter fullscreen mode Exit fullscreen mode

Un solo pool de conexiones por proceso. Se guarda en globalThis para que la recarga en caliente de desarrollo no abra un pool nuevo con cada cambio:

// lib/db.ts
import { Pool } from 'pg'

const globalForDb = globalThis as unknown as { auroraPool?: Pool }

export const pool =
  globalForDb.auroraPool ??
  new Pool({
    connectionString: process.env.DATABASE_URL,
    max: 10,
    idleTimeoutMillis: 30_000,
    connectionTimeoutMillis: 5_000,
  })

if (process.env.NODE_ENV !== 'production') {
  globalForDb.auroraPool = pool
}
Enter fullscreen mode Exit fullscreen mode

Las lecturas devuelven objetos planos, porque pasan de un Server Component a un Client Component:

// lib/notes.ts
import { pool } from './db'

export type BrewNote = {
  id: string
  author: string
  body: string
  createdAt: string
}

export type Product = {
  id: number
  name: string
  origin: string
}

export async function getProduct(id: number): Promise<Product | null> {
  const { rows } = await pool.query<Product>('SELECT id, name, origin FROM products WHERE id = $1', [id])
  return rows[0] ?? null
}

export async function getBrewNotes(productId: number): Promise<BrewNote[]> {
  const { rows } = await pool.query<{ id: string; author: string; body: string; created_at: Date }>(
    `SELECT id, author, body, created_at
       FROM brew_notes
      WHERE product_id = $1
      ORDER BY created_at, id`,
    [productId],
  )
  return rows.map((row) => ({
    id: row.id,
    author: row.author,
    body: row.body,
    createdAt: row.created_at.toISOString(),
  }))
}
Enter fullscreen mode Exit fullscreen mode

Lo que envía el formulario, compartido entre el navegador y el servidor. Como los campos se leen en un solo sitio, la nota que el usuario ve mientras se envía queda recortada exactamente igual que la recortará el servidor:

// lib/note-input.ts
export type NoteInput = {
  id: string
  author: string
  body: string
}

function text(formData: FormData, name: string): string {
  const value = formData.get(name)
  return typeof value === 'string' ? value.trim() : ''
}

// El id se crea en el navegador antes del primer intento, así que cada reintento
// de la misma nota lleva el mismo id y el servidor puede reconocer una repetición.
export function noteFromForm(formData: FormData): NoteInput {
  return { id: crypto.randomUUID(), author: text(formData, 'author'), body: text(formData, 'body') }
}
Enter fullscreen mode Exit fullscreen mode

crypto.randomUUID() solo existe en un contexto seguro: HTTPS, o localhost. Si abres el servidor de desarrollo desde el móvil en http://192.168.x.x:3000, vale undefined, la acción lanza una excepción y acabas en la misma pantalla de error a la que este artículo le dedica una sección.

La página es un Server Component. Lee el producto y sus notas, y le pasa las notas al componente de cliente que contiene el formulario:

// app/products/[id]/page.tsx
import { notFound } from 'next/navigation'
import { getBrewNotes, getProduct } from '@/lib/notes'
import { BrewNotes } from './brew-notes'

export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const productId = Number(id)
  if (!Number.isInteger(productId)) notFound()

  const [product, notes] = await Promise.all([getProduct(productId), getBrewNotes(productId)])
  if (!product) notFound()

  return (
    <main>
      <h1>{product.name}</h1>
      <p>{product.origin}</p>
      <BrewNotes productId={product.id} notes={notes} />
    </main>
  )
}
Enter fullscreen mode Exit fullscreen mode
// app/layout.tsx
import type { ReactNode } from 'react'

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  )
}
Enter fullscreen mode Exit fullscreen mode

Y la mutación. Para los fallos que espera, devuelve un resultado en lugar de lanzar una excepción, porque un hilo cerrado es una respuesta, no un error. Sus argumentos llegan del navegador, y los tipos de TypeScript no viajan con ellos, así que los valida en tiempo de ejecución, incluidos productId y la forma de note:

// app/products/[id]/actions.ts
'use server'

import { revalidatePath } from 'next/cache'
import { pool } from '@/lib/db'
import type { NoteInput } from '@/lib/note-input'

// Un error devuelto es definitivo: enviar la misma nota otra vez obtendría la misma respuesta.
export type PostNoteResult = { ok: true } | { ok: false; error: string }

const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i

function isNoteInput(value: unknown): value is NoteInput {
  if (typeof value !== 'object' || value === null) return false
  const { id, author, body } = value as Record<string, unknown>
  return typeof id === 'string' && UUID.test(id) && typeof author === 'string' && typeof body === 'string'
}

export async function postBrewNote(productId: number, note: NoteInput): Promise<PostNoteResult> {
  // Los dos argumentos llegan del navegador, digan lo que digan sus tipos, así que se validan en tiempo de ejecución.
  if (!Number.isInteger(productId) || !isNoteInput(note)) {
    return { ok: false, error: 'This note could not be read. Reload the page and write it again.' }
  }
  const author = note.author.trim()
  const body = note.body.trim()
  if (author.length === 0 || author.length > 40) {
    return { ok: false, error: 'Your name needs to be between 1 and 40 characters.' }
  }
  if (body.length === 0 || body.length > 500) {
    return { ok: false, error: 'A brew note needs to be between 1 and 500 characters.' }
  }

  // Una sola sentencia comprueba el cierre e inserta, así que un moderador que cierra
  // el hilo entre medias no puede dejar pasar una nota. ON CONFLICT hace inofensivo
  // un reintento: si un intento anterior ya guardó este id, no se inserta dos veces.
  const { rowCount } = await pool.query(
    `INSERT INTO brew_notes (id, product_id, author, body)
     SELECT $1, id, $3, $4 FROM products WHERE id = $2 AND NOT notes_locked
     ON CONFLICT (id) DO NOTHING`,
    [note.id, productId, author, body],
  )

  if (rowCount === 0) {
    // No se insertó nada. Averiguar por qué antes de decirle nada al usuario.
    const { rows } = await pool.query<{ saved: boolean; locked: boolean | null }>(
      `SELECT EXISTS (SELECT 1 FROM brew_notes WHERE id = $1 AND product_id = $2) AS saved,
              (SELECT notes_locked FROM products WHERE id = $2) AS locked`,
      [note.id, productId],
    )
    const { saved, locked } = rows[0]
    if (!saved) {
      return locked
        ? { ok: false, error: 'Notes on this coffee were closed while you were writing. Your note was not posted.' }
        : { ok: false, error: 'This coffee is no longer available. Your note was not posted.' }
    }
  }

  revalidatePath(`/products/${productId}`)
  return { ok: true }
}
Enter fullscreen mode Exit fullscreen mode

El INSERT … SELECT … WHERE NOT notes_locked es lo que hace que el cierre funcione de verdad. Si consultas el indicador con una sentencia e insertas con otra, queda un hueco entre las dos, y si el moderador cierra el hilo justo en ese hueco, la nota del cliente entra igual. Con una sola sentencia no hay hueco. La parte del ON CONFLICT, y la consulta posterior que averigua por qué no se insertó nada, sirven para más adelante.

Cómo lo probé

Cada versión corrió como build de producción (next build && next start) en Chromium sin interfaz, controlado con Playwright. El script retenía cada petición de Server Action durante 1,5 segundos, lo suficiente para ver cada estado, y registraba cada cambio en la página: la lista de notas, el botón, el área de texto y cualquier mensaje de error. Una nota marcada (sending) es una que el servidor todavía no ha confirmado. Para provocar los fallos, el script activaba notes_locked con una petición en curso, cortaba la conexión o respondía 500 en lugar del servidor.

Los registros de abajo son la salida de ese script, copiada tal cual. Cada línea muestra solo los campos que cambiaron.

Versión 1: la que escribes primero

Antes de React 19, una interfaz optimista era una copia de los datos del servidor en useState, una actualización antes de la petición y una reversión en el catch:

// app/products/[id]/brew-notes.tsx (versión 1)
'use client'

import { useState, type FormEvent } from 'react'
import type { BrewNote } from '@/lib/notes'
import { noteFromForm } from '@/lib/note-input'
import { postBrewNote } from './actions'

type DisplayedNote = BrewNote & { sending?: boolean }

export function BrewNotes({ productId, notes: serverNotes }: { productId: number; notes: BrewNote[] }) {
  const [notes, setNotes] = useState<DisplayedNote[]>(serverNotes)
  const [isPosting, setIsPosting] = useState(false)
  const [error, setError] = useState<string | null>(null)
  const [author, setAuthor] = useState('')
  const [body, setBody] = useState('')

  async function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault()
    const note = noteFromForm(new FormData(event.currentTarget))
    const previous = notes
    setNotes([...notes, { ...note, createdAt: new Date().toISOString(), sending: true }])
    setIsPosting(true)
    setError(null)

    try {
      const result = await postBrewNote(productId, note)
      if (result.ok) {
        setBody('')
      } else {
        setNotes(previous)
        setError(result.error)
      }
    } catch {
      setNotes(previous)
      setError('Could not reach the server. Your note was not posted.')
    } finally {
      setIsPosting(false)
    }
  }

  return (
    <section>
      <h2>Brew notes</h2>
      <ul>
        {notes.map((note) => (
          <li key={note.id} data-sending={note.sending ? 'true' : undefined}>
            <strong>{note.author}</strong>: {note.body}
            {note.sending ? <em> (sending…)</em> : null}
          </li>
        ))}
      </ul>
      {error ? <p role="alert">{error}</p> : null}
      <form onSubmit={handleSubmit}>
        <input name="author" value={author} onChange={(event) => setAuthor(event.target.value)} required maxLength={40} />
        <textarea name="body" value={body} onChange={(event) => setBody(event.target.value)} required maxLength={500} />
        <button type="submit" disabled={isPosting}>
          {isPosting ? 'Posting…' : 'Post note'}
        </button>
      </form>
    </section>
  )
}
Enter fullscreen mode Exit fullscreen mode

Contempla todos los fallos. Y aun así se equivoca en el único caso que sale bien:

### success
    0 ms  list: Marta, Luis | button: Post note | textarea: "" | alert: none
   95 ms  textarea: "Cold brew, 18 hours. Like jasmine tea."
  174 ms  list: Marta, Luis, Ana (sending) | button: Posting… (disabled)
  181 ms  POST #1 sent
 1772 ms  POST #1 answered 200
 1784 ms  button: Post note | textarea: ""
final state: list: Marta, Luis, Ana (sending) | button: Post note | textarea: "" | alert: none
notes saved in Postgres: 1
Enter fullscreen mode Exit fullscreen mode

El servidor guardó la nota y Postgres la tiene, pero la nota dice (sending) para siempre. useState(serverNotes) lee su argumento una sola vez, en el primer render. Cuando revalidatePath manda las notas nuevas desde el servidor, llega la prop nueva y nadie la lee. En una revisión de código, nada de esto parece roto.

El arreglo habitual es un efecto que copia la prop en el estado cada vez que cambia:

// app/products/[id]/brew-notes.tsx (versión 1, fragmento: el arreglo que todos añaden)
useEffect(() => {
  setNotes(serverNotes)
}, [serverNotes])
Enter fullscreen mode Exit fullscreen mode

Con él, una nota funciona. Dos notas enviándose a la vez, no. Para tener dos, el script envió una segunda nota mientras la primera seguía enviándose, con form.requestSubmit(). Así se suele programar un atajo Ctrl+Enter en una caja de comentarios, y a requestSubmit() no le importa que el botón esté deshabilitado. La primera nota falla:

### two in flight, first fails
    0 ms  list: Marta, Luis | button: Post note | textarea: "" | alert: none
  117 ms  textarea: "Note A"
  188 ms  list: Marta, Luis, Ana (sending) | button: Posting… (disabled)
  189 ms  POST #1 sent
  632 ms  textarea: "Note B"
  640 ms  list: Marta, Luis, Ana (sending), Ben (sending)
 2704 ms  POST #1 failed: connection dropped
 2714 ms  POST #2 sent
 2720 ms  list: Marta, Luis | button: Post note | alert: "Could not reach the server. Your note was not posted."
 3796 ms  POST #2 answered 200
 3803 ms  textarea: ""
 3808 ms  list: Marta, Luis, Ben
final state: list: Marta, Luis, Ben | button: Post note | textarea: "" | alert: "Could not reach the server. Your note was not posted."
notes saved in Postgres: 1
Enter fullscreen mode Exit fullscreen mode

Cuando falló la primera nota, se ejecutó setNotes(previous), y previous era la lista de antes de las dos notas. La nota de Ben desapareció mientras todavía se estaba enviando. Además, el botón volvió a habilitarse con esa nota aún en camino: isPosting es un único booleano para dos peticiones, y el finally de la primera lo apagó. La nota de Ben reaparece un segundo después, cuando llega la lista del servidor. Cada uno de esos errores tiene arreglo. Ninguno de los arreglos tiene que ver con lo que este componente debe hacer.

Dos notas enviadas a la vez, donde volver a una copia vieja de la lista borra también la segunda nota, que sí se estaba guardando

useFormStatus: el estado pendiente, sacado del propio formulario

React 19 permite pasarle una función a la prop action de un <form>. El formulario la llama con su FormData, dentro de una transición, y a partir de ahí sabe si esa llamada sigue en curso. useFormStatus lee esa información:

// app/products/[id]/submit-button.tsx
'use client'

import { useFormStatus } from 'react-dom'

export function SubmitButton() {
  const { pending } = useFormStatus()

  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Posting…' : 'Post note'}
    </button>
  )
}
Enter fullscreen mode Exit fullscreen mode

Un componente entero para un botón, y el motivo es el error más común con este hook. useFormStatus informa del estado del <form> que renderiza al componente que lo llama, así que hay que llamarlo dentro del formulario. Si lo llamas en el componente que renderiza el <form>, todavía no hay ningún formulario por encima. No lanza ningún error ni muestra ninguna advertencia. Devuelve pending: false, siempre.

Este es ese error, con el hook en el componente que contiene el formulario, y el usuario haciendo doble clic:

// app/products/[id]/brew-notes.tsx (el hook mal ubicado)
'use client'

import { useFormStatus } from 'react-dom'
import type { BrewNote } from '@/lib/notes'
import { noteFromForm } from '@/lib/note-input'
import { postBrewNote } from './actions'

export function BrewNotes({ productId, notes }: { productId: number; notes: BrewNote[] }) {
  // No hay ningún <form> por encima de este componente, así que siempre es false.
  const { pending } = useFormStatus()

  async function submit(formData: FormData) {
    await postBrewNote(productId, noteFromForm(formData))
  }

  return (
    <section>
      <h2>Brew notes</h2>
      <ul>
        {notes.map((note) => (
          <li key={note.id}>
            <strong>{note.author}</strong>: {note.body}
          </li>
        ))}
      </ul>
      <form action={submit}>
        <input name="author" required maxLength={40} />
        <textarea name="body" required maxLength={500} />
        <button type="submit" disabled={pending}>
          {pending ? 'Posting…' : 'Post note'}
        </button>
      </form>
    </section>
  )
}
Enter fullscreen mode Exit fullscreen mode
### useFormStatus in the form owner, double click
    0 ms  list: Marta, Luis | button: Post note | textarea: "" | alert: none
  225 ms  textarea: "Cold brew, 18 hours. Like jasmine tea."
  317 ms  POST #1 sent
 1939 ms  POST #1 answered 200
 1956 ms  POST #2 sent
 3507 ms  POST #2 answered 200
 3521 ms  list: Marta, Luis, Ana, Ana | textarea: ""
final state: list: Marta, Luis, Ana, Ana | button: Post note | textarea: "" | alert: none
notes saved in Postgres: 2
Enter fullscreen mode Exit fullscreen mode

El botón nunca cambió, así que el segundo clic también se envió, y la misma nota quedó dos veces en la base de datos. Cada clic creó su propia nota con su propio id, de modo que el servidor no tenía forma de distinguirlas. El mismo doble clic contra la versión final, donde SubmitButton es hijo del formulario:

### double click
    0 ms  list: Marta, Luis | button: Post note | textarea: "" | alert: none
   85 ms  textarea: "Cold brew, 18 hours. Like jasmine tea."
  151 ms  list: Marta, Luis, Ana (sending) | button: Posting… (disabled) | textarea: ""
  152 ms  POST #1 sent
 1773 ms  POST #1 answered 200
 1792 ms  list: Marta, Luis, Ana | button: Post note
final state: list: Marta, Luis, Ana | button: Post note | textarea: "" | alert: none
notes saved in Postgres: 1
Enter fullscreen mode Exit fullscreen mode

Una petición, una fila. Ese componente extra es lo que cuesta que el botón se entere de algo.

useOptimistic: estado que solo existe mientras corre una acción

useOptimistic recibe el valor real y un reducer, y devuelve el valor que hay que mostrar más una función para añadir un cambio optimista:

const [displayedNotes, addOptimisticNote] = useOptimistic(notes, (current, draft) => [...current, draft])
Enter fullscreen mode Exit fullscreen mode

Se lee así: muestra notes, más cada borrador que haya añadido una acción que todavía está en curso. Ese es el modelo completo, y los dos errores de la versión 1 no caben en él:

  • No hay copia de los datos del servidor. notes es la prop. Cuando revalidatePath manda una lista nueva, el hook muestra la lista nueva, con los borradores pendientes encima. Sin efecto y sin useState desactualizado.
  • No hay reversión. Cuando las acciones terminan, sus borradores se descartan, y lo que queda es lo que valga notes en ese momento. Si el servidor guardó la nota, notes ya la incluye. Si no la guardó, la nota simplemente no está. Deshacer no es código que escribas: es lo que pasa cuando no escribes nada.

"Cuando las acciones terminan", en plural, a propósito. Más adelante importa.

addOptimisticNote tiene que llamarse dentro de una acción o de una transición. Una función pasada a <form action> cumple esa condición, así que esto funciona tal cual:

// app/products/[id]/brew-notes.tsx (versión 2)
'use client'

import { useOptimistic, useState } from 'react'
import type { BrewNote } from '@/lib/notes'
import { noteFromForm } from '@/lib/note-input'
import { postBrewNote } from './actions'
import { SubmitButton } from './submit-button'

type DisplayedNote = BrewNote & { sending?: boolean }

export function BrewNotes({ productId, notes }: { productId: number; notes: BrewNote[] }) {
  const [error, setError] = useState<string | null>(null)
  const [displayedNotes, addOptimisticNote] = useOptimistic<DisplayedNote[], DisplayedNote>(
    notes,
    (current, draft) => [...current, draft],
  )

  async function submit(formData: FormData) {
    const note = noteFromForm(formData)
    setError(null)
    addOptimisticNote({ ...note, createdAt: new Date().toISOString(), sending: true })

    const result = await postBrewNote(productId, note)
    if (!result.ok) setError(result.error)
  }

  return (
    <section>
      <h2>Brew notes</h2>
      <ul>
        {displayedNotes.map((note) => (
          <li key={note.id} data-sending={note.sending ? 'true' : undefined}>
            <strong>{note.author}</strong>: {note.body}
            {note.sending ? <em> (sending…)</em> : null}
          </li>
        ))}
      </ul>
      {error ? <p role="alert">{error}</p> : null}
      <form action={submit}>
        <input name="author" required maxLength={40} />
        <textarea name="body" required maxLength={500} />
        <SubmitButton />
      </form>
    </section>
  )
}
Enter fullscreen mode Exit fullscreen mode

Los argumentos de tipo explícitos en useOptimistic hacen falta porque el hook deduce su tipo de notes, que es BrewNote[], y los borradores llevan un indicador sending que BrewNote no tiene. Sin ellos, note.sending no compila.

El caso en que todo sale bien, para que conste:

### success
    0 ms  list: Marta, Luis | button: Post note | textarea: "" | alert: none
  105 ms  textarea: "Cold brew, 18 hours. Like jasmine tea."
  168 ms  POST #1 sent
  168 ms  list: Marta, Luis, Ana (sending) | button: Posting… (disabled)
 1782 ms  POST #1 answered 200
 1800 ms  list: Marta, Luis, Ana | button: Post note | textarea: ""
final state: list: Marta, Luis, Ana | button: Post note | textarea: "" | alert: none
notes saved in Postgres: 1
Enter fullscreen mode Exit fullscreen mode

La nota optimista se convirtió en la real en el mismo render que trajo la lista del servidor. No hubo parpadeo, porque revalidatePath devuelve las notas nuevas en la respuesta de la propia acción (el navegador no hizo ninguna otra petición), y React cambia el borrador por la nota real en un solo paso. El borrador y la nota guardada comparten además el id, el UUID de noteFromForm, así que React conserva el mismo <li> en lugar de reemplazarlo.

Ahora, los fallos.

Qué pasa cuando falla, de principio a fin

La nota se retira bien. Lo que escribió el usuario, no

El moderador cierra las notas mientras la petición de Ana está en curso:

### locked mid-write
    0 ms  list: Marta, Luis | button: Post note | textarea: "" | alert: none
   86 ms  textarea: "Cold brew, 18 hours. Like jasmine tea."
  150 ms  POST #1 sent
  150 ms  list: Marta, Luis, Ana (sending) | button: Posting… (disabled)
 1696 ms  POST #1 answered 200
 1707 ms  alert: "Notes on this coffee were closed while you were writing. Your note was not posted."
 1713 ms  list: Marta, Luis | button: Post note | textarea: ""
final state: list: Marta, Luis | button: Post note | textarea: "" | alert: "Notes on this coffee were closed while you were writing. Your note was not posted."
notes saved in Postgres: 0
Enter fullscreen mode Exit fullscreen mode

La nota salió de la lista, apareció el error y el botón volvió. Esa es la reversión, y no costó ni una línea de código. Ahora mira el área de texto en la última línea: vacía.

La nota de Ana fue rechazada, y lo que escribió desapareció. Cuando le pasas una función a <form action>, React pide restablecer el formulario antes incluso de llamar a tu función, y lo aplica, con el form.reset() del propio navegador, cuando se confirma la transición de la acción. Lo que devuelva tu acción no influye: { ok: false } y { ok: true } restablecen el formulario igual. Así que el usuario lee que su nota no se publicó y, en ese mismo instante, pierde la nota.

Esta es la trampa de todo el artículo. En un botón de like no hay nada que perder. En cualquier cosa que alguien haya escrito, el restablecimiento es lo peor del fallo.

Los campos controlados se salvan, y conviene saber por qué. form.reset() devuelve cada campo a su valor por defecto, y en un campo controlado React mantiene el valor por defecto igual al actual. El restablecimiento se ejecuta y vuelve a poner lo que ya había. (Si pasas a la vez value y defaultValue, eso deja de cumplirse.)

Un error lanzado se lleva la página entera

Ahora lo que se cae es la red:

### network failure
    0 ms  list: Marta, Luis | button: Post note | textarea: "" | alert: none
  133 ms  textarea: "Cold brew, 18 hours. Like jasmine tea."
  197 ms  list: Marta, Luis, Ana (sending) | button: Posting… (disabled)
  200 ms  POST #1 sent
 1706 ms  POST #1 failed: connection dropped
 1833 ms  uncaught error: Failed to fetch
 1834 ms  page: "This page couldn’t load" (the form is gone)
final state: page: "This page couldn’t load" (the form is gone)
notes saved in Postgres: 0
Enter fullscreen mode Exit fullscreen mode

Eso no es el formulario mostrando un error. Es la página completa reemplazada por la pantalla de error de Next.js. Cuando la llamada a la Server Action falla, await postBrewNote(...) lanza una excepción dentro de la acción, nadie la captura, y un error lanzado por la acción de un formulario va al error boundary más cercano, igual que un error durante el render. Sin ningún error.tsx, el más cercano es el de la raíz. Un 500 hace lo mismo, con otro mensaje:

### server 500
    0 ms  list: Marta, Luis | button: Post note | textarea: "" | alert: none
   79 ms  textarea: "Cold brew, 18 hours. Like jasmine tea."
  144 ms  POST #1 sent
  144 ms  list: Marta, Luis, Ana (sending) | button: Posting… (disabled)
 1646 ms  POST #1 answered 500
 1707 ms  uncaught error: An unexpected response was received from the server.
 1707 ms  page: "This page couldn’t load" (the form is gone)
final state: page: "This page couldn’t load" (the form is gone)
notes saved in Postgres: 0
Enter fullscreen mode Exit fullscreen mode

Un error.tsx cambiaría la página del producto por algo más presentable, que sigue sin ser lo que el usuario necesita. Lo que falló fue su nota, no la página. Así que la acción captura el error.

"Tu nota no se publicó" puede ser mentira

El catch de la versión 1 dice no se pudo contactar con el servidor, tu nota no se publicó. El navegador no puede saber la segunda parte. La conexión puede caerse después de que Postgres confirmara la nota y antes de que llegara la respuesta. Entonces la nota está publicada, la pantalla dice que no, y el usuario hace lo razonable: lo intenta otra vez y la publica dos veces.

Para eso están el UUID creado en el navegador y el ON CONFLICT (id) DO NOTHING. El id queda fijado antes del primer intento, cada reintento manda el mismo, y una segunda inserción con ese id no hace nada. Después, la acción encuentra la nota ya guardada y responde ok. Reintentar es seguro, así que el mensaje puede decirlo con honestidad en lugar de adivinar.

Ese mismo razonamiento decide qué fallos llevan un botón Try again. Una excepción significa no lo sabemos, y reintentar es la forma de averiguarlo. Un error devuelto es la respuesta definitiva del servidor: el hilo está cerrado, y mandar la misma nota otra vez obtiene la misma respuesta. Por eso solo el primer tipo se puede reintentar.

El usuario ya está escribiendo la siguiente nota

Al probar los arreglos apareció un caso más. Ana publica una nota y, mientras se envía, empieza a escribir la siguiente. Cualquier código que vacíe el área de texto cuando termina la petición (el restablecimiento de React, o un setBody('') tras el éxito) la vacía cuando ya contiene la segunda nota.

Por eso la versión final vacía el área de texto en el momento del envío, en un manejador onSubmit, que se ejecuta antes de la acción y fuera de su transición. React llama primero a tu onSubmit, y el FormData se lee antes de que esa actualización se renderice, así que la nota sale con su texto. Vaciar el campo tan pronto tampoco pierde nada. El texto ya está en la lista como borrador, y si la petición falla, se queda en la lista con su propio error y el texto intacto.

Versión 3

// app/products/[id]/brew-notes.tsx (versión 3)
'use client'

import { startTransition, useOptimistic, useState, type FormEvent } from 'react'
import type { BrewNote } from '@/lib/notes'
import { noteFromForm, type NoteInput } from '@/lib/note-input'
import { postBrewNote } from './actions'
import { SubmitButton } from './submit-button'

type DisplayedNote = BrewNote & { sending?: boolean }
type FailedNote = NoteInput & { error: string; retryable: boolean }

export function BrewNotes({ productId, notes }: { productId: number; notes: BrewNote[] }) {
  const [author, setAuthor] = useState('')
  const [body, setBody] = useState('')
  const [failedNotes, setFailedNotes] = useState<FailedNote[]>([])
  const [displayedNotes, addOptimisticNote] = useOptimistic<DisplayedNote[], DisplayedNote>(
    notes,
    (current, draft) => [...current, draft],
  )

  async function send(note: NoteInput) {
    addOptimisticNote({ ...note, createdAt: new Date().toISOString(), sending: true })

    let failure: FailedNote
    try {
      const result = await postBrewNote(productId, note)
      if (result.ok) return
      failure = { ...note, error: result.error, retryable: false }
    } catch {
      failure = {
        ...note,
        error: 'We could not confirm that your note was posted. Trying again is safe: it will not be posted twice.',
        retryable: true,
      }
    }
    // Esto corre después de un await, así que es una actualización normal, fuera de la
    // acción: la nota se marca como fallida ya, no cuando terminen todas las acciones pendientes.
    setFailedNotes((current) => [...current, failure])
  }

  function handleSubmit(event: FormEvent<HTMLFormElement>) {
    if (body.trim() === '' || author.trim() === '') {
      event.preventDefault()
      return
    }
    // Vaciar el área de texto ahora, fuera de la acción, para que el usuario pueda empezar
    // la siguiente nota de inmediato. El texto no se pierde: está en la lista, y en failedNotes si falla.
    setBody('')
  }

  function retry(note: FailedNote) {
    setFailedNotes((current) => current.filter((failed) => failed.id !== note.id))
    startTransition(() => send({ id: note.id, author: note.author, body: note.body }))
  }

  function hide(note: FailedNote) {
    setFailedNotes((current) => current.filter((failed) => failed.id !== note.id))
  }

  // Una nota que no pudimos confirmar puede resultar guardada. En cuanto la lista
  // del servidor la incluye, deja de ser un fallo.
  const savedIds = new Set(notes.map((note) => note.id))
  const unresolved = failedNotes.filter((note) => !savedIds.has(note.id))
  const failedIds = new Set(unresolved.map((note) => note.id))

  return (
    <section aria-labelledby="brew-notes-heading">
      <h2 id="brew-notes-heading">Brew notes</h2>
      <ul>
        {displayedNotes
          .filter((note) => !failedIds.has(note.id))
          .map((note) => (
            <li key={note.id} data-sending={note.sending ? 'true' : undefined} style={{ opacity: note.sending ? 0.6 : 1 }}>
              <strong>{note.author}</strong>: {note.body}
              {note.sending ? <em> (sending…)</em> : null}
            </li>
          ))}
        {unresolved.map((note) => (
          <li key={note.id} data-failed="true">
            <strong>{note.author}</strong>: <span id={`failed-note-${note.id}`}>{note.body}</span>
            <p role="alert">{note.error}</p>
            {note.retryable ? (
              <button type="button" aria-describedby={`failed-note-${note.id}`} onClick={() => retry(note)}>
                Try again
              </button>
            ) : null}
            <button type="button" aria-describedby={`failed-note-${note.id}`} onClick={() => hide(note)}>
              Hide from this list
            </button>
          </li>
        ))}
      </ul>

      <form action={(formData) => send(noteFromForm(formData))} onSubmit={handleSubmit}>
        <label>
          Your name
          <input name="author" value={author} onChange={(event) => setAuthor(event.target.value)} required maxLength={40} />
        </label>
        <label>
          Your brew note
          <textarea name="body" value={body} onChange={(event) => setBody(event.target.value)} required maxLength={500} />
        </label>
        <SubmitButton />
      </form>
    </section>
  )
}
Enter fullscreen mode Exit fullscreen mode

Qué cambió respecto a la versión 2, un motivo por cada fallo:

  • Campos controlados, para que el restablecimiento de React vuelva a poner lo que hay. El nombre no se vacía nunca, que es también lo que quiere alguien que publica dos notas seguidas.
  • Un try/catch alrededor de la llamada, para que una conexión caída o un 500 se conviertan en un mensaje y no en una pantalla de error.
  • Las notas fallidas viven en estado normal, con su texto y su propio error. Un borrador de useOptimistic no puede durar más que su acción, por diseño, así que no sirve para mostrar un fallo.
  • Solo un fallo sin confirmar ofrece *Try again, y el otro botón dice lo que hace: *Hide from this list (ocultar de la lista), no Discard (descartar). Una nota que no pudimos confirmar puede estar guardada, y ocultarla no la borra. En cuanto la lista del servidor contiene su id, deja de mostrarse como fallida.
  • El área de texto se vacía al enviar, no al terminar con éxito, así que nunca toca una nota que se esté escribiendo mientras otra se envía.
  • Una nota hecha solo de espacios no se envía nunca. required acepta tres espacios; la comprobación con trim(), no.

El moderador cerrando las notas, contra la versión 3. El hilo está cerrado, así que no hay nada que reintentar, y la nota solo ofrece ocultarse:

### locked mid-write
    0 ms  list: Marta, Luis | button: Post note | textarea: "" | alert: none
   75 ms  textarea: "Cold brew, 18 hours. Like jasmine tea."
  124 ms  POST #1 sent
  124 ms  list: Marta, Luis, Ana (sending) | button: Posting… (disabled) | textarea: ""
 1656 ms  POST #1 answered 200
 1665 ms  list: Marta, Luis, Ana (failed, text kept: "Cold brew, 18 hours. Like jasmine tea.", buttons: Hide from this list) | alert: "Notes on this coffee were closed while you were writing. Your note was not posted."
 1672 ms  button: Post note
final state: list: Marta, Luis, Ana (failed, text kept: "Cold brew, 18 hours. Like jasmine tea.", buttons: Hide from this list) | button: Post note | textarea: "" | alert: "Notes on this coffee were closed while you were writing. Your note was not posted."
notes saved in Postgres: 0
Enter fullscreen mode Exit fullscreen mode

La conexión cayéndose, y después Try again:

### network failure, then Try again
    0 ms  list: Marta, Luis | button: Post note | textarea: "" | alert: none
   51 ms  textarea: "Cold brew, 18 hours. Like jasmine tea."
   98 ms  list: Marta, Luis, Ana (sending) | button: Posting… (disabled) | textarea: ""
   99 ms  POST #1 sent
 1613 ms  POST #1 failed: connection dropped
 1619 ms  list: Marta, Luis, Ana (failed, text kept: "Cold brew, 18 hours. Like jasmine tea.", buttons: Try again + Hide from this list) | alert: "We could not confirm that your note was posted. Trying again is safe: it will not be posted twice."
 1628 ms  button: Post note
 2661 ms  list: Marta, Luis, Ana (sending) | alert: none
 2663 ms  POST #2 sent
 4229 ms  POST #2 answered 200
 4245 ms  list: Marta, Luis, Ana
final state: list: Marta, Luis, Ana | button: Post note | textarea: "" | alert: none
notes saved in Postgres: 1
Enter fullscreen mode Exit fullscreen mode

Y el caso para el que existe el UUID: el servidor guarda la nota, la respuesta nunca llega y el usuario lo intenta otra vez:

### saved, but the response was lost; then Try again
    0 ms  list: Marta, Luis | button: Post note | textarea: "" | alert: none
   83 ms  textarea: "Cold brew, 18 hours. Like jasmine tea."
  132 ms  list: Marta, Luis, Ana (sending) | button: Posting… (disabled) | textarea: ""
  133 ms  POST #1 sent
 1676 ms  POST #1 reached the server, then the connection dropped
 1683 ms  list: Marta, Luis, Ana (failed, text kept: "Cold brew, 18 hours. Like jasmine tea.", buttons: Try again + Hide from this list) | alert: "We could not confirm that your note was posted. Trying again is safe: it will not be posted twice."
 1691 ms  button: Post note
 2689 ms  list: Marta, Luis, Ana (sending) | alert: none
 2690 ms  POST #2 sent
 4263 ms  POST #2 answered 200
 4279 ms  list: Marta, Luis, Ana
final state: list: Marta, Luis, Ana | button: Post note | textarea: "" | alert: none
notes saved in Postgres: 1
Enter fullscreen mode Exit fullscreen mode

El primer intento guardó la nota, y al usuario se le dijo que quizá no. El reintento no insertó nada, encontró la nota ya guardada y respondió ok. Postgres tiene una nota, no dos.

Si en lugar de reintentar el usuario publica otra cosa, la lista del servidor vuelve con la nota "fallida" dentro, y el fallo se resuelve solo:

### saved, but the response was lost; then another note is posted
    0 ms  list: Marta, Luis | button: Post note | textarea: "" | alert: none
  108 ms  textarea: "Note A"
  188 ms  list: Marta, Luis, Ana (sending) | button: Posting… (disabled) | textarea: ""
  189 ms  POST #1 sent
 1837 ms  POST #1 reached the server, then the connection dropped
 1845 ms  list: Marta, Luis, Ana (failed, text kept: "Note A", buttons: Try again + Hide from this list) | alert: "We could not confirm that your note was posted. Trying again is safe: it will not be posted twice."
 1858 ms  button: Post note
 2730 ms  textarea: "Note B"
 2783 ms  list: Marta, Luis, Ana (sending), Ana (failed, text kept: "Note A", buttons: Try again + Hide from this list) | button: Posting… (disabled) | textarea: ""
 2787 ms  POST #2 sent
 4335 ms  POST #2 answered 200
 4349 ms  list: Marta, Luis, Ana, Ana | button: Post note | alert: none
final state: list: Marta, Luis, Ana, Ana | button: Post note | textarea: "" | alert: none
notes saved in Postgres: 2
Enter fullscreen mode Exit fullscreen mode

Las dos notas de Ana aparecen como guardadas, y Postgres tiene las dos. Hay algo que un reintento no recibe: el estado pendiente del formulario. Try again inicia su propia transición con startTransition, que no es un envío del formulario, así que SubmitButton sigue habilitado mientras el reintento está en curso. La propia nota muestra (sending), y el UUID hace inofensivo un envío doble, así que aquí no es un problema.

Y el usuario escribiendo la siguiente nota mientras la primera se envía. En esa misma prueba el script pulsó además Enter en el campo del nombre, para comprobar que eso no envía nada con el botón deshabilitado. No lo hace: el navegador no hace el envío implícito cuando el botón de envío está deshabilitado, así que solo salió una petición.

### typing the next note while the first is sending
    0 ms  list: Marta, Luis | button: Post note | textarea: "" | alert: none
   64 ms  textarea: "Note A"
  124 ms  list: Marta, Luis, Ana (sending) | button: Posting… (disabled) | textarea: ""
  125 ms  POST #1 sent
  463 ms  textarea: "Note B"
 2650 ms  POST #1 answered 200
 2664 ms  list: Marta, Luis, Ana | button: Post note
final state: list: Marta, Luis, Ana | button: Post note | textarea: "Note B" | alert: none
notes saved in Postgres: 1
Enter fullscreen mode Exit fullscreen mode

El mismo formulario fallido dos veces: a la izquierda el área de texto queda vacía, a la derecha un candado conserva el texto y el error aparece sobre el botón

Avisar al usuario sin un toast

Cada nota fallida lleva su propio <p role="alert">, dentro del elemento de la lista de la nota que falló, y sus botones apuntan al texto de la nota con aria-describedby, para que dos botones Try again en la misma página no sean dos botones idénticos. Esa ubicación es deliberada.

Lo habitual es un toast, y encaja mal con un fallo. Aparece en otra parte de la pantalla, desaparece con un temporizador lo haya leído alguien o no, y los lectores de pantalla lo anuncian separado de lo que falló. Aquí el mensaje está al lado del texto que no se publicó, cuenta lo que pasó con palabras normales y ofrece lo que el usuario puede hacer. Cada fallo añade un elemento de alerta nuevo en lugar de cambiar el texto de uno viejo, que es la forma que los lectores de pantalla anuncian con más fiabilidad. Revisé el marcado, no todos los lectores de pantalla, así que pruébalo con los que usen tus usuarios.

Un detalle: un fallo espera a todas las demás acciones

En cada una de estas pruebas, la segunda nota no salió hasta que terminó la primera. En todos los registros de dos notas, POST #2 sent aparece justo después de que termina POST #1. Next.js ejecuta las Server Actions en una sola cola, que comparte con las navegaciones (si el usuario navega a otra página con una acción pendiente, su resultado se descarta).

Pero no es eso lo que hace que los borradores se queden. Eso lo hace React. Mientras haya alguna acción asíncrona pendiente, React las trata a todas como un solo lote, y solo retira los borradores optimistas cuando termina el lote completo. La cola de Next solo hace que el lote dure más. Esta es la versión 2 cuando falla la primera de dos notas:

### two in flight, first fails
    0 ms  list: Marta, Luis | button: Post note | textarea: "" | alert: none
   90 ms  textarea: "Note A"
  155 ms  list: Marta, Luis, Ana (sending) | button: Posting… (disabled)
  158 ms  POST #1 sent
  512 ms  textarea: "Note B"
  518 ms  list: Marta, Luis, Ana (sending), Ben (sending)
 2658 ms  POST #1 failed: connection dropped
 2666 ms  POST #2 sent
 3808 ms  POST #2 answered 200
 3828 ms  list: Marta, Luis, Ben | button: Post note | textarea: ""
final state: list: Marta, Luis, Ben | button: Post note | textarea: "" | alert: none
notes saved in Postgres: 1
Enter fullscreen mode Exit fullscreen mode

La petición de Ana falló a los 2,66 s. En la página no cambió nada. Su nota siguió diciendo (sending) hasta los 3,83 s, cuando terminó la petición de Ben, y luego simplemente desapareció. No hubo mensaje de error, ni pantalla de error, aunque la versión 2 no tiene try/catch y una sola petición fallida tumbó la página más arriba. La explicación está en cómo React registra el estado de un formulario: cada envío guarda su resultado como estado del formulario, y el envío de Ben reemplazó el resultado fallido de Ana antes de que React llegara a renderizarlo, así que su error nunca se lanzó. El resultado: "Note A" no existe en ningún sitio. No está en Postgres, no está en la lista, y el área de texto ya tiene la nota de Ben.

La versión 3, con la misma prueba:

### two in flight, first fails
    0 ms  list: Marta, Luis | button: Post note | textarea: "" | alert: none
   55 ms  textarea: "Note A"
  110 ms  list: Marta, Luis, Ana (sending) | button: Posting… (disabled) | textarea: ""
  111 ms  POST #1 sent
  455 ms  textarea: "Note B"
  460 ms  list: Marta, Luis, Ana (sending), Ben (sending) | textarea: ""
 2621 ms  POST #1 failed: connection dropped
 2625 ms  POST #2 sent
 2628 ms  list: Marta, Luis, Ben (sending), Ana (failed, text kept: "Note A", buttons: Try again + Hide from this list) | alert: "We could not confirm that your note was posted. Trying again is safe: it will not be posted twice."
 3657 ms  POST #2 answered 200
 3670 ms  list: Marta, Luis, Ben, Ana (failed, text kept: "Note A", buttons: Try again + Hide from this list) | button: Post note
final state: list: Marta, Luis, Ben, Ana (failed, text kept: "Note A", buttons: Try again + Hide from this list) | button: Post note | textarea: "" | alert: "We could not confirm that your note was posted. Trying again is safe: it will not be posted twice."
notes saved in Postgres: 1
Enter fullscreen mode Exit fullscreen mode

La nota de Ana se marca como fallida en cuanto falla su petición, con su texto y una forma de enviarla de nuevo, mientras la de Ben sigue enviándose. La actualización que lo hace corre después de un await, y React no cuenta las actualizaciones posteriores a un await como parte de la acción, así que se muestra de inmediato en lugar de esperar al lote.

El lote todavía se nota en el caso contrario. Cuando falla la segunda nota, la primera, ya guardada, dice (sending) hasta que termina la segunda:

### two in flight, second fails
    0 ms  list: Marta, Luis | button: Post note | textarea: "" | alert: none
   42 ms  textarea: "Note A"
   91 ms  list: Marta, Luis, Ana (sending) | button: Posting… (disabled) | textarea: ""
   91 ms  POST #1 sent
  427 ms  textarea: "Note B"
  431 ms  list: Marta, Luis, Ana (sending), Ben (sending) | textarea: ""
 2626 ms  POST #1 answered 200
 2633 ms  POST #2 sent
 3645 ms  POST #2 failed: connection dropped
 3652 ms  list: Marta, Luis, Ana (sending), Ben (failed, text kept: "Note B", buttons: Try again + Hide from this list) | alert: "We could not confirm that your note was posted. Trying again is safe: it will not be posted twice."
 3657 ms  list: Marta, Luis, Ana, Ben (failed, text kept: "Note B", buttons: Try again + Hide from this list) | button: Post note
final state: list: Marta, Luis, Ana, Ben (failed, text kept: "Note B", buttons: Try again + Hide from this list) | button: Post note | textarea: "" | alert: "We could not confirm that your note was posted. Trying again is safe: it will not be posted twice."
notes saved in Postgres: 1
Enter fullscreen mode Exit fullscreen mode

La nota de Ana se guardó a los 2,63 s y aparece como guardada a los 3,66 s. Un éxito que durante un segundo parece lento es un problema mucho menor que un fallo que parece que sigue intentándolo.

Cuándo la interfaz optimista es mala idea

La pregunta para decidir si usarla no es si la petición suele salir bien. Es qué hace el usuario en el segundo antes de que falle, y cuánto cuesta reintentar.

Una nota de preparación pasa las dos pruebas. Nadie actúa porque una nota sea visible. Si sale de la lista un segundo después con un motivo, en ese segundo no pasó nada que haya que deshacer, y con el UUID, reintentar no puede publicarla dos veces.

Estos casos no las pasan:

  • Pagos. Quien ve "Pagado" cierra la pestaña. Si después rechazan el cargo, se entera una semana más tarde, cuando el pedido no llega. Y un pago reintentado sin una clave de idempotencia es un cobro doble.
  • Reservas. Quien ve el asiento como suyo pasa a reservar el hotel para ese vuelo. Decirle que el asiento se lo llevó otra persona, cuando ya siguió adelante, es peor que un indicador de carga.
  • Borrados. Un elemento que desaparece de forma optimista y vuelve si falla parece un error la primera vez y un fantasma la segunda. Borra con un estado pendiente, o con un margen para deshacer que viva en el servidor, no en la interfaz.
  • Cualquier cosa con consecuencias fuera de la página: correos enviados, inventario reservado, permisos concedidos. Si el usuario le diría a otra persona que ya está hecho, tiene que estar hecho.

En esos casos, muestra el estado pendiente. useFormStatus lo resuelve para todo lo que esté dentro del <form>. Para mostrarlo en cualquier otra parte de la página, useTransition te da un indicador isPending de la transición que inicias. Los dos son independientes de useOptimistic, y la parte del estado pendiente sirve en cualquier formulario.

Una bifurcación entre acciones ligeras que pueden actualizarse al instante y pagos, reservas y borrados que esperan confirmación

Puntos clave


  • useOptimistic deshace sin hacer nada. Muestra el valor real más los borradores de las acciones que siguen en curso. Cuando esas acciones terminan, los borradores se descartan: una mutación fallida desaparece sin código de reversión, y una exitosa se reemplaza por la versión del servidor en el mismo render.
  • React restablece el formulario de una acción sin importar lo que devuelva. Pide el restablecimiento antes de llamar a tu función y lo aplica con form.reset() cuando se confirma la transición. Los campos controlados se salvan. Todo lo demás que haya escrito el usuario, no.
  • Un error lanzado en la acción de un formulario llega al error boundary. Una conexión caída o un 500 reemplazaron la página entera. Captura el error dentro de la acción, y no afirmes que la nota no se publicó: el navegador no lo puede saber. Haz que reintentar sea seguro, con un id creado antes del primer intento, y ofrécelo solo para los fallos que un reintento puede arreglar.
  • useFormStatus tiene que llamarse en un hijo del <form>. Llamado en el componente que renderiza el formulario, devuelve pending: false siempre, sin ninguna advertencia, y un doble clic publicó la misma nota dos veces.
  • Los borradores esperan a todas las acciones pendientes, así que los fallos necesitan su propio estado. React retira los borradores optimistas solo cuando terminan todas las acciones pendientes. Guarda las notas fallidas en estado normal, actualizado después del await, y el usuario ve el fallo, y conserva su texto, en el momento en que ocurre.

Top comments (0)