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.
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:
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');
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"
}
}
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
}
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(),
}))
}
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') }
}
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>
)
}
// app/layout.tsx
import type { ReactNode } from 'react'
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
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 }
}
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>
)
}
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
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])
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
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.
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>
)
}
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>
)
}
### 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
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
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])
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.
noteses la prop. CuandorevalidatePathmanda una lista nueva, el hook muestra la lista nueva, con los borradores pendientes encima. Sin efecto y sinuseStatedesactualizado. -
No hay reversión. Cuando las acciones terminan, sus borradores se descartan, y lo que queda es lo que valga
notesen ese momento. Si el servidor guardó la nota,notesya 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>
)
}
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
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
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
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
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>
)
}
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/catchalrededor 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
useOptimisticno 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.
requiredacepta tres espacios; la comprobación contrim(), 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
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
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
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
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
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
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
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
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.
Puntos clave
-
useOptimisticdeshace 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.
-
useFormStatustiene que llamarse en un hijo del<form>. Llamado en el componente que renderiza el formulario, devuelvepending: falsesiempre, 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)