Skip to content

Optimistic UI for AI Features

10 min read · updated August 4, 2026

Optimistic UI shows the result of an action before the server confirms it. With a model in the loop, most of the result is genuinely unknown — so the technique still applies, but only to the parts that are certain. Getting that line right is the whole of this page.

What is certain and what is not

Split the interaction. Some of it you know the instant the user clicks, because it is a consequence of their own action. The rest is the model’s output, which you cannot know and must not invent.

Certain the moment the user actsDescription
Their own message appearsYou have the text. Render it immediately, in its final position, with no round trip. This alone removes the most noticeable lag in a chat UI.
The conversation state advancedInput cleared, send disabled, the assistant's turn started. All local.
A placeholder for the answer existsYou know there will be an assistant message. You do not know what is in it, so it renders as a pending row rather than as invented text.
The action is undoableA stop button, a cancel affordance. Available before anything comes back.
Not certain, and must not be fakedDescription
The content of the answerObviously. Any placeholder text that reads like an answer will be screenshotted and quoted back to you.
Whether it succeedsRate limits, filters, provider outages and insufficient credit are all live possibilities at the moment of the click.
How long it takesA progress bar implies a known duration. You do not have one, and a bar that reaches 90% and stops is worse than no bar.
Whether the extracted data is rightFor a fill-this-form feature, showing fields as filled before validation means showing values that may fail the schema and vanish.

The React 19 pattern

React 19 has useOptimistic, which holds a temporary state that is automatically discarded when the surrounding transition completes. That automatic discard is the point: the class of bug where an optimistic entry survives past its real counterpart and the list shows a duplicate is designed out rather than handled.

"use client";
import { useOptimistic, useRef, useState, startTransition } from "react";
import { sendMessage } from "./actions";

type Message = {
  id: string;
  role: "user" | "assistant";
  text: string;
  pending?: boolean;
};

export function Composer({ initial }: { initial: Message[] }) {
  const [messages, setMessages] = useState(initial);
  const [error, setError] = useState<string | null>(null);
  const formRef = useRef<HTMLFormElement>(null);

  const [shown, addOptimistic] = useOptimistic(
    messages,
    (current: Message[], draft: string) => [
      ...current,
      { id: "optimistic-user", role: "user" as const, text: draft },
      { id: "optimistic-assistant", role: "assistant" as const, text: "", pending: true },
    ],
  );

  async function action(formData: FormData) {
    const draft = String(formData.get("text") ?? "").trim();
    if (!draft) return;

    setError(null);
    formRef.current?.reset();          // clear the input immediately

    startTransition(async () => {
      addOptimistic(draft);            // both rows appear now
      const result = await sendMessage(draft);

      if (!result.ok) {
        setError(result.error);
        // Put the text back so the user does not retype it. See below.
        if (formRef.current) {
          const input = formRef.current.elements.namedItem("text");
          if (input instanceof HTMLTextAreaElement) input.value = draft;
        }
        return;                        // optimistic rows are discarded here
      }

      setMessages((prev) => [...prev, result.userMessage, result.assistantMessage]);
    });
  }

  return (
    <>
      <ul>
        {shown.map((m) => (
          <li key={m.id} data-pending={m.pending ? "true" : undefined}>
            {m.pending ? <ThinkingRow /> : <MessageBody message={m} />}
          </li>
        ))}
      </ul>

      {error && <p role="alert">{error}</p>}

      <form ref={formRef} action={action}>
        <textarea name="text" required />
        <button type="submit">Send</button>
      </form>
    </>
  );
}

Two details matter more than they look. addOptimistic must be called inside the transition, or React has nothing to tie the discard to and the optimistic state lingers. And the assistant row is added as pending with empty text rather than with a placeholder sentence — it occupies the space, so the layout does not jump when the real answer arrives, without asserting anything about the content.

Rolling back without losing the input

Rollback is the half people skip, and it is the half users notice. The rule is short: rolling back the display must never roll back the user’s typing. A failed send that also clears the message box means the user retypes a paragraph they already wrote, and that is the moment they stop trusting the feature.

  1. Keep the draft until success. Clear the visible input for responsiveness, but hold the text in a variable, and put it back on failure — as the code above does.
  2. Say what failed, in the user’s terms. “Rate limited, try again in 20 seconds” is actionable; “Error” is not. The typed failure union is where those strings come from.
  3. Offer the retry as a button. The user should not have to reconstruct the action.
  4. Never silently drop it. An optimistic message that disappears with no explanation reads as data loss, because from the user’s side that is exactly what it is.
  5. Persist drafts for long inputs. Anything over a couple of hundred characters is worth a sessionStorage write, so a refresh or a crash does not eat it.

The waiting state that is not a spinner

Model latency runs from a few hundred milliseconds to tens of seconds, and the right waiting UI differs across that range. The useful design input is that a user tolerates waiting far better when the wait is legible — when something on screen changes in a way that corresponds to something real.

  • Under about 300ms: show nothing. A spinner that flashes for one frame is visual noise and reads as a glitch.
  • 300ms to about two seconds: a subtle indicator on the pending row. Not a modal, not a full-page state.
  • Two to ten seconds: stream, if the feature allows it. Tokens arriving are the best possible progress indicator because they are progress. If it cannot stream, name the current step — “searching your documents”, then “drafting the answer” — using stages you actually know you are in.
  • Beyond ten seconds: stop pretending it is interactive. Give it a job id, let the user leave, and notify them. This is the queue boundary from deploying without timing out.
// Delay the indicator so fast responses never flash one.
export function useDelayedPending(pending: boolean, delayMs = 300) {
  const [show, setShow] = useState(false);

  useEffect(() => {
    if (!pending) {
      setShow(false);
      return;
    }
    const timer = setTimeout(() => setShow(true), delayMs);
    return () => clearTimeout(timer);
  }, [pending, delayMs]);

  return show;
}

Optimism that is dishonest

There is a line between showing the user the consequence of their own action and asserting something you do not know. Crossing it is not a UX trade-off; it produces interfaces that lie.

  • Fake streaming. Revealing an already-complete answer character by character to look like generation. It adds latency to make the product look slower in a fashionable way, and a user who works it out reasonably distrusts the rest of the interface.
  • Determinate progress bars. You do not know the duration. An indeterminate indicator is honest; a bar that stalls at 90% is the most reliably infuriating pattern in software.
  • Placeholder answers. Skeleton text that reads like plausible content, rather than obviously being a shape. Somebody will screenshot it mid-load.
  • Optimistically applying a model’s action. Showing an email as sent, a file as renamed or a record as updated before the model’s tool call has actually run. The rollback here is not a UI state, it is a user who believes something happened that did not.
  • Hiding failures to keep the flow smooth. A silent fallback to a weaker model, or a silently truncated answer, is a decision the user would want to know about. Say it quietly, but say it — this is the same argument as disclosure in the UI.

The test that settles most cases: would the user be annoyed to discover how this was implemented? If yes, it is not optimism, it is a mock.