Concept
Once streaming and tool use are working mechanically (covered in their own topics), the remaining work in a real AI product is UI-level: making the latency, uncertainty, and multi-step nature of an LLM response feel intentional rather than janky. A handful of patterns recur across nearly every production AI interface, and each one maps to a specific, concrete signal from the API rather than being pure visual design.
Optimistic user-turn rendering
The user's own message should appear in the UI the instant they hit send, before any network round trip completes, let alone before the assistant's response starts streaming.
function handleSend(userText: string) {
// Render immediately, don't wait for the network
setMessages((prev) => [...prev, { role: "user", content: userText, status: "sent" }]);
// Then kick off the actual request
sendToBackend(userText);
}This is standard optimistic UI, but it matters more here than in a typical CRUD app because the assistant's response can take multiple seconds to even begin, without optimistic rendering, the user's own typed text would appear to vanish into a loading spinner for that entire window, which reads as broken even though nothing has actually failed.
Distinguishing "waiting for the model" from "streaming has started"
A chat UI needs at least two distinct pending states, not just one generic spinner: waiting for the first token to arrive (before message_start/first content_block_delta), and actively streaming (deltas are arriving). Collapsing these into one loading state hides genuinely useful information, a long pre-stream wait usually means something different (a slow tool call, a large system prompt, network latency) than an in-progress stream that's simply not finished yet.
type TurnStatus = "pending" | "streaming" | "done" | "error";
// pending → streaming transitions on the FIRST content_block_delta received
// streaming → done transitions on message_stopTool-call progress indicators
const response = await client.messages.create({model: 'claude-opus-4-8',tools: [getWeatherTool],messages: [{ role: 'user', content: 'Weather in Tokyo?' }],});
Tool definitions (name, description, input_schema) are sent alongside the messages on EVERY request, Claude doesn't remember tools between calls, since the API is stateless.
When a response involves a tool call, the UI has real, distinct states to represent, not just "loading": the model decided to call a tool (stop_reason: "tool_use" arrived, or the streamed tool_use block started), your application is executing it, and the model is now processing the result. Surfacing this as a concrete label, "Searching flights..." rather than a generic spinner, measurably improves perceived responsiveness, because the user understands why the wait is happening rather than just that a wait is happening.
function ToolCallIndicator({ toolName, status }: { toolName: string; status: "calling" | "running" | "done" }) {
const labels: Record<string, string> = {
search_flights: "Searching flights…",
get_weather: "Checking the weather…",
};
if (status === "done") return null;
return <div className="tool-indicator">{labels[toolName] ?? `Running ${toolName}…`}</div>;
}Cancel / stop mid-generation
A "Stop generating" button is a near-universal chat UI affordance. Mechanically, it's an AbortController tied to the underlying stream request, the SDK's streaming methods accept a signal that, when aborted, tears down the in-flight HTTP connection.
const controller = new AbortController();
const stream = client.messages.stream(
{ model: "claude-opus-4-8", max_tokens: 4096, messages },
{ signal: controller.signal },
);
// Wired to a "Stop" button:
function handleStop() {
controller.abort();
}Whatever text streamed in before the abort should stay rendered, the user still gets the partial answer they were reading, rather than the whole response disappearing because it was cut short.
Structured / generative UI beyond plain text
Not every AI response should render as a paragraph of text. When a response is naturally structured data, a comparison table, a set of selectable options, a form to fill in, the pattern is to have the model produce that structure explicitly (via a tool call whose input is the structured data, or via output_config.format for a single structured response) and have your frontend render a real UI component from it, rather than asking the model to hand-format markdown that you then try to parse back into structure.
// A "propose_options" tool whose sole purpose is producing UI-renderable data,
// not performing any real side effect
const proposeOptionsTool: Anthropic.Tool = {
name: "propose_options",
description: "Present the user with a small set of selectable options. Call this instead of listing options as plain text when the user needs to make a choice.",
input_schema: {
type: "object",
properties: {
options: {
type: "array",
items: { type: "object", properties: { label: { type: "string" }, value: { type: "string" } }, required: ["label", "value"] },
},
},
required: ["options"],
},
};
// On the frontend: response.content includes a tool_use block for propose_options
This "tool call as a UI-rendering trigger" pattern doesn't require the tool to have any real side effect at all, its input_schema exists purely to force the model's output into a shape your frontend can render deterministically, which is generally more reliable than parsing free-form text for structure.
Graceful degradation: refusals and partial output
A production UI needs an explicit branch for stop_reason: "refusal", a genuinely different case from a normal completed response, and different again from a network error. Presenting a refusal as if it were a generic error ("Something went wrong, try again") is misleading, since retrying the identical request will produce the identical refusal; the UI should instead acknowledge the assistant declined and, where appropriate, suggest rephrasing.
function renderTurnOutcome(stopReason: string, text: string) {
switch (stopReason) {
case "end_turn":
return <AssistantMessage text={text} />;
case "refusal":
return <RefusalNotice text="I'm not able to help with that request." />;
case "max_tokens":
return <AssistantMessage text={text} truncated />; // partial, but real, content
default:
return <AssistantMessage text={text} />;
}
}Try It
Predict what's wrong with this "stop generating" implementation before checking the solution.
function handleStop() {
setMessages((prev) => prev.slice(0, -1)); // remove the in-progress assistant message entirely
controller.abort();
}What's the UX problem with removing the entire in-progress message on stop, rather than just halting the stream?
Solution
This discards whatever text the user had already been reading, which defeats a common reason someone hits "stop" in the first place, they've already gotten the useful part of the answer and just want the (perhaps redundant or off-track) remainder to stop generating, not to lose what's already on screen. The correct behavior is to abort the underlying request (stopping further tokens from arriving) while leaving the already-rendered partial text in place, typically with a small visual indicator that generation was stopped early (e.g. a "[stopped]" label) rather than presenting the partial text as if it were a complete, natural response. Removing the message entirely is a surprisingly common first implementation because it "feels" like the safe, clean way to handle an aborted action, when the better user experience is almost always to keep what already rendered.
Implement It Yourself
Build a minimal turn-status state machine, the actual mechanism behind distinguishing "waiting for the model" from "actively streaming" from "tool call in progress," driven purely by which stream events have been observed so far.
type TurnState =
| { status: "pending" }
| { status: "streaming"; text: string }
| { status: "tool_call"; toolName: string; text: string }
| { status: "done"; text: string; stopReason: string }
| { status: "stopped"; text: string };
function reduceTurnState(state: TurnState,
This reducer pattern is the real mechanism behind a robust streaming chat UI: instead of scattering booleans (isLoading, isStreaming, isToolCalling) that can drift out of sync with each other, a single discriminated-union state, driven entirely by the sequence of observed events, guarantees the UI can only ever be in one well-defined state at a time.
Under the Hood
The turn-state reducer above is a direct application of the same discriminated-union pattern covered generally in Generics in TypeScript, modeling "exactly one of several possible shapes" as a tagged union, rather than a bag of independent booleans, is what makes it possible for the compiler (and a human reader) to guarantee only valid combinations of state exist. The optimistic-UI pattern for the user's own message, and the abort-but-keep-partial-output pattern for stopping generation, both rely on the same non-blocking, event-driven execution model as The JavaScript Event Loop, nothing here blocks the main thread; every state transition is a reaction to an asynchronous event arriving.
Common Mistakes
1. Collapsing "waiting for first token" and "actively streaming" into one loading state
// ❌ can't distinguish a slow pre-stream wait from mid-stream progress
const [isLoading, setIsLoading] = useState(false);A single boolean hides genuinely different situations, a long wait before the first token usually indicates something different (a slow tool call, a large cached system prompt still writing to cache, network latency) than an in-progress stream that simply hasn't finished. Model these as distinct states.
2. Discarding partial output when the user clicks "stop"
// ❌ removes the in-progress message entirely instead of just halting the stream
function handleStop() {
setMessages((prev) => prev.slice(0, -1));
controller.abort();
}Keep whatever text already rendered, the user frequently stops generation because they already have what they need, not because they want the partial answer erased.
3. Presenting a refusal as a generic retry-able error
// ❌ misleads the user into retrying an identical request that will refuse again
if (stopReason !== "end_turn") {
showToast("Something went wrong. Try again.");
}stop_reason: "refusal" is a distinct, meaningful outcome, not a transient failure, presenting it as a generic error invites a pointless retry. Branch on the specific stop_reason and give the user an outcome-appropriate message.
Best Practices
- Render the user's own message optimistically, before any network round trip completes.
- Model turn status as a discriminated union (
pending/streaming/tool_call/done/stopped/error), not a collection of independent booleans that can drift out of sync. - Give tool calls a labeled, specific progress indicator ("Searching flights...") rather than a generic spinner, this measurably improves perceived responsiveness.
- Wire "stop generating" to an
AbortController, and keep the already-rendered partial text in place rather than discarding it. - when a response is naturally structured UI data (options, tables, forms).
Performance Tips
- Batch delta-driven re-renders (see the streaming topic) rather than re-rendering on every single token, this matters even more once tool-call indicators and turn-state transitions are added to the render path.
- Debounce or throttle the "stop generating" button's disabled state during the brief window between the abort call and the actual connection teardown, to avoid a double-click sending a second abort to an already-aborted controller.
- For structured/generative UI patterns, keep the tool's
input_schemaminimal and specific to what the frontend component actually needs to render, an overly generic schema makes both model output and frontend rendering logic more fragile.
