Build the chat UI around three things first: a streaming message pipeline, stored conversation history, and small reusable components that never care which AI provider you use. React is a strong fit because it powers millions of live websites, with usage trackers listing more than 12 million sites using it [source: BuiltWith]. That means your chat interface can be simple, familiar, and easy to maintain.
TLDR: Create a React chat using a MessageList, ChatInput, and MessageBubble, then stream tokens from your backend into state as they arrive. Store the latest messages in local state, localStorage, or a database so users can return to a conversation. For example, a support team handling 1,000 questions a week could cut repeat typing by 30% if the bot keeps thread history and shows earlier answers. Start small, then add polish like loading states, retry buttons, and markdown rendering.
Start with a clear component shape
A good AI chat interface should feel boring under the hood. That is a compliment. If every part has one job, bugs become easier to find.
- ChatContainer: owns state, fetch calls, and conversation logic.
- MessageList: renders all messages in order.
- MessageBubble: displays one user or assistant message.
- ChatInput: handles typing, submit, and disabled states.
- TypingIndicator: shows that the model is still responding.
This split keeps the UI clean. It also makes it easy to swap providers later. OpenAI today, Anthropic tomorrow, your own model next month. The UI should not care.
Model your messages first
Before writing fetch code, define your message shape. Keep it plain.
const message = {
id: crypto.randomUUID(),
role: "user", // or "assistant"
content: "",
createdAt: Date.now()
};
Use an array for the conversation:
const [messages, setMessages] = useState([]);
When the user submits text, add their message right away. Then add an empty assistant message. The stream will fill that assistant message bit by bit.
Stream responses instead of waiting
Waiting for a full AI reply feels slow. Even a 4 second delay can make the app feel broken. Streaming fixes that by showing words as soon as the server sends them.
Your frontend can read a streaming response with the browser’s ReadableStream API. The catch is that stream code can get ugly fast if you mix it with rendering logic. Keep it in one function.
async function sendMessage(text) {
const userMessage = {
id: crypto.randomUUID(),
role: "user",
content: text,
createdAt: Date.now()
};
const assistantId = crypto.randomUUID();
setMessages(prev => [
...prev,
userMessage,
{
id: assistantId,
role: "assistant",
content: "",
createdAt: Date.now()
}
]);
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: [...messages, userMessage] })
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { value, done } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
setMessages(prev =>
prev.map(msg =>
msg.id === assistantId
? { ...msg, content: msg.content + chunk }
: msg
)
);
}
}
This gives the user instant feedback. The answer grows on screen. It feels alive without any fancy animation.
Keep conversation history useful
History is not just a nice extra. It lets the assistant answer follow-up questions. If the user asks, “Can you make that shorter?”, the app must know what “that” means.
For a first version, store history in React state and mirror it to localStorage.
useEffect(() => {
localStorage.setItem("chatHistory", JSON.stringify(messages));
}, [messages]);
useEffect(() => {
const saved = localStorage.getItem("chatHistory");
if (saved) setMessages(JSON.parse(saved));
}, []);
For production, save messages in a database. Tie them to a user ID, workspace ID, or session ID. Add a “New chat” button too. Honestly, it feels like every chat app forgets this tiny feature, then users get stuck inside one giant thread forever.
Build reusable UI components
Your MessageBubble should handle role-based styling. User messages can align right. Assistant messages can align left. Keep the markup simple.
function MessageBubble({ message }) {
const isUser = message.role === "user";
return (
<div className={isUser ? "bubble user" : "bubble assistant"}>
<p>{message.content}</p>
</div>
);
}
The ChatInput should disable submit while the AI is answering. It should also support Enter to send and Shift + Enter for a new line. That tiny detail saves users from accidental walls of text.
- Show streaming state so users know the app is working.
- Add a stop button for long replies.
- Add retry for failed requests.
- Render markdown if your assistant returns lists or code.
- Auto-scroll carefully, but stop if the user scrolls up.
Use a backend for AI calls
Do not call an AI provider directly from React. Your API key would be exposed. Create a backend route such as /api/chat. That route receives the conversation, calls the AI provider, and streams the result back to the browser.
This setup also gives you control. You can limit request size, block abuse, log errors, and trim old messages before sending them to the model. That matters because long chats cost more and slow down replies.
Polish the user experience
Add small touches after the core flow works. Use skeleton loading for the first token. Show timestamps only when helpful. Let users copy answers. Add clear error text, not “Something went wrong.” Say what happened and what they can try next.
A strong React AI chat is not about flashy effects. It is about speed, memory, and structure. Stream the answer. Save the thread. Keep components small. Do that, and you will have a chat interface that feels useful instead of fragile.

