Separating the thinking stream and text stream to progressively expose them in the UI
Should users just wait while Claude answers complex questions? If you've worked with Extended Thinking, this concern becomes quite real. The longer the internal reasoning runs, the longer it can take for the first text to appear — sometimes tens of seconds — leaving users staring at a blank screen with no feedback.
The pattern of separating thinking blocks and text blocks via streaming and exposing each to a different UI area is the solution to this problem. Claude Code and several agentic IDEs have already adopted this approach, and as reasoning-intensive workloads grow more common, it has become a natural idiom. This article covers why you should design this way, how to implement it in real code, and where you might get tripped up.
The Stream Structure: Where Thinking Blocks Come From and How They Flow
When Extended Thinking is enabled, the Claude API response contains two types of content blocks: thinking type and text type. In standard (non-interleaved) Extended Thinking mode, streaming follows the order where the thinking block completes first, then the text block begins.
In streaming mode, two kinds of deltas arrive in sequence as SSE events:
thinking_delta— Claude's internal reasoningtext_delta— the actual response to show the user
One easy-to-miss detail: content_block_stop fires when any block ends, not just thinking blocks. So you cannot assume that this event alone means 'thinking is done.' You need to map the index field in each event against the block type recorded at content_block_start time, and confirm 'index N was a thinking block, and that block has now stopped' before acting on it safely.
Which Thinking Mode Is Right for You?
Extended Thinking is activated by passing thinking: { type: "enabled", budget_tokens: N } in the request. Per the official spec, the two currently required fields are type and budget_tokens; other options related to display behavior may vary by SDK version and model, so it is safer to check the type definitions of the @anthropic-ai/sdk you are using first.
Some newer models offer a mode where the model autonomously decides whether and how deeply to use thinking based on request complexity (referred to in documentation and release notes as 'adaptive' variants). In this case, rather than specifying budget_tokens, the model judges for itself — cost predictability decreases, but configuration becomes simpler for workloads with mixed complexity. Which model and API version supports this changes over time, so always verify against the latest documentation for the model you are using.
In summary, the decision flow looks roughly like this:
Backend: Relaying via SSE in Node.js
Having the frontend call the Anthropic API directly exposes the API key, so the standard approach is for the backend to receive the Claude stream and relay it to the client.
The following is a conceptual example. Adjust actual field names and model IDs to match your SDK version.
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
type BlockKind = "thinking" | "text" | "redacted_thinking" | "other";
const blockKinds = new Map<number, BlockKind>();
export async function POST(req: Request) {
const { prompt } = await req.json();
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const send = (obj: unknown) =>
controller.enqueue(encoder.encode(`data: ${JSON.stringify(obj)}\n\n`));
const anthropicStream = client.messages.stream({
model: "claude-sonnet-4-5",
max_tokens: 16000,
thinking: { type: "enabled", budget_tokens: 10000 },
messages: [{ role: "user", content: prompt }],
});
for await (const event of anthropicStream) {
if (event.type === "content_block_start") {
const t = event.content_block.type;
blockKinds.set(
event.index,
t === "thinking" || t === "text" || t === "redacted_thinking"
? t
: "other",
);
continue;
}
if (event.type === "content_block_delta") {
const kind = blockKinds.get(event.index);
if (kind === "redacted_thinking") continue;
if (event.delta.type === "thinking_delta") {
send({ type: "thinking", content: event.delta.thinking });
} else if (event.delta.type === "text_delta") {
send({ type: "text", content: event.delta.text });
}
continue;
}
if (event.type === "content_block_stop") {
const kind = blockKinds.get(event.index);
if (kind === "thinking") send({ type: "thinking_end" });
else if (kind === "text") send({ type: "text_end" });
blockKinds.delete(event.index);
continue;
}
if (event.type === "message_stop") {
controller.enqueue(encoder.encode(`data: [DONE]\n\n`));
controller.close();
}
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}Two key points:
- At
content_block_start, establish an index → block type mapping, and atcontent_block_stop, reference that mapping to determine what kind of block just ended. - Do not forward
redacted_thinkingblock deltas to the client. This block is internal reasoning delivered in encrypted form for safety review and cannot be displayed to users.
Frontend: Letting Two States Flow Independently
Wrapping this in a React hook means UI components only need to bind to phase and two text streams. There is one common trap here. Referencing the phase state value inside useCallback leads to a stale closure, causing conditional branches to behave unexpectedly. This example sidesteps that problem entirely by delegating phase transitions to the explicit thinking_end signal sent by the server. SSE parsing also accumulates a buffer to avoid chunk boundary issues.
import { useState, useRef, useCallback } from "react";
type Phase = "idle" | "thinking" | "responding" | "done";
export function useThinkingStream() {
const [phase, setPhase] = useState<Phase>("idle");
const [thinkingContent, setThinkingContent] = useState("");
const [textContent, setTextContent] = useState("");
const bufferRef = useRef("");
const handleEvent = useCallback((raw: string) => {
if (raw === "[DONE]") {
setPhase("done");
return;
}
let msg: { type: string; content?: string };
try {
msg = JSON.parse(raw);
} catch {
return;
}
switch (msg.type) {
case "thinking":
setThinkingContent((prev) => prev + (msg.content ?? ""));
break;
case "thinking_end":
setPhase("responding");
break;
case "text":
setTextContent((prev) => prev + (msg.content ?? ""));
break;
case "text_end":
break;
}
}, []);
const startStream = useCallback(
async (prompt: string) => {
setPhase("thinking");
setThinkingContent("");
setTextContent("");
bufferRef.current = "";
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt }),
});
const reader = response.body!.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
bufferRef.current += decoder.decode(value, { stream: true });
let sepIndex: number;
while ((sepIndex = bufferRef.current.indexOf("\n\n")) !== -1) {
const rawEvent = bufferRef.current.slice(0, sepIndex);
bufferRef.current = bufferRef.current.slice(sepIndex + 2);
for (const line of rawEvent.split("\n")) {
if (line.startsWith("data: ")) handleEvent(line.slice(6));
}
}
}
},
[handleEvent],
);
return { phase, thinkingContent, textContent, startStream };
}Points to verify:
- Stream chunks are accumulated in
bufferRef, and each time\n\n(the SSE event separator) appears, it is cut into one event for processing. This preventsJSON.parseerrors when a singleread()call contains multiple events, or conversely when a single event spans two chunks. - Phase transitions are tied to
thinking_endsent by the server. Since this does not re-read the client-sidephasevalue to make decisions, it is unaffected by stale closure issues.
UI Component: Collapsible Thinking Panel
The 'show progress during thinking, collapsible after completion' approach adopted by Claude Code-style UIs.
function ThinkingPanel({
content,
phase,
}: {
content: string;
phase: string;
}) {
const [collapsed, setCollapsed] = useState(false);
const isActive = phase === "thinking";
if (!content && !isActive) return null;
return (
<div className="thinking-panel">
<button
className="thinking-header"
onClick={() => setCollapsed((c) => !c)}
>
<span className="thinking-icon">{isActive ? "⟳" : "✓"}</span>
<span>{isActive ? "Reasoning..." : "Reasoning process"}</span>
<span>{collapsed ? "▶" : "▼"}</span>
</button>
{!collapsed && (
<div className="thinking-body">
<pre className="thinking-text">{content}</pre>
</div>
)}
</div>
);
}Showing a spinner-like icon during thinking and a checkmark icon after completion lets users intuitively grasp the current stage.
Interleaved Thinking: Inserting Reasoning Between Tool Calls
In agentic scenarios with repeated tool calls, the Interleaved Thinking pattern — where thinking blocks appear before and after tool calls — is useful. This feature is provided by Anthropic under a beta header, and since the supported models and exact header string change over time, always check the Extended Thinking page in the official Anthropic documentation for the currently valid beta header string before using it. Copying and pasting the header string arbitrarily will result in 400 errors.
Enabling this pattern breaks the earlier assumption of a 'thinking → text' sequence. Because multiple thinking blocks and tool_use blocks appear alternately in the stream, the frontend must also treat phase not as a simple thinking → responding dichotomy, but as a block-sequence timeline. This is not necessary for a typical chatbot UI; it has value when you want to expose the decision-making flow, as in an agent dashboard.
When You Encounter Redacted Thinking
Anthropic includes some internal reasoning that has triggered safety checks in the response as redacted_thinking blocks, encrypted. The contents of these blocks cannot be displayed to users as-is, but you must not remove these blocks in the next conversation turn either. When passing prior assistant messages back in a multi-turn conversation, redacted blocks must be preserved in their original form. Otherwise, the thinking context breaks and response quality degrades.
This is why the backend example records redacted_thinking in blockKinds but only skips forwarding the delta. The original block is preserved in the server-side conversation history, while simply not being exposed to the client.
Tradeoffs: What to Consider Before Deciding
| Item | Consideration |
|---|---|
| Cost | Thinking tokens are billed on the full internal reasoning, not just a summary shown on screen. This can be substantially more than a summary; always measure actual usage, and run budget_tokens caps alongside usage monitoring |
| Initial latency | Standard Extended Thinking starts the text block only after the thinking block completes, so latency to first text grows. If latency is a direct UX metric for your service, consider lowering budget_tokens or maintaining a separate profile with thinking disabled |
| Long response handling | At large max_tokens values, streaming may be required or strongly recommended. Exact thresholds vary by model and time, so check the documentation and SDK warning messages for the model you are using |
| Model compatibility | Supported models differ between Extended Thinking and Interleaved Thinking. Check the release notes for the model ID you are using |
| UI fatigue | Thinking content is long and dense; displaying it by default causes significant user fatigue. Collapsed by default with a progress indicator is a safe combination |
When This Pattern Is Not Needed
One final point worth making as a counterbalance: there are cases where this pattern is unnecessary or even counterproductive.
- Simple FAQ or short-response chatbots: Leaving thinking disabled is better on both latency and cost. The UI separation logic also becomes over-engineering.
- Domains where exposing thinking is inappropriate: In domains like legal, medical, or investment advice where internal reasoning risks being mistaken for the final answer, it is safer not to expose thinking to users. Consider a structure where the backend uses it but does not forward it to the frontend.
- Services where response latency is a KPI: If you are not going to expose thinking, it is better to not enable it at all or to run it with a minimal
budget_tokens. - CLI tools where single-response streaming is sufficient: In environments like terminals where sequentially appended text reads naturally anyway, there is no need to separate blocks into different rendering areas.
The decision to expose thinking in the UI is best limited to workloads where the reasoning process itself serves as a trust signal for users — such as code review, planning, and research assistants where 'why it reached that conclusion' is itself part of the output. For everything else, letting it think quietly in the background and presenting only the result is kinder to users and to your wallet.