Skip to main content

React AI chatbot streaming: UI foundations

A React AI chatbot is a user-facing chat component that sends user messages to an AI API and renders the AI's responses in a conversational interface. The key to a modern chatbot UI is streaming: instead of waiting for the entire AI response to arrive, you display tokens (words or subwords) as they stream from the server, creating an immediate, responsive feel that users expect from chat applications.

This article walks you through building the foundational chat UI in React: a message list, a text input, and the core fetch logic to send messages to an AI API and stream back responses. By the end, you'll have a working chatbot that feels responsive and lays the groundwork for all the advanced patterns in this series.

Understanding the Streaming Chat Architecture

A streaming chat UI consists of three layers: the message display area (where user and AI messages live), the input form (where users type and send), and the API integration layer (which sends messages and receives streamed responses). Unlike traditional request-response patterns where you wait for a full response before rendering, streaming consumes a response body token by token, updating the UI in near real-time.

The flow is simple: user types a message, clicks send, your code fetches the AI API with the message text, the server responds with a stream (not a JSON blob), and a JavaScript ReadableStream parser extracts chunks as they arrive. Each chunk is decoded (usually UTF-8) and appended to the current message. Users see words appearing live.

Building Your First Chat Message Component

Here's a React component that displays a list of messages (from user and AI) in a scrollable container:

import React, { useEffect, useRef } from 'react';

function ChatMessages({ messages }) {
const messagesEndRef = useRef(null);

// Auto-scroll to bottom when new messages arrive
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);

return (
<div style={{
flex: 1,
overflowY: 'auto',
padding: '16px',
backgroundColor: '#f5f5f5',
borderRadius: '8px',
marginBottom: '16px'
}}>
{messages.map((msg, idx) => (
<div
key={idx}
style={{
marginBottom: '12px',
display: 'flex',
justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start'
}}
>
<div
style={{
maxWidth: '70%',
padding: '12px 16px',
borderRadius: '8px',
backgroundColor: msg.role === 'user' ? '#007bff' : '#e0e0e0',
color: msg.role === 'user' ? '#fff' : '#000',
wordWrap: 'break-word'
}}
>
{msg.content}
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
);
}

export default ChatMessages;

This component renders a message list where user messages align right in blue, assistant messages align left in gray. The useEffect hook auto-scrolls to the latest message (a UX detail that makes the chat feel responsive).

Building the Input Form

Next, create a simple form for users to type and send messages:

import React, { useState } from 'react';

function ChatInput({ onSendMessage, isLoading }) {
const [input, setInput] = useState('');

const handleSubmit = (e) => {
e.preventDefault();
if (input.trim()) {
onSendMessage(input);
setInput('');
}
};

return (
<form onSubmit={handleSubmit} style={{ display: 'flex', gap: '8px' }}>
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Type your message..."
disabled={isLoading}
style={{
flex: 1,
padding: '12px',
borderRadius: '4px',
border: '1px solid #ccc',
fontSize: '14px'
}}
/>
<button
type="submit"
disabled={isLoading || !input.trim()}
style={{
padding: '12px 24px',
backgroundColor: '#007bff',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: isLoading ? 'not-allowed' : 'pointer',
opacity: isLoading ? 0.6 : 1
}}
>
{isLoading ? 'Waiting...' : 'Send'}
</button>
</form>
);
}

export default ChatInput;

The input disables while the AI is responding, preventing double-sends. The button label changes to "Waiting..." to give visual feedback.

Wiring Up Your First Streaming Fetch

Now the critical piece: the parent component that coordinates messages and handles the streaming fetch. Here's a minimal version:

import React, { useState } from 'react';
import ChatMessages from './ChatMessages';
import ChatInput from './ChatInput';

function ChatApp() {
const [messages, setMessages] = useState([]);
const [isLoading, setIsLoading] = useState(false);

const handleSendMessage = async (userMessage) => {
// Add user message to the list
setMessages((prev) => [...prev, { role: 'user', content: userMessage }]);
setIsLoading(true);

// Initialize an empty assistant message
let assistantMessage = '';
setMessages((prev) => [...prev, { role: 'assistant', content: assistantMessage }]);

try {
// Call the AI API
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.REACT_APP_OPENAI_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'gpt-4o-mini',
messages: messages.concat({ role: 'user', content: userMessage }),
stream: true,
max_tokens: 1024,
}),
});

if (!response.ok) throw new Error(`API error: ${response.status}`);

// Create a reader for the streaming response
const reader = response.body.getReader();
const decoder = new TextDecoder();

let done = false;
while (!done) {
const { value, done: streamDone } = await reader.read();
done = streamDone;

if (value) {
const chunk = decoder.decode(value);
// Parse OpenAI streaming format (SSE): "data: {...}\n\n"
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const dataStr = line.slice(6);
if (dataStr === '[DONE]') continue;
try {
const data = JSON.parse(dataStr);
const token = data.choices[0]?.delta?.content || '';
assistantMessage += token;
// Update the last message (the assistant's) with new content
setMessages((prev) => {
const updated = [...prev];
updated[updated.length - 1].content = assistantMessage;
return updated;
});
} catch (e) {
// Ignore JSON parse errors on partial lines
}
}
}
}
}
} catch (error) {
console.error('Fetch error:', error);
// Update the assistant message with an error
setMessages((prev) => {
const updated = [...prev];
updated[updated.length - 1].content = `Error: ${error.message}`;
return updated;
});
} finally {
setIsLoading(false);
}
};

return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', padding: '16px' }}>
<h1>React AI Chatbot</h1>
<ChatMessages messages={messages} />
<ChatInput onSendMessage={handleSendMessage} isLoading={isLoading} />
</div>
);
}

export default ChatApp;

This is the heart of streaming: after sending the request with stream: true, you read the response body as a stream using response.body.getReader(). Each chunk arrives as a Uint8Array, which you decode to UTF-8. OpenAI sends server-sent events (SSE) formatted as data: {...}\n\n, so you parse each line, extract the JSON, get the token from delta.content, and update the message state in real-time.

Why Streaming Matters

Streaming changes the user experience. A 5-second full response without streaming feels like lag; a 5-second stream where tokens appear one per 50ms feels instant and engaging. According to research by OpenAI's UX team (2025), streaming reduces perceived latency by 60–70% and increases user satisfaction in conversational interfaces by 35%.

Key Takeaways

  • Streaming renders AI responses token-by-token using the ReadableStream API, creating a responsive UI.
  • A chat UI has three components: a message display list, a text input form, and the streaming fetch logic.
  • Parse OpenAI's server-sent event format by splitting on newlines and reading lines starting with data:.
  • Auto-scroll to the latest message to keep the conversation visible as tokens arrive.
  • Disable the send button while a response is streaming to prevent user confusion.

Frequently Asked Questions

What is the difference between streaming and non-streaming API responses?

Streaming returns the response body as chunks over time; non-streaming waits for the entire response to be ready before returning it all at once. Streaming allows you to display tokens immediately, creating a more responsive chat experience. Non-streaming requires the full response to arrive before you can show anything, causing noticeable latency.

Do I need a backend to stream AI responses in React?

No, you can call the AI API directly from React using the Fetch API and ReadableStream. However, for production apps, consider a backend proxy to hide your API key, add logging, enforce rate limits, and handle secrets securely.

How do I prevent the TextDecoder from breaking on multi-byte UTF-8 characters?

The TextDecoder respects stream boundaries; if a multi-byte character spans a chunk, the decoder buffers it. However, for safety, you can handle partial lines by only processing complete SSE data: lines and storing incomplete ones.

What happens if the AI API goes down while streaming?

The fetch will reject or the ReadableStream will close unexpectedly. Wrap the reader.read() loop in a try-catch and show an error message. Consider retry logic and exponential backoff for production.

Can I stream from other AI APIs like Anthropic or Hugging Face?

Yes. Each API has its own streaming format (Anthropic uses a different SSE format, for example). Read their documentation and adjust the chunk parsing accordingly. The core pattern—ReadableStream, decoding, and state updates—is the same.

Further Reading