useOptimistic in React 19: The Rollback Is Free, the Draft Is Not
Every useOptimistic demo I have read increments something that cannot fail. A like counter. A todo that always saves. The optimistic update shows up, the server agrees, everyone goes home. That is the one case where you did not need the hook.
The reason optimistic UI is hard is the other case: you told the user it worked, and then the server said no. The note has to come off the list, the user has to find out why, and whatever they typed has to still be there so they can try again. That is the part this article is about.
So here is a form that can fail for real reasons. Aurora Coffee Co., the shop from React Server Components for SPA Veterans, lets customers leave brew notes on each coffee. A moderator can close the notes on a coffee at any moment, the network can drop, and the server can answer 500. I built the form three ways, drove each one in a real browser through every one of those failures, and logged everything the user would have seen. The obvious version of the form lost the user's text in one failure and lost the whole page in the other two.
Everything here is React 19.3 and Next.js 16.4 with a production build.
The setup: notes in Postgres, a Server Action that can say no
Postgres in Docker, so the failures come from a real database and not from a mock that fails on command:
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: The seed.sql beside it creates two tables. notes_locked is the moderator's switch. A note's id is a UUID that the browser creates, for a reason that only shows up when a request fails halfway; it gets its own section below. The CHECK constraints repeat the length rules the Server Action enforces, so a bug in the action cannot write a note the schema would reject:
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'); The app is a stock Next.js 16 project with the Postgres driver added, and a .env.local holding 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" } } One connection pool per process, pinned to globalThis so hot reload in development does not open a new pool on every edit:
// 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 } The reads, mapped to plain objects because they cross from a Server Component into a 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(), })) } What the form sends, shared by the browser and the server. Reading the fields in one place means the note the user sees while it is sending is trimmed exactly the way the server will trim it:
// 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() : '' } // The id is created in the browser, before the first attempt, so every retry of // the same note carries the same id and the server can recognise a repeat. export function noteFromForm(formData: FormData): NoteInput { return { id: crypto.randomUUID(), author: text(formData, 'author'), body: text(formData, 'body') } } crypto.randomUUID() only exists in a secure context: HTTPS, or localhost. Open the dev server from your phone at http://192.168.x.x:3000 and it is undefined, the action throws, and you are looking at the error screen this article spends a section on.
The page is a Server Component. It reads the product and its notes and hands the notes to the client component that owns the form:
// 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> ) } And the mutation. It returns a result instead of throwing for the failures it expects, because a closed thread is an answer, not an exception. Its arguments come from the browser, and TypeScript types do not survive the trip, so it checks them at runtime, productId and the shape of note included:
// app/products/[id]/actions.ts 'use server' import { revalidatePath } from 'next/cache' import { pool } from '@/lib/db' import type { NoteInput } from '@/lib/note-input' // A returned error is final: sending the same note again would get the same answer. 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> { // Both arguments arrive from the browser, whatever their types say, so check them at runtime. 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.' } } // One statement checks the lock and inserts, so a moderator closing the thread // in between cannot let a note through. ON CONFLICT makes a retry harmless: // if an earlier attempt already saved this id, nothing is inserted twice. 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) { // Nothing was inserted. Find out why before telling the user anything. 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 } } The INSERT … SELECT … WHERE NOT notes_locked is the line that makes the lock real. Checking the flag with one query and inserting with a second leaves a gap between them, and a moderator who closes the thread inside that gap loses to the customer. In one statement there is no gap. The ON CONFLICT half, and the query after it that works out why nothing was inserted, are for later.
How I tested it
Each version below ran as a production build (next build && next start) in headless Chromium driven by Playwright. The script held every Server Action request for 1.5 seconds, long enough to see each state, and logged every change to the page: the list of notes, the button, the textarea, and any error message. A note marked (sending) is one the server has not confirmed. To produce failures, the script set notes_locked while a request was in flight, dropped the connection, or answered 500 in place of the server.
The logs below are that script's output, pasted. Each line shows only the fields that changed.
Version 1: the one you write first
Before React 19, optimistic UI was a copy of the server's data in useState, an update before the request, and a revert in the catch:
// app/products/[id]/brew-notes.tsx (version 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 (...