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.errorSource:https://dev.to/jgomezdev/useoptimistic-en-react-19-deshacer-sale-gratis-el-texto-del-usuario-no-o2m