# React Markdown Streaming Without Flicker or Scroll Jumps

> React markdown streaming done right: repair half-typed syntax, re-parse only the last block, and keep the chat pinned to the bottom until the reader scrolls up.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 16, 2026
- Category: [Building UI with AI agents](https://wingo-ui.com/blog/category/ai-agents)
- Reading time: 10 min
- Canonical: https://wingo-ui.com/blog/react-markdown-streaming

## TL;DR

Streamed Markdown flickers in React because every chunk re-parses the whole answer and half-typed syntax like an open ** or code fence renders raw until it closes. Repair the unfinished tail before parsing, split the text into top-level blocks and memoize them so only the last block re-renders. Follow the growing answer with a scroll loop that lets go the moment the reader scrolls up and picks up again when they return to the bottom.

You pipe a model's answer into react-markdown and it mostly works. Then the details show up: `**$22,7` sits on screen as raw asterisks before it snaps to bold, a code block opens and the rest of the reply turns monospace, a table renders as a row of pipes, and the view keeps dragging the reader back down while they try to reread the first paragraph. React markdown streaming has three separate problems: half-typed syntax, re-parsing the whole answer on every chunk, and scrolling. This tutorial fixes each one, then puts them together in a small chat. The code uses the [Markdown](https://wingo-ui.com/components/markdown) renderer, [Chat Message](https://wingo-ui.com/components/chat-message) and the [useStickToBottom](https://wingo-ui.com/components/use-stick-to-bottom) hook from Wingo UI, the library we build, and it explains the technique so you can apply it to plain react-markdown too.

## Why does streamed Markdown flicker in React?

The usual react-markdown streaming flicker has a simple cause: Markdown is only well formed once it is finished, and a stream shows you every unfinished state in between. A model sends a few characters at a time. At any moment the tail of the text can be an open `**`, a backtick with no partner, a link whose URL is half there, or a table header without its separator row. The parser does exactly what CommonMark says with that input, so you see raw syntax, and a few tokens later you see the formatted version.

The second cause is cost. Each chunk produces a new source string, and react-markdown runs remark and rehype over everything received so far. The work per chunk grows with the answer, so the total grows with the square of its length. The [AI SDK cookbook's earlier memoization recipe](https://v4.ai-sdk.dev/cookbook/next/markdown-chatbot-with-memoization) memoized parsed blocks "to prevent them from being re-parsed and re-rendered on each token update." React still reconciles the output, so most DOM survives a re-render. The exception is a `components` object written inline: every render hands React new component functions, React treats them as new types and remounts those elements, and a table the reader scrolled sideways snaps back to its first column.

The third is scrolling, covered below. A streaming markdown React setup that fixes only one of the three still feels broken, so this tutorial handles all of them.

## How do you repair half-typed Markdown before parsing?

Rewrite only the unfinished tail of the source, before the parser sees it, so every intermediate state is valid Markdown that looks like the finished one. The Markdown component exports its repair step as a pure function, `completeMarkdown(text)`. Here is what it returns for real mid-stream inputs:

| Mid-stream source | After `completeMarkdown` | Why |
|---|---|---|
| `The total is **$22,7` | `The total is **$22,7**` | bold shows as bold from the first digit |
| `` Run `npm i `` | `` Run `npm i` `` | inline code closes |
| `See [the docs](https://exa` | `See the docs` | link text shows, the half URL never becomes an href |
| ```` ```ts ```` + `const x = 1` | the same, plus a closing ```` ``` ```` | the rest of the answer stays outside the code block |
| ```` ```t ```` | nothing yet | the fence waits until its first line ends, so a half-typed language never picks the wrong highlighter |
| `\| Client \| Amount \|` + `\|---` | nothing yet | a table waits for its separator row |
| `1. First` + `2.` | `1. First` | a list marker with nothing after it is dropped |
| `snake_case and 2*3` | unchanged | underscores and asterisks inside words are left alone |

Two rules keep the repair honest. It never touches anything inside a code block, so a `**` in a code sample stays literal. And it only adds closers or holds text back; it never invents content. When the real closer arrives, the formatting is the one the reader already saw, so the text does not flip between raw and formatted.

If you stay on plain react-markdown, the same idea fits in one line while the answer streams: `<ReactMarkdown>{streaming ? completeMarkdown(text) : text}</ReactMarkdown>`. The function lives in `components/ui/markdown.tsx`, takes a string and returns a string, and uses no React.

## How do you stop re-parsing the whole answer on every token?

Split the source into top-level blocks at blank lines, render each block in its own memoized component, and key the blocks by position. While text streams, only the last block's string changes, so only the last block parses again. Every finished paragraph, table and code block keeps its DOM.

The split has to respect Markdown's structure. A blank line inside a code fence does not end the block, and neither does a blank line between items of a loose list. `splitMarkdownBlocks` from the same file handles both:

```ts
import { splitMarkdownBlocks } from "@/components/ui/markdown";

splitMarkdownBlocks("Intro\n\n```ts\na\n\nb\n```\n\n- one\n\n- two\n\nEnd");
// ["Intro", "```ts\na\n\nb\n```", "- one\n\n- two", "End"]
```

The earlier AI SDK recipe did the split with `marked.lexer` and compared each block's `content` in `React.memo`. That works for the cost, but memoization alone leaves the flicker in the last block, which is the block the reader is looking at. Run the repair first, then split.

Two caveats. Reference-style links and footnotes (`[id]: https://...`) resolve across the whole document, so a block-by-block render cannot resolve them. While text streams, a `[docs][1]` whose definition sits in another block shows as literal brackets. Once `streaming` turns off, the Markdown component switches to a one-pass render if the source contains a definition (or you passed your own remark or rehype plugins), and the references resolve. Also keep any `components` override stable (module scope or `useMemo`). An inline object defeats the memo, so every block re-renders on every chunk, and inline functions remount their elements as described above.

Press Replay as a stream below and watch the bold total and the table header. They stay hidden until they are complete instead of flashing raw characters, and each new block fades in under a pulsing caret.

> Live demo (Markdown): Press Replay as a stream: half-typed bold, code spans and the table header wait until they are complete, and only the last block re-renders. Try it at [Markdown](https://wingo-ui.com/components/markdown) and install it with `npx wingo-ui@latest add markdown`.

## How do you render a streaming answer with the Markdown component?

Install it, append each chunk to state, and pass `streaming` while the answer is arriving:

```bash
npx wingo-ui@latest add markdown
```

```tsx
"use client";

import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Markdown } from "@/components/ui/markdown";

export function StreamedAnswer({ question }: { question: string }) {
  const [text, setText] = useState("");
  const [streaming, setStreaming] = useState(false);
  const abort = useRef<AbortController | null>(null);
  // stop the request when the component unmounts
  useEffect(() => () => abort.current?.abort(), []);

  async function ask() {
    abort.current?.abort();
    const controller = new AbortController();
    abort.current = controller;
    setText("");
    setStreaming(true);
    try {
      const response = await fetch("/api/chat", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ messages: [{ role: "user", content: question }] }),
        signal: controller.signal,
      });
      if (!response.ok || !response.body) return;
      const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
      while (true) {
        const { value, done } = await reader.read();
        if (done) break;
        setText((current) => current + value);
      }
    } catch {
      // aborted or failed: keep what arrived
    } finally {
      if (abort.current === controller) setStreaming(false);
    }
  }

  return (
    <div className="flex flex-col gap-3">
      <Button variant="soft" size="sm" onClick={ask}>
        Ask
      </Button>
      <Markdown streaming={streaming} variant="compact" caret="bar" allowImages={false}>
        {text}
      </Markdown>
    </div>
  );
}
```

`/api/chat` is your route handler returning a plain text stream. With `streaming` on, the component repairs the tail, re-parses only the last block, fades new blocks in, shows the caret after the last word and sets `aria-busy` on its root. The caret is `dot` by default; `bar`, `block` and `none` are the other options. Turn `streaming` off when the stream ends: the repair stops, the caret goes, and the finished blocks keep their DOM.

Two settings matter for model output. `variant="compact"` keeps headings small, so an answer that starts with `#` does not shout. `allowImages={false}` renders every image as a link with its alt text, so untrusted output cannot load a tracking pixel. Raw HTML in the source is never rendered.

## How do you keep a chat pinned to the bottom while it streams?

Follow new content only while the reader is at the bottom, stop the moment they scroll up, and start again when they scroll back down or send a message. The first version of chat auto scroll to bottom in React is often a `useEffect` that scrolls whenever the messages array changes. During a stream it fails both ways: it misses height that changes without a state update, and it drags back a reader who scrolls up. Four rules make a stick to bottom chat feel right:

- **Let go on intent.** At the very bottom, a wheel turn upward has not moved `scrollTop` yet when the next chunk lands, so a position check scrolls the reader right back. The hook stops following on the upward wheel event itself, and on any upward scroll while the content did not shrink, even inside the threshold.
- **Watch the content's size.** Height also changes without a state update: an image finishes loading, a reasoning panel expands. A `ResizeObserver` on the growing element catches every change whatever caused it, and a `MutationObserver` picks up a child swapped in later, such as an empty state replaced by the list.
- **Follow outside React.** Following writes `scrollTop` in a `requestAnimationFrame` loop that re-reads the target every frame, so text that grows mid-animation is still caught. React state changes only when `isAtBottom` or `isFollowing` flips.
- **Respect reduced motion.** With reduced motion on, following jumps instead of easing.

You may see CSS scroll anchoring suggested for this. It was designed to keep visible content still when something changes above it; pinning to the bottom with it needs an anchor-element trick, and it gives you no `isAtBottom` state for a jump button. [MDN's overflow-anchor page](https://developer.mozilla.org/en-US/docs/Web/CSS/overflow-anchor) lists it as Baseline only since September 2026 (checked October 2026), so phones on older browser versions still lack it.

```bash
npx wingo-ui@latest add use-stick-to-bottom
```

```tsx
const { scrollRef, contentRef, isAtBottom, scrollToBottom } = useStickToBottom({
  threshold: 72, // px from the end that still counts as the bottom
  initial: "instant", // open a conversation at its end
  resize: "smooth", // glide after streamed text instead of jumping
});
```

`scrollRef` goes on the `overflow-y-auto` element, `contentRef` on the element inside it that grows. Scroll up while the answer below is still arriving: following stops at once, the text keeps growing underneath and a New messages button appears.

> Live demo (useStickToBottom): Scroll up while an answer streams: following stops, the text keeps growing below and a New messages button brings you back. Try it at [useStickToBottom](https://wingo-ui.com/components/use-stick-to-bottom) and install it with `npx wingo-ui@latest add use-stick-to-bottom`.

## How do you build a streaming Markdown chat in React?

Render each message with Chat Message inside a `useStickToBottom` scroller, and on every chunk replace only the streaming message's object. Chat Message renders its `content` through the same Markdown component and turns on streaming when `status` is `"streaming"`. It is wrapped in `memo`, so the finished messages above keep their props and do not render again. Here is the whole thing in one file:

```bash
npx wingo-ui@latest add chat-message use-stick-to-bottom input
```

```tsx
"use client";

import { useRef, useState, type FormEvent } from "react";
import { ArrowDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ChatMessage, type ChatMessageData } from "@/components/ui/chat-message";
import { Input } from "@/components/ui/input";
import { useStickToBottom } from "@/hooks/use-stick-to-bottom";
import { createChatId } from "@/lib/ai-chat";

export function StreamingChat() {
  const [messages, setMessages] = useState<ChatMessageData[]>([]);
  const [draft, setDraft] = useState("");
  const abort = useRef<AbortController | null>(null);
  const { scrollRef, contentRef, isAtBottom, scrollToBottom } = useStickToBottom();

  // a new object for the one message that changed, so memo skips the rest
  const patch = (id: string, change: Partial<ChatMessageData>) =>
    setMessages((list) => list.map((m) => (m.id === id ? { ...m, ...change } : m)));

  async function send(event: FormEvent) {
    event.preventDefault();
    const text = draft.trim();
    if (!text) return;
    setDraft("");
    const question: ChatMessageData = { id: createChatId("u"), role: "user", content: text };
    const history = [...messages, question].map(({ role, content }) => ({ role, content }));
    const id = createChatId("a");
    setMessages((list) => [...list, question, { id, role: "assistant", content: "", status: "pending" }]);
    // sending always returns to the bottom, even after the reader scrolled up
    scrollToBottom("smooth");

    abort.current?.abort();
    const controller = new AbortController();
    abort.current = controller;
    let content = "";
    try {
      const response = await fetch("/api/chat", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ messages: history }),
        signal: controller.signal,
      });
      if (!response.ok || !response.body) throw new Error("The assistant is unavailable.");
      const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
      while (true) {
        const { value, done } = await reader.read();
        if (done) break;
        content += value;
        patch(id, { content, status: "streaming" });
      }
      patch(id, { status: "complete" });
    } catch {
      patch(id, { status: controller.signal.aborted ? "stopped" : "error" });
    }
  }

  const last = messages.at(-1);

  return (
    <div className="flex h-dvh flex-col">
      <div className="relative min-h-0 flex-1">
        <div ref={scrollRef} className="h-full overflow-y-auto overscroll-contain">
          <div ref={contentRef} className="mx-auto flex max-w-2xl flex-col gap-6 p-4">
            {messages.map((m) => (
              <ChatMessage key={m.id} {...m} isLatest={m.id === last?.id} />
            ))}
          </div>
        </div>
        {!isAtBottom && (
          <Button
            size="sm"
            radius="full"
            leftIcon={<ArrowDown />}
            className="absolute bottom-3 left-1/2 -translate-x-1/2"
            onClick={() => scrollToBottom()}
          >
            New messages
          </Button>
        )}
      </div>
      <form onSubmit={send} className="mx-auto flex w-full max-w-2xl gap-2 p-4 pb-[max(1rem,env(safe-area-inset-bottom))]">
        <Input value={draft} onValueChange={setDraft} placeholder="Ask about your invoices" aria-label="Message" className="flex-1" />
        <Button type="submit">Send</Button>
      </form>
    </div>
  );
}
```

What each piece does while an answer streams: the pending message shows typing dots until the first chunk, the streaming message repairs its Markdown and re-parses only its last block, the other messages skip rendering, and the scroller follows the growing answer until the reader scrolls up. An aborted request keeps the text that arrived and marks the message as stopped.

This file leaves out what a production thread adds: a count of new replies on the jump button, keeping the reader's place when older history loads at the top, and a polite live region that announces when a reply starts and when it is ready instead of reading every streamed word. The [Chat Thread](https://wingo-ui.com/components/chat-thread) component does those. Its composer slot usually takes a [Prompt Input](https://wingo-ui.com/components/prompt-input), which ignores Enter while an IME is composing and, with `position="fixed"`, stays above the phone keyboard. The [React AI chat UI guide](https://wingo-ui.com/blog/react-ai-chat-ui) walks through each part.

## When should you use Streamdown or use-stick-to-bottom instead?

When you want free, open-source building blocks and are fine composing them yourself. Both are good choices, as of October 2026:

- [Streamdown](https://github.com/vercel/streamdown) from Vercel is Apache-2.0 licensed and describes itself as a drop-in replacement for react-markdown designed for AI streaming, with memoized rendering. Its [termination docs](https://streamdown.ai/docs/termination) say it uses the remend preprocessor to close incomplete bold, italic, inline code, strikethrough, links and block math. If your answers contain math, pick it: our Markdown component renders GitHub flavored Markdown without math support. If your app already uses the AI SDK, it is also the natural pick.
- [use-stick-to-bottom](https://github.com/stackblitz-labs/use-stick-to-bottom) from StackBlitz Labs is an MIT-licensed, zero-dependency hook and component with velocity-based spring scrolling, and it lets the reader cancel stickiness by scrolling up. Our hook shares its name and its goal.

The Wingo UI versions fit when you want the renderer, the message and the scroller to share one look and one set of props, with phone behavior already handled: text stays at 15px in chat, wide tables and code scroll inside their own box, and a long press on a message opens its actions in a bottom sheet on touch screens. Markdown, Chat Message and useStickToBottom are part of Wingo UI Pro ([pricing](https://wingo-ui.com/pricing)).

## What should you try next?

Open the [Markdown docs](https://wingo-ui.com/components/markdown), press Replay as a stream in the playground, then install it with `npx wingo-ui@latest add markdown`. If you build with a coding agent, the [component library MCP guide](https://wingo-ui.com/blog/component-library-mcp-guide) shows how to let it install these components directly. More posts like this one are in [Building UI with AI agents](https://wingo-ui.com/blog/category/ai-agents).

## Components in this post

- [Markdown](https://wingo-ui.com/components/markdown) (Pro): A React Markdown renderer in the library's look: scrolling tables, highlighted code, task lists and a flicker-free streaming mode for AI. Install: `npx wingo-ui@latest add markdown`
- [Chat Message](https://wingo-ui.com/components/chat-message) (Pro): A React AI chat message: the question in a bubble, the answer with its thinking, tool calls, markdown, sources and actions. Install: `npx wingo-ui@latest add chat-message`
- [useStickToBottom](https://wingo-ui.com/components/use-stick-to-bottom) (Pro): A React hook that keeps a chat, a log or streaming output pinned to its bottom while it grows, and lets go when the reader scrolls up. Install: `npx wingo-ui@latest add use-stick-to-bottom`

## FAQ

### Why does react-markdown flicker while an AI answer streams?

Each chunk changes the source string, so react-markdown parses and renders the whole answer again, and unfinished syntax such as an open ** or a half-typed link renders as raw characters until its closer arrives. The text then flips between raw and formatted on every few tokens.

### How do I stop a streaming code block from breaking the rest of the message?

Close the open fence yourself before parsing: if the text has an opening ``` without a closer, append one. Also hold the fence back until its first line ends, or a half-typed language name can briefly pick the wrong highlighting.

### Is memoizing Markdown blocks enough to fix streaming?

It fixes the cost, because finished blocks stop re-parsing, but the last block still shows half-typed bold, links and table headers. You need both: block memoization for speed and a repair step on the tail for the flicker.

### How do I auto-scroll a React chat to the bottom only when the user is at the bottom?

Track whether the scroller is within a small threshold of its end, follow new content only while that is true, and stop following as soon as the reader scrolls up, even by a few pixels. A ResizeObserver on the content catches every change in height, including ones without a state update, such as an image that finishes loading.

### Does CSS overflow-anchor solve stick to bottom on its own?

Not reliably for streaming. Scroll anchoring was designed to keep visible content still when something changes above it, it gives you no at-bottom state for a jump button, and MDN lists overflow-anchor as Baseline only since September 2026, so older browsers on phones still lack it.

---

Source: https://wingo-ui.com/blog/react-markdown-streaming
