Sign in

React

intermediate~40 minreactstreaming-uioptimistic-uifrontend

Rendering partial model output, optimistic state, and streaming UI — the React patterns every AI product interface needs.

React

Overview

A chat interface looks simple and is one of the harder UI problems you will meet. The final state does not exist yet when rendering starts, the user expects their message to appear instantly, and they can cancel or supersede a reply mid-flight.

React has purpose-built answers for exactly these three: useOptimistic, streaming state, and useTransition. This lesson is a message's full lifecycle — optimistic render, streaming updates, final settle — with a cancel path available at every stage.

Theory

"In progress" is a real state you render, not a spinner you hide behind. A streaming message has partial content that changes many times a second. Treat it as a first-class value — render what has arrived — rather than blocking the whole view until it settles.

useOptimistic shows the result before you know it worked. The user's message appears the instant they hit send, and reverts automatically if the request fails. That instant echo is most of what makes a chat feel fast, and it costs one hook.

Suspense is for an unknown-duration wait with a clean fallback. Loading a conversation, yes. It is not a substitute for handling a stream — a stream is not "pending then done", it is continuously partial, and Suspense has nothing useful to say about that.

useTransition keeps the UI responsive under expensive renders. Marking a heavy state update as a transition lets React keep the input responsive while a long message list re-renders. Without it, typing stutters as tokens arrive.

Cancellation has to reach the render layer. Aborting the fetch is half of it. The half people forget is the component still holding a half-written message — clear it, or the user sees a truncated reply from a request they already cancelled.

Visual Diagram

User sendsThe input clears immediately.
Optimistic renderTheir message appears before the server confirms.
Assistant placeholderAn empty bubble that will fill in.
Stream inTokens append; the view sticks to the bottom unless scrolled away.
SettleThe final message replaces the streamed draft.
Failed? Roll the optimistic message back and say why.
A new send can supersede an in-flight reply. Cancel the old one first.
A message's lifecycle, with a cancel path at every stage.

Draw a single chat message as a small state machine with four boxes in a row: optimistic (sent, unconfirmed) → streaming (partial content, growing) → settled (final content) or cancelled (stopped early, partial content kept). Arrows move left to right along the happy path. A separate arrow labeled "cancel" cuts diagonally from both the optimistic and streaming boxes straight to "cancelled" — showing that a user can interrupt at any point before settling, and the UI needs a defined, non-broken appearance for that interrupted state, not just for the two ends of the happy path.

Examples

  • A chat thread where the user's own message renders instantly on send, using useOptimistic, while the assistant's reply streams in below it token by token from a separate piece of state.
  • A markdown-rendering message component wrapped so its expensive re-parse is deferred with useTransition, keeping the input box responsive to typing even while a long response streams and re-renders continuously.
  • A "regenerate" button that cancels the in-flight stream via AbortController, resets the message to a streaming-again state, and starts a fresh request — reusing the same optimistic/streaming/settled state machine rather than a bespoke one-off.
  • A useEffect cleanup function that aborts an in-flight generation's controller when its owning component unmounts, preventing a setState call on an unmounted component after the user has navigated away mid-stream.

Code

import { useOptimistic, useState, useRef, useTransition, useEffect } from "react";

type Message = {
  id: string;
  role: "user" | "assistant";
  content: string;
  status: "sending" | "streaming" | "done" | "cancelled";
};

export function ChatThread() {
  const [messages, setMessages] = useState<Message[]>([]);
  const [isPending, startTransition] = useTransition();
  const controllerRef = useRef<AbortController | null>(null);

  // Optimistic layer: a new user message (and a placeholder assistant
  // reply) appear instantly, before the network round trip confirms
  // anything. React reconciles back to real state once send() resolves.
  const [optimisticMessages, addOptimistic] = useOptimistic(
    messages,
    (state, next: Message) => [...state, next],
  );

  async function send(text: string) {
    const userMessage: Message = {
      id: crypto.randomUUID(),
      role: "user",
      content: text,
      status: "sending",
    };
    addOptimistic(userMessage);

    const assistantId = crypto.randomUUID();
    const controller = new AbortController();
    controllerRef.current = controller;

    // Real state update, inside a transition so React can keep the UI
    // (typing, scrolling) responsive while streaming re-renders happen.
    startTransition(() => {
      setMessages((prev) => [
        ...prev,
        { ...userMessage, status: "done" },
        { id: assistantId, role: "assistant", content: "", status: "streaming" },
      ]);
    });

    try {
      const response = await fetch("/api/chat", {
        method: "POST",
        body: JSON.stringify({ prompt: text }),
        signal: controller.signal,
      });
      if (!response.body) throw new Error("no stream");

      const reader = response.body.getReader();
      const decoder = new TextDecoder();

      // Each chunk triggers its own transition-wrapped update rather than
      // one setState per token, keeping the render count manageable on a
      // fast stream while still feeling continuous to the user.
      for (;;) {
        const { value, done } = await reader.read();
        if (done) break;
        const chunk = decoder.decode(value, { stream: true });

        startTransition(() => {
          setMessages((prev) =>
            prev.map((m) =>
              m.id === assistantId ? { ...m, content: m.content + chunk } : m,
            ),
          );
        });
      }

      startTransition(() => {
        setMessages((prev) =>
          prev.map((m) => (m.id === assistantId ? { ...m, status: "done" } : m)),
        );
      });
    } catch {
      // An abort lands here too — mark it cancelled, not errored, so the
      // UI can render a distinct, non-alarming "stopped" state.
      startTransition(() => {
        setMessages((prev) =>
          prev.map((m) =>
            m.id === assistantId ? { ...m, status: "cancelled" } : m,
          ),
        );
      });
    }
  }

  // Cancel any in-flight generation if the component unmounts, so a
  // late-arriving chunk never calls setState on an unmounted tree.
  useEffect(() => () => controllerRef.current?.abort(), []);

  return (
    <div aria-busy={isPending}>
      {optimisticMessages.map((m) => (
        <p key={m.id} data-status={m.status}>
          {m.content}
          {m.status === "streaming" && <span aria-label="generating"> ▍</span>}
        </p>
      ))}
    </div>
  );
}

Real Product Example

Vercel's v0 (and the broader class of AI page-builder tools it popularized) is a clean example of nearly every pattern in this lesson operating at once: a user's prompt appears in the conversation instantly on submit, the generated code and preview stream in incrementally rather than appearing all at once when generation finishes, and the interface stays responsive — you can scroll, read earlier messages, and start typing a follow-up — while a large, continuously-updating code block is actively re-rendering in the same view. That combination is exactly the challenge this lesson addresses: showing the user's own action immediately (optimistic state), rendering a value that changes dozens of times per second without the rest of the UI stuttering (transitions), and giving the user a way to stop generation and redirect it (cancellation reaching both the network and the render layer). None of this is exotic React — it's the same useState/useTransition-shaped patterns in this lesson, just applied to a UI where every response is a large, streamed block of code instead of a short chat reply.

Common Mistakes

Waiting for server confirmation before showing the user's own message

Rendering the user's sent message only after the server round-trips a confirmation makes every send feel slow, even though the actual bottleneck is the assistant's reply, not the send itself. Show the user's message immediately with useOptimistic (or equivalent local state) and let the network confirmation happen invisibly in the background.

One setState call per token with no transition wrapping

On a fast stream (tens of chunks per second), calling setState directly outside a transition for every single chunk can produce more renders than the browser can paint smoothly, especially once the message list or a markdown parser is doing real work per render. Wrap streaming updates in useTransition so React can prioritize more urgent updates (typing, clicking) over catching up on every intermediate render.

Not cleaning up in-flight requests on unmount

A fetch-and-stream loop started in one component that navigates away mid-generation will keep calling setState on a component React has already unmounted unless the AbortController is aborted in a useEffect cleanup function. Beyond the console warning, this can leave a "ghost" request running against your API and billing tokens for a response no component will ever render.

Treating a cancelled stream the same as an errored one in the UI

Catching an AbortError and rendering the same "Something went wrong, please retry" message you'd show for a genuine failure is confusing — the user cancelled on purpose. Distinguish "cancelled" from "errored" in your message state (as in the status field above) and render each with UI that matches what actually happened.

Senior Tips

Senior tip

Keep the optimistic update and the real update using the same shape. If useOptimistic's reducer produces a message object with a different shape than what your real setState calls produce later, you'll get a jarring visual "pop" the instant real data replaces optimistic data. Design one Message type and make every code path — optimistic, streaming, settled, cancelled — produce values of that same type.

Senior tip

Batch chunk updates on a short timer for very fast streams, not just per-chunk. Some providers can emit many small chunks per second — well within what React can technically handle, but past the point where visually updating on literally every chunk adds anything a user can perceive. Buffering chunks for ~30-50ms before committing a state update reduces render count with no perceptible loss of "live" feeling.

Senior tip

Render the streaming cursor (or equivalent "still going" indicator) as part of message content, not as a separate loading state. A separate top-level spinner disappears the instant the first token arrives, which reads as "done" to users even though the response is 5% complete. An inline cursor that sits at the end of the growing text, and disappears only when the stream truly finishes, communicates progress accurately throughout.

Exercises

Exercise

In the ChatThread component above, trace what happens if the user clicks "send" a second time while the first assistant response is still streaming. Does the current code cancel the first request? Modify it so a new send() call aborts any existing controllerRef.current before starting a fresh one.

Exercise

Explain, in your own words, why useOptimistic needs to be used inside a component that also has the real setState it will eventually reconcile against — what would go wrong if the optimistic update and the real update lived in two totally separate, unconnected pieces of state?

Exercise

Design (describe in words, no code required) a distinct visual treatment for each of the four message statuses (sending, streaming, done, cancelled) in a chat UI — what changes for each one, and why does the user need to be able to tell them apart at a glance?

Mini Project

Streaming chat UI with optimistic send. Extend the ChatThread component from the Code section:

  1. Add a text input and send button that calls send(text) on submit, clearing the input immediately (the optimistic part).
  2. Point /api/chat at a fake local handler (reuse the Node.js lesson's mini project server if you built it) that streams back a canned response word by word.
  3. Add a "stop" button, visible only while a message has status === "streaming", that aborts the current controller and confirms the message transitions to cancelled rather than getting stuck.
  4. Add the useEffect unmount-cleanup from the Code section, and verify by navigating away mid-stream (or unmounting the component in a test) that no console warning about updating an unmounted component appears.

Resources

  • React — useOptimistic: the hook behind the instant-send behavior in this lesson's Code sample and Theory section.
  • React — Suspense: the boundary component discussed in Theory for genuinely one-shot async waits, contrasted here with token-by-token streaming state.
  • React — useTransition: how to mark frequent, expensive streaming updates as interruptible so the rest of the UI stays responsive.
  • Vercel AI SDK — AI SDK UI: Chatbot: a production-grade implementation of the same streaming-chat state machine built by hand in this lesson's Code section, worth comparing against once you've built the manual version yourself.