DEV Community

Gaurav Guha
Gaurav Guha

Posted on Originally published at thefrontkit.com

How to Build Streaming UI for AI Responses in React

AI responses take 5 to 30 seconds to generate. Without streaming, users stare at a spinner the entire time. Streaming UI sends tokens as they arrive so the first word appears in 200 to 500 milliseconds.

How Streaming Works Under the Hood

Most LLM providers stream via Server-Sent Events (SSE) over a single HTTP connection. Each event carries a small chunk:

data: {"choices":[{"delta":{"content":"Hello"}}]}

data: {"choices":[{"delta":{"content":" there"}}]}

data: {"choices":[{"delta":{"content":"! How"}}]}

data: [DONE]
Enter fullscreen mode Exit fullscreen mode

The frontend reads the response body as a readable stream, decodes chunks, and appends them to the accumulated text:

async function streamResponse(prompt) {
  const response = await fetch('/api/chat', {
    method: 'POST',
    body: JSON.stringify({ prompt }),
  });

  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let accumulated = '';

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    const chunk = decoder.decode(value, { stream: true });
    accumulated += parseChunk(chunk);
    setContent(accumulated);
  }
}
Enter fullscreen mode Exit fullscreen mode

The hard part isn't receiving data. It's rendering partial Markdown, code blocks, and tables without flicker or layout shift.

Rendering Partial Markdown Safely

A naive renderer reparses on every token. That breaks bold tags, leaves code fences unclosed, and invalidates tables. Buffer incomplete structures and only render when you hit a safe boundary.

function StreamingMarkdown({ chunks, isStreaming }) {
  const [rendered, setRendered] = useState('');
  const bufferRef = useRef('');

  useEffect(() => {
    bufferRef.current += chunks;
    const safeContent = sanitizePartialMarkdown(bufferRef.current);
    setRendered(markdownToHtml(safeContent));
  }, [chunks]);

  return (
    <div
      className="prose"
      dangerouslySetInnerHTML={{ __html: rendered }}
      aria-live="polite"
      aria-busy={isStreaming}
    />
  );
}

function sanitizePartialMarkdown(text) {
  // Close any open bold/italic tags
  // Close any open code fences
  // Remove incomplete table rows
  // Buffer incomplete list items
  return cleanedText;
}
Enter fullscreen mode Exit fullscreen mode

For code blocks, defer syntax highlighting until the closing fence arrives. Re-highlighting on every chunk is expensive for long blocks.

Streaming Citations

Citations often reference content that appears later in the stream. Render inline markers immediately, then populate the reference panel when full citation objects arrive:

function StreamingWithCitations({ content, isStreaming }) {
  const { text, citations } = parseCitations(content);

  return (
    <div>
      <div className="prose" aria-live="polite" aria-busy={isStreaming}>
        {text}
      </div>
      {citations.length > 0 && (
        <div className="citations-panel" aria-label="Sources">
          {citations.map((citation, i) => (
            <a key={i} href={citation.url} className="citation-card">
              <span className="citation-number">[{i + 1}]</span>
              <span className="citation-title">{citation.title}</span>
              <span className="citation-domain">{citation.domain}</span>
            </a>
          ))}
        </div>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Stream Controls Users Expect

Stop Button

Visible during streaming. Aborts generation and keeps what arrived.

function StreamControls({ isStreaming, onStop, onRetry }) {
  return (
    <div className="flex gap-2">
      {isStreaming ? (
        <button onClick={onStop} aria-label="Stop generating">
          <StopIcon aria-hidden="true" />
          Stop
        </button>
      ) : (
        <button onClick={onRetry} aria-label="Regenerate response">
          <RefreshIcon aria-hidden="true" />
          Retry
        </button>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Copy and Format Toggle

Show copy after streaming completes. Let users switch between rendered Markdown, raw Markdown, and plain text without regenerating.

Accessibility Requirements

Streaming content breaks screen readers if handled poorly. Use these patterns for WCAG AA:

<div aria-live="polite" aria-atomic="false" aria-busy={isStreaming}>
  {streamedContent}
</div>
Enter fullscreen mode Exit fullscreen mode
  • aria-live="polite" announces changes without interrupting
  • aria-atomic="false" reads only new content, not the entire region
  • aria-busy="true" holds announcements until streaming completes

Don't steal focus from the prompt input when a response starts. Debounce announcements to 2 to 3 second intervals. Stop, retry, and copy buttons must be keyboard accessible with logical tab order.

Using the Vercel AI SDK

The Vercel AI SDK handles the streaming connection and state. You provide the UI layer:

'use client';

import { useChat } from 'ai/react';

export default function Chat() {
  const { messages, input, handleInputChange, handleSubmit, isLoading, stop } = useChat();

  return (
    <div>
      {messages.map((message) => (
        <div key={message.id} role={message.role === 'assistant' ? 'status' : undefined}>
          <strong>{message.role === 'user' ? 'You' : 'Assistant'}</strong>
          <p>{message.content}</p>
        </div>
      ))}

      <form onSubmit={handleSubmit}>
        <input
          value={input}
          onChange={handleInputChange}
          placeholder="Ask something..."
          aria-label="Chat prompt"
        />
        {isLoading ? (
          <button type="button" onClick={stop}>Stop</button>
        ) : (
          <button type="submit">Send</button>
        )}
      </form>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Anti-Patterns to Avoid

Layout thrash: Each token resizes the container and pushes content down. Fix with a minimum height on the response container and scroll the container, not the page.

No loading state before first token: TTFT can be seconds. Show a typing indicator or skeleton immediately on submit.

Flickering Markdown: Re-parsing every token toggles formatting on and off. Buffer and re-render only on whitespace or sentence boundaries.

No stop button: Users can't abort irrelevant long responses. Keep a prominent stop button visible during streaming.

Losing partial responses on error: If the stream fails mid-response, don't discard what arrived. Show the partial content with an error message and retry option.


This is a shorter version of a guide I wrote at Frontkit Studio. The full post has the complete walkthrough.

Top comments (0)