skip to content

A React 19 chat UI shows a message optimistically while the send Action is in flight. If that Action rejects, what does the user actually see, and what does the 'rollback' consist of in code?

level: middleimportance: should knowfreq 40%

answer

  1. it is an overlay, not a write
  2. lifetime equals the transition
  3. real state never changed
  4. nothing to undo, something to explain
  5. silent disappearance reads as a bug

basics

~20 s

Rollback is not an undo step. The optimistic value exists only while the Action is pending; when it rejects React re-renders from the real state, which never changed, so the temporary message simply disappears. Showing the error is your job.

solid answer

~50 s

The optimistic value is a temporary overlay React derives on top of your real state, and its lifetime is exactly the lifetime of the Action's transition. While the send is pending, renders see the real messages plus the pending one. The moment the Action settles — resolved or rejected — React drops the overlay and renders the real state again. On failure the real state was never updated, so the pending message vanishes and the list is back where it started; there is no reversal code to write, which is why people are surprised there is nothing to write. The trap is that vanishing silently is terrible UX: the user's message disappears with no explanation. So the Action must catch the failure and put something durable into real state — an error banner, or the failed message re-inserted with a "retry" affordance — before it finishes.

code

jsx · 26 lines
jsx
function Chat({ messages, setMessages }) {
  const [optimisticMessages, addOptimistic] = useOptimistic(
    messages,
    (state, pending) => [...state, { ...pending, sending: true }]
  );

  async function send(formData) {
    const text = formData.get('text');
    addOptimistic({ text });
    try {
      const saved = await sendMessage(text);
      setMessages(current => [...current, saved]);
    } catch {
      setMessages(current => [...current, { text, failed: true }]);
    }
  }

  return (
    <form action={send}>
      {optimisticMessages.map(m => (
        <p key={m.id ?? m.text}>{m.text}{m.sending ? ' …' : ''}</p>
      ))}
      <input name="text" />
    </form>
  );
}

go deeper

for a junior

Know that an optimistic value is shown only while the Action is running and that React removes it automatically when the Action ends, whether it succeeded or failed.

for a middle

Explain that the optimistic value is derived on top of real state rather than written into it, which is why failure needs no reversal — and why the failure therefore looks like a silent disappearance.

for a senior

Show how you close the communication gap: catching inside the Action and committing a durable failed entry or error banner, plus how you handle several in-flight mutations resolving out of order.

for a principal

Own the policy on where optimistic UI is allowed at all. Weigh perceived speed against the cost of a wrong-then-corrected value, and rule it out for money, irreversible actions, and anything a user makes a decision on.

## What "optimistic" means mechanically An optimistic update is a *derived* view, not a second copy of your data. You keep your real state — the messages the server has confirmed — and React lets you layer a provisional value on top of it for the duration of an Action. `useOptimistic` is the primitive: it takes your real state and a function describing how a pending item modifies it, and during an Action every render sees the modified version. The crucial property is the lifetime. React ties the overlay to the transition that the Action runs in. When that transition finishes, the overlay is gone — unconditionally, not conditionally on success. ```jsx const [optimisticMessages, addOptimistic] = useOptimistic( messages, (state, pending) => [...state, { ...pending, sending: true }] ); async function send(formData) { const text = formData.get('text'); addOptimistic({ text }); const saved = await sendMessage(text); // if this throws, see below setMessages(current => [...current, saved]); } ``` ## The success path and the failure path are the same mechanism On success: the overlay shows the provisional message, then `setMessages` commits the real one, then the transition ends and the overlay disappears. Because the real state now contains an equivalent message, the user sees continuity — the provisional row is replaced by the confirmed row, usually indistinguishably. On failure: the overlay shows the provisional message, `sendMessage` rejects, `setMessages` never runs, the transition ends, the overlay disappears. Real state is exactly what it was, so the message is simply gone from the screen. That is the whole of "rollback". There is no inverse operation, no snapshot to restore, no `undo` callback. This is the reason the model is safe by construction: an optimistic update cannot leave your state corrupted, because it never wrote to your state in the first place. ## Why the silent vanish is the real interview point Automatic rollback solves the *consistency* problem and creates a *communication* problem. A user typed a sentence, saw it appear, and then watched it evaporate with no explanation — that reads as a bug even though the state is perfectly correct. Handling it is your responsibility and it must be done inside the Action, before the transition ends: ```jsx async function send(formData) { const text = formData.get('text'); addOptimistic({ text }); try { const saved = await sendMessage(text); setMessages(current => [...current, saved]); } catch { setMessages(current => [...current, { text, failed: true }]); } } ``` Here the catch writes a durable failed entry into real state, so when the overlay is dropped there is something for the user to see and retry. The alternative shapes are an error banner in state, or — if the failure is genuinely exceptional rather than expected — letting it reach an error boundary. Note also that if you let the rejection escape unhandled, the overlay still disappears, but now the nearest error boundary replaces the subtree. That is the correct behaviour for "the server is broken" and the wrong behaviour for "that message was too long". ## Ordering and multiple in-flight updates Because the overlay is produced by a reducer-shaped function over real state, several optimistic items applied during the same Action compose in the order they were added. Across separate Actions each has its own lifetime; a fast second send can settle while a slow first one is still pending, so do not assume the list order on screen mirrors the order the server will confirm. If ordering matters, sort on a field you control rather than on arrival. ## Where the technique does not fit Optimistic UI is a bet that the mutation will succeed, and the bet is only worth taking when success is overwhelmingly likely, the operation is cheap to lose, and the correction is cheap to show. Adding a chat message qualifies. Charging a card, transferring money, or anything where the user makes a downstream decision based on the optimistic result does not — showing a balance that is about to be wrong is worse than a spinner.

  • If there is nothing to undo, why do people still write catch blocks around optimistic Actions?
    Not to revert — to communicate. When the overlay disappears the user needs to know why, so the catch writes something durable into real state: a failed-message entry with a retry control, or an error banner. Without it the message vanishes silently, which reads as data loss even though the state is correct.
  • What happens to the optimistic value if the user navigates away mid-Action?
    It disappears with the component, like any render-derived value — there is no persistence and no queue. The in-flight request itself keeps running unless you cancel it, so the mutation may still land on the server with nothing on screen reflecting it. If that matters, the confirmed result has to be re-read on the next mount rather than assumed.

saying these in an interview costs you the question

  • Thinks you must write code to reverse the optimistic change
  • Believes React retries the failed Action automatically
  • Says the optimistic value persists until you clear it
  • Assumes the user is told about the failure automatically
  • Uses optimistic UI for payments or irreversible operations

context