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
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
useEffectcleanup function that aborts an in-flight generation's controller when its owning component unmounts, preventing asetStatecall 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:
- Add a text input and send button that calls
send(text)on submit, clearing the input immediately (the optimistic part). - Point
/api/chatat 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. - Add a "stop" button, visible only while a message has
status === "streaming", that aborts the current controller and confirms the message transitions tocancelledrather than getting stuck. - Add the
useEffectunmount-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.