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.
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}- event
return <form action={addReview}>… <SubmitButton /></form>Submit. The form action runs inside a transition, and thesetReviewsbefore the request is part of it… - commit phase
return <button>{pending ? 'Posting…' : 'Post review'}</button>…so React holds it back until the whole action ends. Only the button shows progress, throughuseFormStatus. - commit phase
const saved = await postReview(formData)The review appears only when the server answers, 800 ms after clicking.
1function Reviews() {2 const [reviews, setReviews] = useState(initial)3 const [shown, addOptimistic] = useOptimistic(reviews,4 (current, text) => [...current, { text, sending: true }])5 async function addReview(formData) {6 addOptimistic(formData.get('text'))7 const saved = await postReview(formData)8 setReviews(r => [...r, saved]) // after an await9 }10 return <form action={addReview}>…</form>11}- commit phase
addOptimistic(formData.get('text'))An optimistic value is allowed to render during the transition: the review shows immediately, marked as sending. - commit phase
setReviews(r => [...r, saved]) // after an awaitThe server answers. But asetReviewsafter anawaitisn’t part of the action any more: it renders on its own while the optimistic copy still exists. - commit phaseRecorded: the review appeared twice for a moment, then the action ended and the optimistic value fell back to the real list.
1function Reviews() {2 const [reviews, setReviews] = useState(initial)3 const [shown, addOptimistic] = useOptimistic(reviews,4 (current, text) => [...current, { text, sending: true }])5 async function addReview(formData) {6 addOptimistic(formData.get('text'))7 const saved = await postReview(formData)8 startTransition(() => setReviews(r => [...r, saved]))9 }10 return <form action={addReview}>…</form>11}- commit phase
addOptimistic(formData.get('text'))Shown immediately, as before. - commit phase
startTransition(() => setReviews(r => [...r, saved]))startTransitionputs the update back into the action. Recorded: the real review replaced the optimistic one in a single step, with no duplicate.
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
- Actions are transitions. A function passed to
<form action>(or called insidestartTransition) runs as a transition: its state updates render together when it completes. useOptimistic(value, reducer?)returns[optimisticValue, setOptimistic]. Outside an action,optimisticValueis justvalue. During one,setOptimisticoverrides it; when the action ends, it’svalueagain, whatever happened.- After an
await, React 19 loses track of which action an update belongs to (async context isn’t preserved yet), so updates after theawaitneed their ownstartTransition. React’s docs note this limitation.
Common mistakes
useStatefor optimistic results inside an action (recorded: nothing until the server answered).- Updating real state after
awaitwithoutstartTransition(recorded: a duplicate review). useFormStatusin the component that renders the<form>. It only sees forms above it.- No error message on failure. The rollback is silent.
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>.
Related
- Forms: form actions and
FormData. - Promise Caching and Transitions: transitions and
isPending.
Sources
- react.dev:
useOptimistic,useFormStatus,useTransition(state updates afterawait) - Topic order inspired by Kent C. Dodds’ EpicReact React Suspense workshop; the example app and code here are this handbook’s own.
Primary source: react.dev/reference/react/useOptimistic