Must knowReact Suspense · 03

Optimistic UI

useOptimistic shows a review before the server confirms it; useFormStatus shows the form is busy.

On this page · 7 sections
  1. In one minute
  2. The example: posting a review
  3. How it works
  4. Common mistakes
  5. Interview Q&A
  6. Related
  7. Sources

In one minute

Optimistic UI shows the result of an action before the server confirms it: the review appears in the list the moment you post it, marked “sending…”. A <form action={fn}> runs fn inside a transition, and React holds back ordinary state updates made during a transition until it ends, so useState can’t do this. useOptimistic can: its value may change during the action, and when the action ends it falls back to the real state, which also gives you rollback for free if the request failed. useFormStatus lets a button inside the form know the form is submitting. One React 19 catch: a state update after an await isn’t part of the action any more; wrap it in startTransition.

You’ll be able to: post a form with an optimistic result, show its pending state, roll back on failure, and avoid the after-await duplicate.

useState
Reviews.tsx
1function Reviews() {2  const [reviews, setReviews] = useState(initial)3  async function addReview(formData) {4    setReviews(r => [...r, temp(formData)]) // show it right away?5    const saved = await postReview(formData)6    setReviews(r => [...replaceTemp(r), saved])7  }8  return <form action={addReview}>… <SubmitButton /></form>9}1011function SubmitButton() {12  const { pending } = useFormStatus()13  return <button>{pending ? 'Posting…' : 'Post review'}</button>14}
What you see
Ana: Keeps my tea hot.
Post review
  1. eventreturn <form action={addReview}>… <SubmitButton /></form>Submit. The form action runs inside a transition, and the setReviews before the request is part of it…
  2. commit phasereturn <button>{pending ? 'Posting…' : 'Post review'}</button>…so React holds it back until the whole action ends. Only the button shows progress, through useFormStatus.
  3. commit phaseconst saved = await postReview(formData)The review appears only when the server answers, 800 ms after clicking.

0 / 0
Posting a review “Great lamp!”; the server takes 800 ms (all three versions recorded).

The example: posting a review

Posting a review takes the server 800 ms. Each recording types “Great lamp!” and clicks “Post review”.

The submit button: useFormStatus

function SubmitButton() {
	const { pending } = useFormStatus() // the status of the <form> around this button
	return (
		<button type="submit" disabled={pending} data-note>
			{pending ? 'Posting…' : 'Post review'}
		</button>
	)
}

useFormStatus reads the status of the <form> the component is rendered inside (it must be a child of the form, so the button is its own component). It also gives you the submitted data, method and action.

1. Plain state

function ReviewsWithState() {
	const [reviews, setReviews] = useState(initial)
	async function addReview(formData: FormData) {
		const text = String(formData.get('text'))
		setReviews((r) => [...r, { id: 'temp', author: 'You', text }]) // try to show it right away
		const saved = await postReview('p1', { author: 'You', text })
		setReviews((r) => [...r.filter((x) => x.id !== 'temp'), saved])
	}
	return (
		<>
			<ReviewList reviews={reviews} />
			<form action={addReview}>
				<input name="text" id="text" />
				<SubmitButton />
			</form>
		</>
	)
}
1250 ms  → POST /products/p1/reviews
screen: Ana: Keeps my tea hot. · Posting…
2050 ms  ← POST /products/p1/reviews
screen: Ana: Keeps my tea hot. · You: Great lamp! · Posting…
screen: Ana: Keeps my tea hot. · You: Great lamp! · Post review

The button said “Posting…” right away, but the review only appeared when the server answered, 800 ms later. The setReviews before the await was part of the action’s transition, so React held it back.

2. useOptimistic

function Reviews({ fail = false, fixed = false }) {
	const [reviews, setReviews] = useState(initial)
	const [optimisticReviews, addOptimistic] = useOptimistic(reviews, (current, text: string) => [
		...current,
		{ id: 'temp', author: 'You', text, sending: true },
	])

	async function addReview(formData: FormData) {
		const text = String(formData.get('text'))
		addOptimistic(text) // shown immediately, during the action
		try {
			const saved = await postReview('p1', { author: 'You', text }, 800, fail)
			if (fixed) {
				startTransition(() => setReviews((r) => [...r, saved])) // part of the action again
			} else {
				setReviews((r) => [...r, saved]) // after an await: no longer part of the action
			}
		} catch {
			log('action: the server rejected the review')
		}
	}

	return (
		<>
			<ReviewList reviews={optimisticReviews} />
			<form action={addReview}>
				<input name="text" id="text" />
				<SubmitButton />
			</form>
		</>
	)
}
1250 ms  → POST /products/p1/reviews
screen: Ana: Keeps my tea hot. · You: Great lamp! (sending…) · Posting…
2050 ms  ← POST /products/p1/reviews
screen: Ana: Keeps my tea hot. · You: Great lamp! · You: Great lamp! (sending…) · Posting…
screen: Ana: Keeps my tea hot. · You: Great lamp! · Post review

The review appeared immediately, marked “(sending…)”. But when the server answered, the list briefly showed it twice: the saved review and the optimistic one. The setReviews after the await ran as a separate update while the action’s optimistic state was still alive. Wrapping it in startTransition makes it part of the action again:

startTransition(() => setReviews((r) => [...r, saved])) // part of the action again
1250 ms  → POST /products/p1/reviews
screen: Ana: Keeps my tea hot. · You: Great lamp! (sending…) · Posting…
2050 ms  ← POST /products/p1/reviews
screen: Ana: Keeps my tea hot. · You: Great lamp! · Post review

One step from “sending…” to saved.

3. When the server fails

1250 ms  → POST /products/p1/reviews
screen: Ana: Keeps my tea hot. · You: Great lamp! (sending…) · Posting…
2100 ms  ✗ POST /products/p1/reviews: Server error, review not saved
action: the server rejected the review
screen: Ana: Keeps my tea hot. · Post review

The optimistic review disappeared on its own when the action ended: the optimistic value fell back to the real list, which never got the review. Show an error message too, so the shopper knows why.

4. Several steps in one action

function Checkout() {
	const [message, setMessage] = useOptimistic('Place order')
	async function placeOrder() {
		setMessage('Reserving stock…')
		await callApi('POST /orders', 400)
		setMessage('Charging card…')
		await callApi('POST /payments', 400)
	}
	return (
		<form action={placeOrder}>
			<button type="submit" data-note>{message}</button>
		</form>
	)
}
1200 ms  → POST /orders
screen: Reserving stock…
1600 ms  ← POST /orders
1600 ms  → POST /payments
screen: Charging card…
2000 ms  ← POST /payments
screen: Place order

An optimistic value can change as often as you like during the action, then falls back to “Place order” when it ends.

How it works

Common mistakes

Interview Q&A

What is optimistic UI?

Showing the expected result of an action immediately, before the server confirms it, and correcting it if the server disagrees.

Why doesn't useState work for it inside a form action?

Form actions run in a transition, and React holds state updates in a transition until it finishes. Recorded: the review appeared only after the 800 ms request.

How does useOptimistic work?

It returns a value that can be updated during an action and falls back to the real state when the action ends. Recorded: the review showed “(sending…)” immediately.

How do you get rollback on failure?

Do nothing special: when the action ends, the optimistic value reverts to the real state, which didn’t change. Recorded: after a server error the optimistic review disappeared.

Why did the review appear twice for a moment?

The setReviews after await wasn’t part of the action’s transition in React 19, so it rendered while the optimistic copy still existed. Wrapping it in startTransition fixed it (recorded).

What does useFormStatus give you?

{ pending, data, method, action } for the form the component is inside, without passing props. It must be called from a child of the <form>.

Sources

Primary source: react.dev/reference/react/useOptimistic