Logo

Streaming a response

Your application receives text from the model. Smoothstream presents the complete Markdown received so far. Pass a growing string and set receiving to true while more text may arrive, then set it to false when input ends.

The contract is shared by every framework: React uses children, Vue uses markdown, and the DOM adapter uses update().

Accumulate the incoming text

Each update includes the previous text:

let markdown = ""; markdown += "Hello "; // Send "Hello "markdown += "world";  // Send "Hello world"

Sending only "world" on the second update would replace the response. Within one instance, input must be append-only: the new string must start with the previous string. An identical snapshot is also valid.

Packet boundaries have no Markdown meaning. Whether your transport delivers "Hello " and "world", or "H" and "ello world", Smoothstream applies the same structural and pacing rules. A network pause can still delay content that has not arrived. Updates within a frame can be coalesced into the latest snapshot.

Close input and let presentation finish

receiving describes input, while mode describes presentation:

Situationreceivingmode
A live answer is arrivingtrue"streaming"
Input has ended; the answer may still be revealingfalseKeep "streaming"
A complete string should play through the revealfalse"streaming"
A stored answer should appear immediatelyfalse"static"

The defaults are receiving={false} and mode="streaming".

An unchanged string might mean the network has paused, or that the answer is finished. Smoothstream cannot infer which. Closing input confirms the final word and any unfinished Markdown so the remaining content can be scheduled. It does not skip the reveal queue.

To show follow-up controls when the final unit appears, or wait until its entrance finishes, use the completion callbacks.

Connect a stream

This example assumes your backend returns raw UTF-8 text in the response body. It preserves characters split across network chunks and releases the reader when finished.

read-markdown.ts
export async function* readMarkdown(url: string, signal: AbortSignal) {  const response = await fetch(url, { signal });  if (!response.ok) throw new Error(`Request failed: ${response.status}`);  if (!response.body) throw new Error("The response has no body.");   const reader = response.body.getReader();  const decoder = new TextDecoder();   try {    while (true) {      const { done, value } = await reader.read();      if (done) break;      yield decoder.decode(value, { stream: true });    }    yield decoder.decode();  } finally {    reader.releaseLock();  }}

If you use SSE, WebSockets, or a model SDK, decode that protocol first and pass its text deltas through the same accumulation pattern. SSE event fields and JSON envelopes are not Markdown. If your SDK already supplies accumulated text, pass that string directly instead of appending it again.

Mount this component with a key for the response. The URL stays fixed for that mounted response.

LiveResponse.tsx
"use client"; import { useEffect, useRef, useState } from "react";import { Smoothstream } from "@smoothstream/react";import { readMarkdown } from "./read-markdown"; export function LiveResponse({ url }: { url: string }) {  const [text, setText] = useState("");  const [receiving, setReceiving] = useState(true);  const [error, setError] = useState<string>();  const request = useRef<AbortController | null>(null);   useEffect(() => {    const controller = new AbortController();    request.current = controller;    let mounted = true;    let accumulated = "";     async function receive() {      try {        for await (const delta of readMarkdown(url, controller.signal)) {          if (!mounted || controller.signal.aborted) break;          accumulated += delta;          setText(accumulated);        }      } catch (cause) {        if (mounted && !controller.signal.aborted) {          setError(cause instanceof Error ? cause.message : "Request failed.");        }      } finally {        if (mounted) setReceiving(false);      }    }     void receive();    return () => {      mounted = false;      controller.abort();    };  }, [url]);   return (    <>      <Smoothstream receiving={receiving}>{text}</Smoothstream>      {receiving && <button onClick={() => request.current?.abort()}>Stop</button>}      {error && <p role="alert">{error}</p>}    </>  );}

Start a different response by changing the key on the component that owns both the request and the renderer:

<LiveResponse key={response.id} url={response.url} />

Mount this component with :key="response.id". The URL stays fixed for that mounted response.

LiveResponse.vue
<script setup lang="ts">import { onBeforeUnmount, onMounted, ref } from "vue";import { Smoothstream } from "@smoothstream/vue";import { readMarkdown } from "./read-markdown"; const props = defineProps<{ url: string }>();const text = ref("");const receiving = ref(true);const error = ref("");let request: AbortController | undefined;let mounted = false; onMounted(async () => {  mounted = true;  request = new AbortController();  try {    for await (const delta of readMarkdown(props.url, request.signal)) {      if (!mounted || request.signal.aborted) break;      text.value += delta;    }  } catch (cause) {    if (mounted && !request.signal.aborted) {      error.value = cause instanceof Error ? cause.message : "Request failed.";    }  } finally {    if (mounted) receiving.value = false;  }}); onBeforeUnmount(() => {  mounted = false;  request?.abort();});</script> <template>  <Smoothstream :markdown="text" :receiving="receiving" />  <button v-if="receiving" @click="request?.abort()">Stop</button>  <p v-if="error" role="alert">{{ error }}</p></template>

This helper returns separate controls for stopping input and removing the view. The container belongs to your application.

live-response.ts
import { createSmoothstream } from "@smoothstream/dom";import { readMarkdown } from "./read-markdown"; export function mountResponse(container: HTMLElement, url: string) {  const stream = createSmoothstream(container, { receiving: true });  const request = new AbortController();  const error = document.createElement("p");  error.setAttribute("role", "alert");  container.append(error);  let mounted = true;  let text = "";   async function receive() {    try {      for await (const delta of readMarkdown(url, request.signal)) {        if (!mounted || request.signal.aborted) break;        text += delta;        stream.update(text, { receiving: true });      }    } catch (cause) {      if (mounted && !request.signal.aborted) {        error.textContent = cause instanceof Error ? cause.message : "Request failed.";      }    } finally {      if (mounted) stream.update(text, { receiving: false });    }  }   void receive();  return {    stop: () => request.abort(),    destroy() {      mounted = false;      request.abort();      stream.destroy();      error.remove();    },  };}

Stop, retry, or edit an answer

The examples’ Stop buttons abort the network request, keep the text already received, and close input in finally. That partial answer is now treated as complete Markdown. A request error follows the same rendering lifecycle, with the error shown separately.

This stops receiving, not the queued visual reveal. There is no public pause, seek, or flush method. Removing the component or destroying the controller removes the response entirely.

A retry or regeneration needs a new response ID and a new instance. Editing earlier text also requires a new instance, even in static mode. Source positions identify already-scheduled content; changing that content in place would invalidate those identities.

Render chat history

Use static mode for messages that are already complete when they mount. For example, in React:

<>  {history.map((message) => (    <Smoothstream key={message.id} mode="static">      {message.content}    </Smoothstream>  ))}  <LiveResponse key={response.id} url={response.url} /></>

Keep the current response in its original mounted position after its request ends. Moving it into a different history subtree can remount it even if its key is unchanged. A message loaded as history on a later visit can mount in static mode immediately. Vue uses the same lifecycle with :key and mode="static"; the DOM adapter uses separate controllers.

Smoothstream renders one Markdown response. Your application owns the conversation layout, message controls, and scrolling. If you implement bottom following, observe changes to rendered content: the response can keep growing after the last network chunk, and images can load later.

Accessibility and next steps

Keep the Markdown subtree outside live regions such as role="log". Streaming instances provide their own completion announcement; see Accessibility for integration details.

© 2026 Smoothstream. All rights reserved.

Built withRadiant Docs