Engineering Notes
Home/Frontend/React · Hooks/useOptimistic
Frontend · React · Hooks

useOptimistic

useOptimistic shows the expected result of an action immediately and reverts automatically if it fails. Watch a sent message appear instantly, then either settle on success or roll back on error.

🎨 Frontend● Intermediate⏱ 7 min read▶ 1 interactive🛠️ 2 exercises🎤 5 interview Q&As

🎯 What you'll learn

  • The problem it solves
  • The basic API
  • Automatic reverting
  • Calling inside a transition and composing with useActionState

📚 Before you start

1The Problem It Solves

When a user sends a chat message or likes a post, waiting for the server round-trip before showing the change makes the UI feel sluggish — the "correct" data is often extremely predictable, so showing it immediately (and quietly correcting course if the server disagrees) feels far more responsive:

tsx
// Without useOptimistic: the new message only appears once the network
// request resolves — a visible delay between clicking "Send" and seeing it
function ChatThread({ messages, sendMessage }: { messages: Message[]; sendMessage: (text: string) => Promise<void> }) {
  const [pending, setPending] = useState(false);

  async function handleSend(text: string) {
    setPending(true);
    await sendMessage(text); // messages only updates after this resolves
    setPending(false);
  }
  // ... the sent message is invisible until the round-trip completes
}

useOptimistic lets the UI display a predicted state immediately, while the real update is still in flight, and automatically reverts to the real data once the actual update lands (or fails).

2The Basic API

tsx
import { useOptimistic } from "react";

interface Message {
  id: string;
  text: string;
  sending?: boolean;
}

function ChatThread({ messages, sendMessage }: { messages: Message[]; sendMessage: (text: string) => Promise<void> }) {
  const [optimisticMessages, addOptimisticMessage] = useOptimistic(
    messages,
    (currentMessages, newText: string) => [
      ...currentMessages,
      { id: `temp-${Date.now()}`, text: newText, sending: true },
    ]
  );

  async function handleSend(formData: FormData) {
    const text = formData.get("text") as string;
    addOptimisticMessage(text); // shows immediately, before the network call
    await sendMessage(text);    // once this resolves, `messages` (the real
                                  // prop) updates and optimisticMessages
                                  // reverts to reflecting it
  }

  return (
    <form action={handleSend}>
      {optimisticMessages.map((m) => (
        <p key={m.id} style={{ opacity: m.sending ? 0.5 : 1 }}>{m.text}</p>
      ))}
      <input name="text" />
    </form>
  );
}

useOptimistic(actualState, updateFn) returns [optimisticState, addOptimistic]:

  • actualState — the real, confirmed state (usually a prop derived from server data).
  • updateFn(currentState, optimisticValue) => newState — a pure function describing how to merge an optimistic guess into the current (possibly already-optimistic) state.
  • optimisticState — what to render; equals actualState when nothing is pending, or the predicted merge while a transition is in flight.
  • addOptimistic(value) — call this (inside a transition — typically inside a form action or another transition-wrapped handler) to apply the optimistic update immediately.

3It Automatically Reverts

The optimistic value is never stored as its own independent state that a developer must remember to clear — it's derived fresh every render from actualState and whatever optimistic values are still pending:

tsx
// If sendMessage fails, don't manually "undo" the optimistic message —
// simply don't update `messages` with it. Once the pending transition
// settles, useOptimistic re-derives optimisticState from the real
// `messages` prop, and the temporary optimistic entry disappears on its own.
async function handleSend(formData: FormData) {
  const text = formData.get("text") as string;
  addOptimisticMessage(text);
  try {
    await sendMessage(text); // on success, caller re-fetches/updates `messages`
  } catch {
    showErrorToast("Message failed to send"); // no manual optimistic-state cleanup needed
  }
}

This is the core mental model: optimisticState is always a function of actualState plus in-flight guesses, never a separate mutable store — so there's nothing to "roll back" by hand.

4Must Be Called Inside a Transition

addOptimistic(value) only has an effect when called during a transition — inside a useActionState/form action, inside startTransition, or inside another hook that's already part of one. Calling it in a plain event handler outside any transition does nothing useful, because React needs a transition to know it should render the optimistic value first, then keep that render around while the real update resolves:

tsx
// CORRECT: inside a form action (which useActionState/form actions
// automatically wrap in a transition)
<form action={async (formData) => {
  addOptimisticMessage(formData.get("text") as string); // works — inside the action
  await sendMessage(formData.get("text") as string);
}}>

// Also correct: explicitly wrapped
function handleClick() {
  startTransition(() => {
    addOptimisticLike();
  });
}

5Composed With useActionState

The two are frequently paired: useActionState manages the actual async submission and its result, while useOptimistic predicts what that result will look like before it arrives:

tsx
async function likePostAction(previousState: number, _formData: FormData) {
  await new Promise((r) => setTimeout(r, 800)); // network latency
  return previousState + 1; // the server-confirmed like count
}

function LikeButton({ initialLikes }: { initialLikes: number }) {
  const [likes, formAction] = useActionState(likePostAction, initialLikes);
  const [optimisticLikes, addOptimisticLike] = useOptimistic(likes, (current) => current + 1);

  return (
    <form action={async (formData) => {
      addOptimisticLike(undefined); // instantly shows likes + 1
      await formAction(formData);   // real update lands in `likes` once resolved
    }}>
      <button>{optimisticLikes} likes</button>
    </form>
  );
}
useOptimistic renders a predicted merge of actualState immediately; once the real update commits, actualState changes and optimisticState re-derives from it, discarding the temporary guess
useOptimistic renders a predicted merge of actualState immediately; once the real update commits, actualState changes and optimisticState re-derives from it, discarding the temporary guess
Under the HoodHow it works internally▾

React does not attach state to the component function — a function component is just a function, called fresh every render with no persistent memory of its own. Hook data lives on the fiber, in the same singly linked list of hook objects every hook in this curriculum uses, walked in strict call order every render.

useOptimistic's hook node tracks the passed-in actualState and a pending queue of optimistic updates, conceptually similar to how useState's node tracks a pending-update queue. What's different is when the optimistic value gets rendered versus committed: calling addOptimistic(value) inside a transition tells React "re-render right now, showing the result of updateFn(latestState, value), but treat this render as provisional." React shows that provisional render to the user immediately — this is what makes the UI feel instant — while the transition that triggered it (an async form action, typically) is still running in the background.

The "revert" behavior is not an undo operation React performs — it's a direct consequence of optimisticState being recomputed from scratch every render, as updateFn(actualState, pendingOptimisticValue), rather than being its own independently stored value. Once the transition's real async work resolves and updates actualState (by whatever mechanism the surrounding code uses — a useActionState result, a state setter, a re-fetch), the next render calls updateFn again with the new actualState. If there's no longer a pending optimistic value queued for that update, optimisticState simply equals actualState — the temporary guess evaporates because it was never persisted anywhere; it only existed as one render's derived output. This is exactly analogous to how useMemo's cached value is derived, not stored independently of its inputs — except here the "inputs" include a transient in-flight value tracked by the transition system, not just a dependency array.

This ties useOptimistic structurally to the same transition machinery useActionState and useTransition use: all three are built around React tracking "work that's allowed to keep the current UI interactive while something asynchronous resolves in the background," rather than the older model of a single synchronous state update per event.

⚠️ Common Pitfalls

  1. Calling addOptimistic outside a transition (a plain click handler with no startTransition, no form action) — the optimistic update has no transition to attach to and won't behave as expected.
  2. Treating optimisticState as separately mutable state — it's derived from actualState every render; there's no "setOptimisticState" to call directly outside updateFn's merge logic.
  3. Forgetting to eventually update actualState — if the real async operation never updates the underlying state/prop, the optimistic value has nothing to revert to or reconcile against once the transition settles, leaving the UI showing a stale prediction indefinitely.
  4. Writing an impure or side-effecting updateFn — like a reducer, it should be a pure function from (currentState, optimisticValue) to a new state, called potentially multiple times as renders occur.
  5. Using useOptimistic for updates where an incorrect guess would be confusing or costly to show (e.g., a payment total) — it's best suited to low-stakes, highly predictable updates (likes, sent messages, toggles) where a brief incorrect flash is an acceptable trade for responsiveness.

📋 Quick Reference

text
const [optimisticState, addOptimistic] = useOptimistic(actualState, updateFn)

updateFn: (currentState, optimisticValue) => newState   → pure, like a reducer

addOptimistic(value)  → call INSIDE a transition (form action / startTransition)
optimisticState        → actualState merged with any pending optimistic guess
                          → automatically reverts once actualState updates for real

🧠 Key takeaways

  • useOptimistic(state, updateFn) → [optimisticState, addOptimistic].
  • Optimistic values exist only while the transition/action is pending, then fall back to real state.
  • It reverts automatically on failure; you only add error messaging.
  • Must be called inside an action or startTransition; composes with useActionState and Server Actions.

🎤 Interview Q&A

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

Q1What is useOptimistic for?core▾

Showing an optimistic UI update immediately while an async action (e.g. a server action) is in progress: const [optimisticItems, addOptimistic] = useOptimistic(items, (state, newItem) => [...state, newItem]). When the action finishes, state returns to the real value.

Follow-ups interviewers ask
  • Example? A sent message appears instantly with a 'sending…' indicator.
Q2What happens if the action fails?core▾

The optimistic state is discarded when the transition ends, so the UI reverts to the actual state; you should show an error and possibly offer retry.

Follow-ups interviewers ask
  • Why automatic revert? Optimistic values live only during the transition.
Q3How must useOptimistic be called?core▾

The update function (addOptimistic) should be called inside an action or startTransition, so React ties the optimistic state to the pending transition.

Follow-ups interviewers ask
  • Outside a transition? React warns and the update won't behave as intended.
Q4When is optimistic UI a bad idea?senior▾

When failures are common or costly to undo (payments, irreversible actions), or when the server computes values the client can't predict.

Follow-ups interviewers ask
  • Alternative? Pending states with clear feedback.
Q5How do libraries like TanStack Query do optimistic updates?core▾

In onMutate, cancel related queries, snapshot the cache, update it optimistically, and roll back in onError; refetch in onSettled.

Follow-ups interviewers ask
  • Advantage of useOptimistic? Built into React's transitions and server actions.

🛠️ Practice exercises

Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.

✅ Check yourself

Answer the questions — feedback is instant.

1. What happens to the optimistic value if the action fails?
Automatic revert.
2. addOptimistic must be called…
Transition-bound.
3. Good use case?
Low-risk, reversible.
4. useOptimistic replaces…
Less boilerplate.