Skip to the solution
HardFrontend7 min read

How does useOptimistic enable optimistic UI in React 19?

704 views

The problem

Question presented to candidate: "A like button should feel instant even though the request takes 300ms. How does useOptimistic help, and what happens if the request fails?"

What a strong answer should cover:

  • useOptimistic(actualState, updateFn) returns a value that may differ from real state while an action is pending, plus a function to apply an optimistic update.
  • It shows a predicted result immediately, then automatically reverts to the real state when the transition settles.
  • Error handling is the headline benefit: if the action throws, React discards the optimistic value automatically. No manual rollback code.
  • It must be used inside a transition β€” an action, or startTransition. Outside one, the optimistic value is discarded immediately.
  • The update function is a reducer: (currentState, optimisticValue) => newState, so several optimistic updates compose.
  • Pairs naturally with form actions and useActionState.
  • The distinction from plain local state: hand-rolled optimistic UI means writing the revert path yourself, and getting it wrong on error or on rapid repeated actions.
  • Judgement: optimistic UI suits high-success, low-stakes actions β€” likes, toggles, reordering. Not payments.

Clarifying questions expected:

  • "How likely is this action to fail, and how bad is a visible revert?" β€” that decides whether optimistic UI is appropriate at all.
  • "Are we already using form actions?"

Code / implementation expected: Yes β€” an optimistic list or counter, including a failure path.

  • react
  • react-19
  • hooks
  • optimistic

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

7 min read Β· 9 sections

Try it in the playground

Open any example in the playground to run and change it.

ReactAn optimistic list with a deliberate failure path
import { useState, useOptimistic, useTransition } from "react";

let nextId = 3;
const wait = (ms) => new Promise((r) => setTimeout(r, ms));

export default function App() {
  const [todos, setTodos] = useState([
    { id: 1, text: "Read the docs", pending: false },
    { id: 2, text: "Try useOptimistic", pending: false },
  ]);
  const [failNext, setFailNext] = useState(false);
  const [error, setError] = useState(null);
  const [isPending, startTransition] = useTransition();

  // The reducer receives the CURRENT real state and the optimistic value, so
  // several pending additions stack on top of each other.
  const [optimisticTodos, addOptimistic] = useOptimistic(
    todos,
    (current, newTodo) => [...current, newTodo],
  );

  const add = (text) => {
    setError(null);
    // Must be inside a transition β€” that is the pending window the optimistic
    // value lives in. Outside one it would be dropped immediately.
    startTransition(async () => {
      addOptimistic({ id: "temp-" + Date.now(), text, pending: true });

      await wait(900);                              // pretend network

      if (failNext) {
        // No rollback code needed: throwing discards the optimistic entry.
        setError("Server rejected: " + text);
        setFailNext(false);
        throw new Error("rejected");
      }
      setTodos((prev) => [...prev, { id: nextId++, text, pending: false }]);
    });
  };

  return (
    <div style={{ padding: 24, fontFamily: "system-ui", lineHeight: 1.6, maxWidth: 480 }}>
      <form
        onSubmit={(e) => {
          e.preventDefault();
          const text = new FormData(e.target).get("text");
          if (text) { add(text); e.target.reset(); }
        }}
      >
        <input name="text" placeholder="add a todo" defaultValue="New task" />{" "}
        <button type="submit">add</button>
      </form>

      <label style={{ display: "block", margin: "8px 0", fontSize: 14 }}>
        <input type="checkbox" checked={failNext} onChange={(e) => setFailNext(e.target.checked)} />{" "}
        make the next request fail
      </label>

      <ul style={{ paddingLeft: 20 }}>
        {optimisticTodos.map((t) => (
          <li key={t.id} style={{ opacity: t.pending ? 0.5 : 1, fontStyle: t.pending ? "italic" : "normal" }}>
            {t.text} {t.pending && <span style={{ fontSize: 12, color: "#666" }}>(saving…)</span>}
          </li>
        ))}
      </ul>

      {error && (
        <p style={{ color: "crimson", fontSize: 14 }}>
          {error} β€” note the item vanished from the list with no rollback code.
        </p>
      )}

      <p style={{ color: "#666", fontSize: 13 }}>
        Add an item: it appears instantly, dimmed, while the request runs. Tick
        the failure box and add another β€” it appears, then disappears when the
        action throws. React discarded the optimistic entry on its own.
        {isPending && <strong> (a request is in flight)</strong>}
      </p>
    </div>
  );
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 90 of 119 in React.js

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All React.js questions
Up nextHard

useEffectEvent β€” separating reactive and non-reactive logic in Effects

Continue