Build a streaming chat interface in React

Connect a prompt input to a streamed reply, keep draft and response state separate, and let people stop generation.

Live example

Install the components

Add these to a React project with Tailwind CSS configured.

npx shadcn@latest add https://gradientdeck.com/ai-components/r/prompt-input-card.json
npx shadcn@latest add https://gradientdeck.com/ai-components/r/streaming-text-default.json

Components used: Prompt input, Streaming text.

1. Keep the draft separate from the response

PromptInputCard receives the draft through value and onChange. On submit, save the question, clear the draft and start a new response. Keep the full message history in your application if the conversation spans multiple turns.

2. Append chunks as they arrive

The example uses an async generator with sample text. Replace reply() with your backend transport, yielding decoded text chunks. StreamingText takes the accumulated response, a visible word count and a completion flag. It does not call a model.

3. Cancel and clean up

The stop button aborts the current request. Check the abort signal before appending another chunk, and cancel when the component unmounts. A real fetch transport should receive the same signal and surface non-abort errors with a retry action.

Complete example

Save this as a client component after installing the components above.

"use client";

import { useEffect, useRef, useState } from "react";
import { PromptInputCard } from "@/components/ai/prompt-input-card";
import { StreamingText } from "@/components/ai/streaming-text-default";

// Replace this sample transport with an async iterable from your backend.
async function* reply(signal: AbortSignal) {
  for (const word of "A good chat interface keeps the draft separate from the reply, shows progress, and lets the person stop generation.".split(" ")) {
    await new Promise(resolve => setTimeout(resolve, 90));
    if (signal.aborted) return;
    yield word + " ";
  }
}

export default function StreamingChatExample() {
  const [draft, setDraft] = useState("");
  const [question, setQuestion] = useState("");
  const [answer, setAnswer] = useState("");
  const [running, setRunning] = useState(false);
  const controller = useRef<AbortController | null>(null);
  useEffect(() => () => controller.current?.abort(), []);

  async function send(value: string) {
    if (controller.current) return;
    const current = new AbortController();
    controller.current = current;
    setQuestion(value);
    setDraft("");
    setAnswer("");
    setRunning(true);
    try {
      for await (const chunk of reply(current.signal)) {
        if (current.signal.aborted) break;
        setAnswer(text => text + chunk);
      }
    } finally {
      if (controller.current === current) {
        controller.current = null;
        setRunning(false);
      }
    }
  }

  return <div className="w-full max-w-xl space-y-5">
    {question && <p className="text-sm text-zinc-500">{question}</p>}
    {answer && <StreamingText text={answer} shown={answer.trim().split(/\s+/).length} done={!running} />}
    <PromptInputCard value={draft} onChange={setDraft} onSubmit={send} streaming={running} onStop={() => controller.current?.abort()} placeholder="Ask about chat interface design" />
  </div>;
}

Connect your backend

Keep model credentials on your server. Add error feedback, message persistence and retry behavior before using this sample in a production chat. This example demonstrates text streaming; tool events and structured messages need their own event handlers.

All guides โ†’