Skip to content
advanced Phase 6 · Server Actions and Forms

Optimistic UI Updates

Reflect changes instantly while the server confirms.

45m
0 problems
Topic Progress 0%

useOptimistic in One Screen: Predicting the Result Before the Server Answers

The user clicks "Like". On a fast connection the server answers in 80 ms; on hotel wifi it answers in 1.5 seconds. During that gap the counter sits unchanged and the interaction feels broken — the classic reason optimistic UI exists: show the predicted result the instant the action starts, then reconcile with reality.

React's hook for it lives up to its name: useOptimistic(initialValue, updateFn) returns a state slot that holds the real value normally and a projected value while an action is in flight.

'use client';
import { useOptimistic } from 'react';
import { likePost } from './actions';

export function LikeButton({ post }: { post: Post }) {
  const [optimisticLikes, addOptimisticLike] = useOptimistic(
    post.likes,                            // real value from the server
    (current, delta: number) => current + delta,   // how to project it
  );

  return (
    <form
      action={async () => {
        addOptimisticLike(1);              // prediction: +1 NOW
        await likePost(post.id);           // the real mutation
      }}
    >
      <button type='submit'>♥ {optimisticLikes}</button>
    </form>
  );
}

Read the three pieces carefully, because each one is a different kind of value:

  • post.likes is the server's truth, arriving with the render (or refreshed after revalidation).
  • the updateFn is pure and declarative: given the current value and what you pass to the setter, return the projection. It never touches the network — it only says what the UI should look like if this succeeds.
  • optimisticLikes is what renders: post.likes normally, the projection while the action is pending, then back to post.likes (now re-rendered from fresh server data) when it settles.

The value you pass to the setter is a delta instruction, not a value to display — addOptimisticLike(1) means "apply this to the current real state". Projecting a whole object instead works too (setOptimistic(next => ({ ...next, text })) in a message composer), and is the right shape when the prediction is "my text appears in the list" rather than "the count increases".

Note the timing requirement: the setter must run inside the action's lifecycle — in the action prop function, or inside startTransition when calling from an event handler. Calling addOptimisticLike(1) outside a transition does nothing useful (React has no pending window to attach it to), which is the single most common way this hook appears to "not work". The form above wraps both steps so the prediction and the mutation are one unit of work.

Why not just setState the new count and be done? Because a local useState and an optimistic slot have different lifecycles: local state persists until you overwrite it, so a failed action leaves your number lying to the user; the optimistic slot is scoped to the transition and disappears when it ends, restoring whatever the server currently says. That scoping is the feature. It also means the hook composes with the rest of the server pipeline: the action's revalidatePath updates the prop underneath, and the optimistic view is simply discarded once the authoritative value arrives — no synchronization code, no "did the server confirm yet" flag in component state.

Equally important is what you must not put in the projection: the final server timestamp, a canonical like-id, or anything the server will assign differently than your guess. Predict the user-visible shape (count up, row appended), then let the real record replace it — a projection that fabricates fields you cannot honor will flicker or contradict the revalidated data a moment later.

What you get for that wiring is the interaction designers ask for: instant visual feedback, no spinner on the button, and a UI that stays responsive through the request. What you are also doing is making a claim about the future — and the next two chapters are about what happens when the server agrees, and what happens when it does not.

Optimistic In, Authoritative Out: How the Value Swaps Back When the Action Settles

The lifecycle is the whole mechanism, so let's trace it precisely for a message composer that appends to a chat log.

'use client';
import { useOptimistic } from 'react';
import { sendMessage } from './actions';

export function Chat({ messages }: { messages: Message[] }) {
  const [optimistic, addMessage] = useOptimistic(
    messages,
    (state, pending: Message) => [...state, { ...pending, sending: true }],
  );

  return (
    <>
      <MessageList items={optimistic} />       {/* shows predicted log */}
      <form action={async (fd) => {
        const text = String(fd.get('text'));
        addMessage({ id: crypto.randomUUID(), text, author: 'me' });
        await sendMessage(text);
      }}>
        <input name='text' required />
        <button type='submit'>Send</button>
      </form>
    </>
  );
}

The four states it passes through:

Phase What renders Source
idle messages server truth in props
action starts messages + projection optimistic, marked sending
action resolves server's new list revalidated render replaces it
action throws the pre-action list automatic rollback, then error path

Three details that decide whether this works in production.

The projection must be replaced by fresh server data, not by the projection itself. After sendMessage completes, its action calls revalidatePath('/chat') (or the mutation's write is picked up by the re-render), so the messages prop is new — and because the optimistic slot only exists during the pending window, the component falls back to that new prop. If you forget the revalidation, the optimistic value disappears and the user sees their message vanish until some unrelated navigation refreshes the route. That is the classic bug: the UI "un-sends" the message. The optimistic value is a placeholder for a fetch you have not done yet, so the action owes you the fetch (here, the re-render).

Mark the predicted row so you can style reality. The sending: true flag on the projected message lets you render it at reduced opacity with a clock icon. When the real record arrives it carries its server timestamp instead — the visual difference is honest feedback, not decoration, and it is what tells a user "this one is still in flight".

Predictions compose; replacements do not have to be whole. Two rapid sends produce two setter calls, each projecting from the current real list, and React batches them into one render — you get both pending rows. When the first action settles, the list re-renders with server data for one message while the second is still pending; because each pending window is separate, the remaining projection still applies on top. This is why the updateFn must be a pure function of (realState, delta) rather than a closure over "what I think the list is now".

The alternative design — skip the hook, wait for the action, then let revalidation update the list — is not wrong; it is simply pessimistic rendering: correct, and visibly slow. The spectrum from spinner → pessimistic update → optimistic-with-rollback is a UX/correctness trade you should be able to place yourself on deliberately. For a like count, optimistic is clearly right. For a payment confirmation, pessimistic is the only responsible choice — showing "Payment complete" before the server agrees is a lie the user will act on.

When the Action Fails: What Rollback Guarantees — and the Four Cases It Does Not Cover

The rollback promise is the reason this hook is safe enough to use: if the action throws, React discards the projection and renders the real state again, with no code from you. The user sees the count snap back, the error boundary (or your caught rejection) handles the message, and UI and server agree because only the server's value survived.

That guarantee is narrower than it sounds. Know its edges before an interviewer (or a code review) finds them for you:

  1. Rollback happens only on throw. If the action resolves but the mutation did not actually happen — wrong argument swallowed by a try/catch that returns { ok: false }, a silent no-op, a validation error you returned instead of threw — the optimistic window still ends and the projection drops, but nothing tells the user why. Returning failure is correct for validation, so pair it with explicit UI: read the returned { ok: false } and show the message (from useActionState), because the rollback alone will look like a glitch.
  2. It rolls back to the stale truth, not the fresh truth. The fallback is the pre-action prop. If someone else's change arrived during your request and the action never revalidates, you roll back to data that was already out of date — the snapshot was stale before your click.
  3. It does not undo side effects outside the render. A toast fired optimistically, an analytics event, a socket message you already pushed — rollback fixes the React tree and nothing else. Keep non-reversible emissions until the action succeeds (await action(); toast.success(...)), or make them idempotent.
  4. Concurrent edits can interleave. Two tabs clicking Like produce two optimistic +1s from the same base; each rollback restores its base and the final render must come from revalidated data. If the server's state and the prop disagree at that moment, the last render wins — which is exactly why the final word always belongs to the server.

Rollback is also where error handling design meets: a thrown error triggers rollback and the boundary; a returned error ends the optimistic window and hands you a message to render. Choose per failure class — infrastructure failure throws, business validation returns — and make sure the user learns which happened:

const res = await sendMessage(text);          // inside the transition
if (!res.ok) toast.error(res.message);        // returned failure: explain it

Finally, add the guardrails that make optimistic UI reviewable: a loading affordance on the predicted row (sending flag), a debounce or disable on rapid-fire buttons where duplicates matter, and an idempotent action (client-generated message id) so a retry cannot double-apply. The mental model to leave the chapter with: the optimistic value is a bet placed in the UI and settled by the server — React handles placing it and calling it back, the action is responsible for making the truth match, and every guarantee above follows from who owns which half.

How do you test this? Unit-test the updateFn (pure functions fall out immediately: given a list and a delta, assert the projection), then cover the lifecycle with an integration test that delays the action: click, assert the projected row appears before the server resolves, resolve, assert the revalidated list replaces it, and finally run the failure case to assert rollback. A test suite that only checks the settled state will happily pass while the optimistic path is broken — and the optimistic path is precisely the part users touch on every click. The prediction only earns trust if the action behind it validates its input first (Validating Action Input) and then returns data the client can trust; when the caller is not your UI at all, the endpoint you build instead is Creating GET and POST Handlers.

Quiz

1. useOptimistic(initialState, updateFn) returns what, and when does the projection appear?

Question 1 options

2. The action succeeds and revalidates, but the user's message disappears from the list. Root cause?

Question 2 options

3. An action resolves with { ok: false, message: 'Quota reached' } instead of throwing. What happens to the optimistic UI?

Question 3 options

4. Which interaction is the worst candidate for an optimistic update?

Question 4 options

Flashcards

Question

What does useOptimistic return, and what must wrap the setter call?

Answer

[optimisticState, setOptimistic]. The setter must run inside the form action function or a startTransition — React attaches the projection to that pending window. Outside a transition there is no window, so nothing renders optimistically (the most common 'it doesn't work' cause).

Question

What happens to the optimistic value when the action settles?

Answer

On success the window closes and the slot reverts to the real prop — so the action must revalidate (revalidatePath/revalidateTag) or the UI appears to undo the change. On throw, React rolls back automatically to the pre-action state and the error propagates to the boundary.

Question

The updateFn contract: inputs, purity, and what you pass the setter?

Answer

updateFn(realState, delta) => projected state, and it must be pure — no network, no mutation. The setter receives a delta instruction (increment, appended item), not the value to display, so multiple calls batch correctly from the latest real state.

Question

Four failure cases optimistic rollback does not cover?

Answer

1) resolved-but-failed actions returning { ok: false } (no throw, so no explanation — surface the message); 2) rollback lands on a stale pre-action snapshot if nothing revalidated; 3) non-React side effects (toasts, analytics, socket pushes) are not undone; 4) concurrent edits from other tabs interleave — final truth must come from revalidated data.

Question

When should you NOT use an optimistic update?

Answer

When the outcome is consequential or not yet known: payments, deletions with side effects, anything whose confirmation changes what the user does next. Optimism presents a bet as fact — use pessimistic rendering (wait for the authoritative result) and reserve optimism for reversible, low-stakes, latency-sensitive UI.

Revision Notes

Key Takeaways

  • 1. useOptimistic renders a projected value only during the action's pending window, then falls back to the real server-provided state
  • 2. The setter must be called inside the action or a transition; outside one there is no pending window and nothing renders
  • 3. Revalidation is what makes the prediction durable — forget it and the UI un-sends the message the moment the action settles
  • 4. Thrown actions roll back automatically; returned failures do not — you must surface the returned message
  • 5. Optimism presents a bet as fact: reserve it for reversible, low-stakes, latency-sensitive interactions

Interview Tips

  • • "Explain optimistic updates" — projection during pending, revert to real state on settle, automatic rollback on throw; then name the revalidation requirement, which most candidates skip
  • • "My optimistic value flashes then disappears" — the action didn't revalidate, so the slot reverted to an unchanged prop; the fix is in the action, not the component
  • • "Rollback vs returned errors" — throw triggers React's rollback + boundary; a returned { ok: false } ends the window silently, so the template must render the message (useActionState)
  • • "When would you avoid it?" — consequential outcomes (payments, irreversible actions): UI must reflect confirmed state; show you treat optimism as a UX/correctness trade, not a default

Cheat Sheet

Optimistic UI Updates

const [optimistic, setOptimistic] = useOptimistic(
  realValue,                                  // server truth (props)
  (state, delta) => project(state, delta),    // pure projection
);

<form action={async (fd) => {
  setOptimistic(delta);        // prediction — must be inside the action/transition
  const res = await doAction(fd);
  if (!res.ok) toast.error(res.message);   // returned failure: explain it
}}>

Lifecycle:

Phase Renders
idle real prop
pending real prop + projection (style the sending flag)
success revalidated server data — action must revalidate or the change vanishes
throw automatic rollback to pre-action state -> error.tsx

Contract: setter runs inside action/startTransition · updateFn is pure and takes (realState, delta) · delta is an instruction (increment/append), not a display value · multiple calls batch from the latest real state.

Rollback does not cover: resolved { ok: false } (render the message yourself) · stale snapshot · non-React effects (toasts, analytics, sockets) · concurrent edits from other tabs.

Choose wisely: optimistic for likes/drafts/prefs/chat; pessimistic for payments, destructive or consequential results.

Guardrails: pending styling · idempotent action (client-generated id) · disable/debounce rapid submits.

Continue Learning