Menu

React useOptimistic Hook: Instant UI With Rollback

useOptimistic shows the result you expect while an async action is still running, then falls back to the real state when it ends, so a failed request undoes itself. Learn the syntax, the reducer form, and why it must run inside an action.

This page includes runnable editors - edit, run, and see output instantly.

useOptimistic is a React 19 hook that lets the screen show the result of an action before the action finishes. You set an optimistic value inside an action; React shows it while the action is pending, then goes back to the real state. If the request failed, the real state never changed, so the screen rolls back by itself.

The fake API in this example stands in for a real request and fails on every third call, so you can see both outcomes.

Click the button three times, waiting a second between clicks. The first two switch at once and the saved line catches up a second later. The third also switches at once, then flips back when the request fails. Change calls % 3 to calls % 2 and every second click fails.

The syntax

const [optimisticState, setOptimistic] = useOptimistic(state, updateFn?);
  • state is the real value, usually from useState, props or useActionState. When no action is pending, optimisticState is exactly this value.
  • setOptimistic(value) sets the optimistic value for as long as the current action runs.
  • updateFn is optional: (currentState, optimisticValue) => nextState. With it, setOptimistic receives a change rather than a whole new value. See the reducer form below.

The lifecycle of one click is always the same:

  1. Inside an action, you call setOptimistic. The screen updates at once.
  2. The action awaits the request.
  3. On success you update the real state (inside startTransition when it comes after an await).
  4. The action ends. optimisticState follows state again: the saved value on success, the old value on failure.

Step 4 is why there is no rollback code in the example. The catch block only sets an error message.

It must run inside an action

The optimistic value exists only while a transition is pending, so setOptimistic has to be called inside one. These all count:

  • the function you pass to startTransition (or the startTransition from useTransition),
  • a function passed to <form action={...}> or <button formAction={...}>,
  • the action you give to useActionState.

Called from a plain onClick, the optimistic value has no action to live in. React reverts it right away, and in development React logs "An optimistic state update occurred outside a transition or action".

Updates to the real state that happen after an await should be wrapped in their own startTransition, as startTransition(() => setLiked(next)) is above. React cannot tell, after an await, that you are still inside the earlier transition.

A message list with the reducer form

When the optimistic state is a list, pass an update function as the second argument. The setter then receives the new item, and React appends it to whatever the real list is at that moment.

Send three messages with different text, one at a time. Each appears faded with "(sending...)" and becomes solid once delivered; the third disappears from the chat and shows up as "Not sent". Send them quickly instead and all three stay faded until the last request ends, because React keeps optimistic values until every pending action has finished. The form action is already a transition, so addOptimistic needs no startTransition of its own here.

Give each optimistic item a key that will not clash with real items. The example uses pending- plus the text, so sending the same text twice while both are pending would clash; a real app would create an id on the client.

Several updates at once

Because the update function receives the current state, optimistic updates stack. Click quickly and each pending click is applied on top of the last one.

Click four times fast. The cart shows 4 at once. When the requests finish, the count settles on 3, because the third request failed and the console says so. React keeps the optimistic values until every pending action has finished, then shows the real count.

When to use it, and when not

Optimistic updates suit actions that almost always succeed and are easy to undo: likes, stars, toggles, renaming, adding a message. The user sees no spinner for the common case.

Avoid them where a false "done" would mislead: payments, deleting an account, anything the user may act on before the result is known. For those, show a pending state with isPending from useActionState or useTransition and wait for the real answer.

Always tell the user when a rollback happens. A value that silently changes back looks like a bug. Both examples above keep an error message in ordinary state so it outlives the action.

With useActionState

useOptimistic and useActionState fit together. The action from useActionState is already a transition, so you can set the optimistic value at the start of it, and the returned state is the real value the optimistic one falls back to.

const [state, formAction] = useActionState(async (previous, formData) => {
    const title = formData.get('title');
    setOptimisticTitle(title);
    const saved = await saveTitle(title);
    return { title: saved.title };
}, { title: 'Untitled' });

const [optimisticTitle, setOptimisticTitle] = useOptimistic(state.title);

While the save runs, the page shows the new title. When it resolves, state.title holds the saved value. If saveTitle throws, the action's error goes to the nearest error boundary; to show the old title with a message instead, catch the error inside the action and return the previous state plus an error.

Common mistakes

Calling the setter outside an action. The optimistic value does not stay on screen, and React warns in development. Wrap it in startTransition or move it into a form action.

Forgetting to update the real state. On success, the action must change the state that useOptimistic mirrors (setLiked, setMessages). Otherwise the optimistic value disappears when the action ends, and a successful request looks like a rollback.

Updating the real state after an await without a transition. The update still lands, but React may show it at a different moment than the end of the action. Wrap it in startTransition, as all three examples do.

Using the optimistic value as the source of truth. Send requests and compute totals from the real state. The optimistic value is for display only and can be thrown away at any time.

Frequently Asked Questions

What does useOptimistic do?

It gives you a copy of a piece of state that you can change immediately while an async action runs. When the action finishes, the copy goes back to following the real state, which by then holds either the saved result or the old value.

How does useOptimistic roll back on error?

It does not need a special error path. The optimistic value only lives while the action is pending. If the request fails and you never update the real state, the hook shows the real state again, which is the value from before the click.

Why do I get "An optimistic state update occurred outside a transition or action"?

The setter was called from a plain event handler. Call it inside a function passed to startTransition, inside a <form action>, or inside an action from useActionState.

What is the second argument of useOptimistic?

An optional update function, (currentState, optimisticValue) => newState, like a reducer. With it the setter receives just the change (a new message, +1) and React computes the optimistic state, even when several updates are pending at once.

Is useOptimistic only for forms?

No. It works in any transition, so a button that calls startTransition(async () => { ... }) can use it as well as a form action.

Coddy programming languages illustration

Learn to code with Coddy

GET STARTED