---
title: Streaming a response
description: Connect a text stream, finish or cancel input, and preserve each response's presentation lifecycle.
---

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:

```ts
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:

| Situation | `receiving` | `mode` |
| --- | --- | --- |
| A live answer is arriving | `true` | `"streaming"` |
| Input has ended; the answer may still be revealing | `false` | Keep `"streaming"` |
| A complete string should play through the reveal | `false` | `"streaming"` |
| A stored answer should appear immediately | `false` | `"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](/guides/completion-callbacks).

<Callout type="warning" title="Keep the live instance mounted">
  Changing its key, unmounting it, or switching its mode when input ends interrupts its existing presentation. Update `receiving` on the same instance and let it finish.
</Callout>

## 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.

```ts title="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.

<Tabs>
  <Tab label="React" icon="/icons/react.svg">
    Mount this component with a key for the response. The URL stays fixed for that mounted response.

    ```tsx title="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:

    ```tsx
    <LiveResponse key={response.id} url={response.url} />
    ```
  </Tab>
  <Tab label="Vue" icon="/icons/vue.svg">
    Mount this component with `:key="response.id"`. The URL stays fixed for that mounted response.

    ```vue title="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>
    ```
  </Tab>
  <Tab label="Vanilla DOM" icon="/icons/javascript-typescript.svg">
    This helper returns separate controls for stopping input and removing the view. The container belongs to your application.

    ```ts title="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();
        },
      };
    }
    ```
  </Tab>
</Tabs>

## 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:

```tsx
<>
  {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](/guides/accessibility) for integration details.

- [Markdown behavior](/guides/markdown) explains why some content waits before appearing.
- [Reveal and timing](/customize/reveal) covers pacing and reduced motion.
- [Troubleshooting](/guides/troubleshooting) covers missing tails, replay, and lifecycle errors.
