DEV Community

Cover image for useOptimistic in React 19: The Rollback Is Free, the Draft Is Not
Juan Gómez
Juan Gómez

Posted on

useOptimistic in React 19: The Rollback Is Free, the Draft Is Not

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.

A comment card that appears as sending, loses its connection to the server, and leaves an empty slot in the list

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:
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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(),
  }))
}
Enter fullscreen mode Exit fullscreen mode

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') }
}
Enter fullscreen mode Exit fullscreen mode

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

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 }
}
Enter fullscreen mode Exit fullscreen mode

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 (
    <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

It handles every failure. It is also wrong on the one path that works:

### 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

The server saved the note and Postgres has it, but the note says (sending) forever. useState(serverNotes) reads its argument once, on the first render. When revalidatePath sends fresh notes from the server, the new prop arrives and nobody reads it. Nothing about this looks broken in code review.

The usual fix is an effect that copies the prop into state whenever it changes:

// app/products/[id]/brew-notes.tsx (version 1, excerpt: the fix everyone adds)
useEffect(() => {
  setNotes(serverNotes)
}, [serverNotes])
Enter fullscreen mode Exit fullscreen mode

With it, a single note works. Two notes in flight do not. To get two, the script submitted a second note while the first was still sending, using form.requestSubmit(). That is how a Ctrl+Enter shortcut in a comment box is usually written, and requestSubmit() does not care that the button is disabled. The first note fails:

### 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

When the first note failed, setNotes(previous) ran, and previous was the list from before either note. Ben's note disappeared while it was still being sent, and the button came back while Ben's note was still sending: isPosting is one boolean shared by two requests, and the first request's finally cleared it. Ben's note reappears a second later, when the server's list arrives. Each of those bugs has a fix. None of the fixes is about what this component is for.

Two notes sent at once, where reverting to an old snapshot of the list also erases the second note that was succeeding

useFormStatus: the pending state, from the form itself

React 19 lets a <form> take a function as its action. The form calls it with the form's FormData, inside a transition, and the form now knows whether that call is still running. useFormStatus reads that:

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

That is a whole component for one button, and the reason is the most common mistake with this hook. useFormStatus reports the status of the <form> that renders the component calling it, which means it has to be called inside the form. Call it in the component that renders the <form>, and there is no form above it yet. It does not throw, and it does not warn. It returns pending: false, permanently.

Here is that mistake, with the hook in the component that owns the form, and the user double-clicking:

// app/products/[id]/brew-notes.tsx (the misplaced hook)
'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[] }) {
  // There is no <form> above this component, so this is always 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

The button never changed, so the second click went through, and the same note is in the database twice. Each click created its own note, with its own id, so nothing on the server could tell them apart. The same double click against the final version, where SubmitButton is a child of the form:

### 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

One request, one row. The extra component is the price of the button knowing anything.

useOptimistic: state that only exists while an action runs

useOptimistic takes the real value and a reducer, and returns the value to display plus a function to add an optimistic change:

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

Read it as: display notes, plus every draft added by an action that is still running. That is the whole model, and both of version 1's bugs are missing from it:

  • There is no copy of the server's data. notes is the prop. When revalidatePath sends a new list, the hook shows the new list, with any still-pending drafts on top. No effect, no stale useState.
  • There is no revert. When the actions finish, their drafts are discarded, and what is left is whatever notes is by then. If the server saved the note, notes now contains it. If it did not, the note is simply not there. The rollback is not code you write. It is what happens when you write nothing.

"When the actions finish" is plural on purpose. It matters later.

addOptimisticNote has to be called inside an action or a transition. A function passed to <form action> is one, so this works directly:

// app/products/[id]/brew-notes.tsx (version 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

The explicit type arguments on useOptimistic are there because the hook infers its type from notes, which is BrewNote[], and the drafts carry a sending flag that BrewNote does not have. Without them, note.sending does not compile.

The happy path, for the record:

### 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

The optimistic note became the real one in the same render that brought the server's list. Nothing flickered, because revalidatePath sends the new notes back in the response to the action itself (the browser made no other request), and React swaps draft for real note in one step. The draft and the saved note even share an id, the UUID from noteFromForm, so React keeps the same <li> instead of replacing it.

Now the failures.

The failure path, in full

The rollback works. The user's text does not survive it

The moderator closes the notes while Ana's request is in flight:

### 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

The note came off the list, the error appeared, the button came back. That is the rollback, and it took no code. Now look at the textarea on the last line: empty.

Ana's note was rejected, and the text she wrote is gone. When a function is passed to <form action>, React asks for a form reset before it even calls your function, and applies it, with the browser's own form.reset(), when the action's transition commits. What your action returns plays no part in it: { ok: false } and { ok: true } reset the form the same way. So the user is told the note was not posted and, in the same frame, loses the note.

This is the trap in this whole article. For a "like" button there is nothing to lose. For anything someone typed, the reset is the worst part of the failure.

Controlled fields survive it, and the reason is worth knowing. form.reset() sets every field back to its default value, and for a controlled field React keeps the default value equal to the current one. The reset runs and puts back what was already there. (Pass both value and defaultValue and that stops being true.)

A thrown error takes the whole page with it

Now the network drops instead:

### 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

That is not the form showing an error. That is the entire page replaced by Next.js's error screen. When the Server Action call rejects, await postBrewNote(...) throws inside the action, nothing catches it, and an error thrown by a form action goes to the nearest error boundary, the same as an error thrown while rendering. With no error.tsx anywhere, the nearest boundary is the root. A 500 does the same, with a different message:

### 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

An error.tsx would replace the product page with something nicer, which is still not what the user needs. Their note failed. The page did not. So the action catches it.

"Your note was not posted" can be a lie

The catch block in version 1 says could not reach the server, your note was not posted. The browser cannot know the second half. The connection can drop after Postgres committed the note and before the response arrived. Then the note is posted, the screen says it is not, and the user does the reasonable thing: tries again, and posts it twice.

That is what the browser-made UUID and ON CONFLICT (id) DO NOTHING are for. The id is fixed before the first attempt, every retry sends the same one, and a second insert of the same id does nothing. The action then finds the note already there and answers ok. A retry is safe, so the message can say so honestly instead of guessing.

The same logic decides which failures get a Try again button. A thrown error means we do not know, and retrying is how you find out. A returned error is the server's final answer: the thread is closed, and sending the same note again gets the same answer. So only the first kind is retryable.

The user is already typing the next note

One more case turned up while testing the fixes. Ana posts, and while her note is sending she starts writing her next one. Any code that empties the textarea when the request finishes (React's reset, or a setBody('') after a success) empties it while it holds the second note.

So the final version empties the textarea at the moment of submitting, in an onSubmit handler, which runs before the action and outside its transition. React calls your onSubmit first, and the FormData is read before that update renders, so the note still goes out with its text. Nothing is lost by emptying the field early, either. The text is already on the list as a draft, and if the request fails, it stays on the list with its own error and its text intact.

Version 3

// app/products/[id]/brew-notes.tsx (version 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,
      }
    }
    // This runs after an await, so it is an ordinary update, not part of the action:
    // the note is marked as failed now, not when every pending action has finished.
    setFailedNotes((current) => [...current, failure])
  }

  function handleSubmit(event: FormEvent<HTMLFormElement>) {
    if (body.trim() === '' || author.trim() === '') {
      event.preventDefault()
      return
    }
    // Empty the textarea now, outside the action, so the user can start the next
    // note at once. The text is not lost: it is in the list, and in failedNotes if it fails.
    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))
  }

  // A note we could not confirm may turn out to be saved after all. Once the
  // server's list contains it, it is not a failure any more.
  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

What changed from version 2, one reason per failure:

  • Controlled fields, so React's reset puts back what is there. The name is never cleared at all, which is also what someone posting twice wants.
  • A try/catch around the call, so a dropped connection or a 500 becomes a message, not an error screen.
  • Failed notes live in ordinary state, with their text and their own error. A draft from useOptimistic cannot outlive its action by design, so it cannot be where a failure is shown.
  • Only an unconfirmed failure offers *Try again, and the other button says what it does: *Hide from this list, not Discard. A note we could not confirm may be saved, and hiding it does not delete it. Once the server's list contains its id, it stops being shown as failed at all.
  • The textarea empties on submit, not on success, so a note typed while another is sending is never touched.
  • A note that is only whitespace is never sent. required accepts three spaces; the trim() check does not.

The moderator closing the notes, against version 3. The thread is closed, so there is nothing to retry, and the note only offers to be hidden:

### 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

The connection dropping, then 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

And the case the UUID exists for: the server saves the note, the response never arrives, and the user tries again:

### 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

The first attempt saved the note and the user was told it might not have. The retry inserted nothing, found the note already there, and answered ok. Postgres has one note, not two.

If the user posts something else instead of retrying, the server's list comes back with the "failed" note in it, and the failure resolves itself:

### 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

Both of Ana's notes are listed as saved, and Postgres has both. One thing a retry does not get is the form's pending state. Try again starts its own transition with startTransition, which is not a form submission, so SubmitButton stays enabled while a retry is in flight. The note itself shows (sending), and the UUID makes a double send harmless, so here that is fine.

And the user typing ahead while the first note is sending. In the same run the script also pressed Enter in the name field, to check that this does not submit while the button is disabled. It does not: the browser refuses implicit submission when the submit button is disabled, so only one request went out.

### 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

The same failed form twice: on the left the text area is swept empty, on the right a lock keeps the text and the error sits above the button

Telling the user, without a toast

Each failed note carries its own <p role="alert">, inside the list item of the note that failed, and its buttons point at the note's text with aria-describedby, so two Try again buttons on the page are not two identical buttons. That placement is deliberate.

A toast is the usual choice, and it fits a failure badly. It appears somewhere else on the screen, it disappears on a timer whether or not anyone read it, and screen readers announce it detached from the thing that failed. Here, the message sits next to the text that was not posted, says what happened in plain words, and offers what the user can do about it. Every failure adds a new alert element instead of changing the text of an old one, which is the pattern screen readers announce most reliably. I checked the markup, not every screen reader, so test it with the ones your users have.

A gotcha: a failure waits for every other action

The second note in each of these runs did not go out until the first one finished. In every two-note log here, POST #2 sent comes right after POST #1 ends. Next.js runs Server Actions through a single queue, which it shares with navigations (navigating away while one is pending discards its result).

That is not why drafts linger, though. React does that. While any async action is pending, React treats all of them as one batch, and it removes optimistic drafts only when the whole batch has finished. Next's queue just makes the batch last longer. Here is version 2 when the first of two notes fails:

### 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

Ana's request failed at 2.66 s. Nothing on the page changed. Her note kept saying (sending) until 3.83 s, when Ben's request finished, and then it was simply gone. There was no error message and no error screen either, even though version 2 has no try/catch and a single failed request crashed the page earlier. The reason is in how React tracks a form's status: each submission stores its outcome as the form's state, and Ben's submission replaced Ana's failed outcome before React ever rendered it, so her error was never thrown. The result: "Note A" exists nowhere. It is not in Postgres, it is not on the list, and the textarea has moved on to Ben's note.

Version 3, same run:

### 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

Ana's note is marked as failed as soon as her request fails, with its text and a way to send it again, while Ben's is still sending. The update that does it runs after an await, and React does not count updates after an await as part of the action, so it renders immediately instead of waiting for the batch.

The batch still shows in the opposite case. When the second note fails, the first one, already saved, says (sending) until the second finishes:

### 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

Ana's note was saved at 2.63 s and is shown as saved at 3.66 s. A success that looks slow for a second is a much smaller problem than a failure that looks like it is still trying.

When optimistic UI is the wrong answer

The test for optimistic UI is not whether the request usually succeeds. It is what the user does in the second before it fails, and what it costs to try again.

A brew note passes both. Nobody acts on a note being visible. If it comes off the list a second later with a reason, nothing happened in that second that has to be undone, and with the UUID, trying again cannot post it twice.

These fail it:

  • Payments. A user who sees "Paid" closes the tab. If the charge is declined after that, they find out from a missing order a week later. And a payment retried without an idempotency key is a double charge.
  • Bookings and reservations. A user who sees the seat as theirs goes on to book the hotel for that flight. Telling them the seat went to someone else, after they have moved on, is worse than a spinner.
  • Deletes. An item that vanishes optimistically and comes back on failure looks like a glitch the first time and a ghost the second. Delete with a pending state, or delete with an undo window that runs on the server, not in the UI.
  • Anything with a consequence outside the page: emails sent, inventory reserved, permissions granted. If the user would tell someone else it is done, it should be done.

For those, show the pending state instead. useFormStatus does it for anything inside the <form>. For pending state anywhere else on the page, useTransition gives you an isPending flag for the transition you start. Both are independent of useOptimistic, and the pending half is useful on every form.

A fork between light actions that can update instantly and payments, bookings and deletes that wait for confirmation

Key Takeaways


  • useOptimistic rolls back by doing nothing. It shows the real value plus drafts from actions still running. When those actions finish, the drafts are discarded: a failed mutation disappears without any revert code, and a successful one is replaced by the server's version in the same render.
  • React resets a form action's form no matter what the action returns. It asks for the reset before calling your function and applies it with form.reset() when the transition commits. Controlled fields survive it. Anything else the user typed does not.
  • A thrown error in a form action reaches the error boundary. A dropped connection or a 500 replaced the whole page. Catch inside the action, and do not claim the note was not posted: the browser cannot know. Make the retry safe instead, with an id created before the first attempt, and offer it only for failures that a retry can fix.
  • useFormStatus must be called in a child of the <form>. Called in the component that renders the form, it returns pending: false forever, with no warning, and a double click posted the same note twice.
  • Drafts wait for every pending action, so failures need their own state. React removes optimistic drafts only when all pending actions have finished. Keep failed notes in ordinary state, set after the await, and the user sees the failure, and keeps the text, the moment it happens.

Top comments (0)