Guide
Building a React frontend for LLM chat apps
A React frontend for an LLM chat app needs more than a message list. Model each message with an explicit status, keep API keys on the server, render model output as untrusted Markdown, keep streaming updates cheap, and design for stopping, retrying, errors, tool activity, scrolling and screen readers. Most of the work lies in these states rather than in the happy path.
What makes a chat interface different
A typical form submits a request and shows a result. A chat turn stays in progress: text arrives gradually, may be interrupted, may pause while a tool runs and may end in an error after half an answer is already on screen. Replies also vary in length and quality, and users need a way to respond to that.
This guide concentrates on React state and interface decisions. The transport layer, including server-sent events, buffering and parsing, is covered in streaming LLM responses to a web UI, so it appears here only as a placeholder function. The wider architecture is in adding an LLM feature to a full stack app.
One rule comes before all the others: the browser talks to your backend, and your backend talks to the model. A key in client code can be read by anyone who opens developer tools.
Model messages as data with a status
An array of strings will not get you far. Give each message a stable ID, a role and an explicit status, so that each state can be drawn deliberately:
- pending: sent, with nothing received yet.
- streaming: text is arriving.
- complete: finished normally.
- stopped: the user cancelled, so the partial text is kept and marked as such.
- failed: an error occurred, possibly after some text had arrived.
Use the ID, not the array index, as the React key. Consider storing parts rather than one string as well. An assistant turn may contain text, a tool call, a citation and more text, and a list of typed parts renders far more cleanly than markers embedded in a string.
Keep this logic in a hook or a reducer, away from the components. Here is an illustrative sketch. It omits persistence and batching, and readStream stands in for your own transport function:
// Illustrative only. readStream is a placeholder for your transport code.
import { useRef, useState } from "react";
export function useChat() {
const [messages, setMessages] = useState([]);
const controllerRef = useRef(null);
const update = (id, change) =>
setMessages((all) =>
all.map((m) => (m.id === id ? { ...m, ...change(m) } : m))
);
async function send(text) {
const replyId = crypto.randomUUID();
const controller = new AbortController();
controllerRef.current = controller;
setMessages((all) => [
...all,
{ id: crypto.randomUUID(), role: "user", text, status: "complete" },
{ id: replyId, role: "assistant", text: "", status: "pending" },
]);
try {
await readStream(text, controller.signal, (chunk) =>
update(replyId, (m) => ({ text: m.text + chunk, status: "streaming" }))
);
update(replyId, () => ({ status: "complete" }));
} catch (error) {
const status = error.name === "AbortError" ? "stopped" : "failed";
update(replyId, () => ({ status }));
}
}
const stop = () => controllerRef.current?.abort();
return { messages, send, stop };
}
The functional form of setMessages matters here, because chunks arrive after the render that started the request, and a value captured at that time would be out of date.
Keep streaming updates cheap
If every chunk re-renders the whole conversation and re-parses every message's Markdown, long chats will start to lag. Some practical measures help:
- Batch incoming text. Hold chunks in a ref and commit them to state on an animation frame or a short timer. A reader will not notice the difference, and React does far less work.
- Memoise finished messages. Wrap the message component in
React.memoand keep each message object's identity stable when it has not changed, so that only the active message re-renders. - Virtualise very long histories, or load older messages on demand, once profiling shows a need. Variable message heights and streaming make virtualisation fiddly, so do not add it by default.
Measure with the React Profiler on a long conversation, using a slower device if you can. A short demo chat will hide these issues.
Render model output as untrusted
Model output may include text that an attacker planted in a document or a web page. Avoid dangerouslySetInnerHTML with unsanitised output. The react-markdown library builds React elements from a syntax tree and, according to its documentation, does not render raw HTML by default. That safe default can be undone by plugins such as rehype-raw, by custom components or by an insecure URL transform, so review each addition.
Links and images need particular thought. A Markdown image triggers a request as soon as it renders, which can leak data through its URL. The guide to LLM guardrails and prompt injection defence explains that attack. In practice, use a custom image component that allows only approved domains or shows a link instead. Open external links with rel="noopener noreferrer", and consider showing the destination clearly, because a model can write misleading link text.
Partial Markdown is the other problem. An unclosed code fence may make the rest of a message appear as code until the closing marks arrive, and the layout can jump as a table takes shape. Render code blocks with a stable container, keep their horizontal scrolling inside the block and consider delaying syntax highlighting until the block has finished.
Scrolling that respects the reader
Following new text is useful until someone wants to read something further up. Forcing them back to the bottom on every chunk makes the app feel hostile. A reasonable set of rules is:
- Follow new output only while the user is at or very near the bottom.
- Stop following as soon as they scroll up.
- Show a "jump to latest" button while they are away from the bottom.
- When they send a message, scroll to show it.
- When loading older messages, preserve the current position so the content does not jump.
The input box and controls
The composer is where people spend most of their time, so small details matter:
- Keyboard behaviour. Enter to send and Shift+Enter for a new line is a common convention, but check
isComposingso that Enter does not send a message while someone is confirming text in an input method editor, as used for Japanese or Chinese. Think through how Enter behaves on mobile too. - Stop and send. Swap the send button for a stop button during generation. Stopping should abort the request, and the backend should cancel the model call as well.
- Drafts. Do not lose a long message because a request failed. Restore the text to the composer, or offer a retry on the failed message.
- Height. Let the text area grow up to a limit, then scroll. On phones, check that the on-screen keyboard does not cover it.
Showing tools and waiting time
Agents can go quiet while a tool runs. A generic spinner tells the user nothing, whereas a line such as "Searching the documentation" shows that something is happening. Send these as structured events from the backend, written in plain language, and do not show raw tool arguments to users unless they are meant to see them. They may contain internal identifiers.
When an agent needs permission, render a proper confirmation with the exact action and its details, not a line of chat that the user answers by typing "yes". The review screen advice in human-in-the-loop approval for AI agents applies directly. If the product cites sources, make each citation link to the passage itself, so that people can check the claim.
Errors, retries and honesty
A failure after some text has arrived is not the same as a failure before anything arrived. Keep the partial text, label it as incomplete and offer a retry. Different errors also deserve different messages: "you have reached your limit" requires different action from "the service is unavailable", and neither should appear as a raw stack trace.
Decide what retry and regenerate do to the history. Replacing an answer is simple, while keeping both versions as branches is more complex and only worth doing if users need it. Also consider how the product conveys uncertainty. A short, accurate note saying that answers can be wrong, placed near the output, is more useful than a long disclaimer on a separate page.
Accessibility
MDN lists chat logs as an example of the ARIA log role, which has an implicit polite live setting and needs an accessible name, for instance through aria-label. However, applying it directly to a streaming message may announce many tiny fragments. A common approach is to announce status changes, and then the finished message, through a separate live region. Test with an actual screen reader, since behaviour differs between products.
Also make sure that every control works from the keyboard, that focus returns to the composer sensibly after sending, that icon buttons have labels and that animated cursors respect prefers-reduced-motion.
Testing the states
Replace the backend with a scripted stub that can produce any sequence on demand, and write a component test for each state: no response yet, a very long reply, an error mid-stream, a stop click and a malicious link or image in the output. Then try the result on a phone with a throttled connection. For help building this kind of interface, see full stack development.
Frequently asked questions
Can I call an LLM API directly from React?
Not with a real secret key. Anything shipped to the browser can be read by users, so the key could be copied and abused. Send requests through your backend, which can also authenticate users, apply limits and record usage.
Is react-markdown safe for rendering LLM output?
Its documentation describes it as safe by default, since it does not use dangerouslySetInnerHTML and does not render raw HTML unless configured to. Plugins and custom components can change that, and you still need your own policy for links and images.
How do I stop re-renders during streaming in React?
Batch incoming chunks and commit them on an animation frame or a short timer. Memoise finished messages and keep their object identity stable, so that only the active message updates. Confirm the effect with the React Profiler on a long conversation.
Should a chat app use a library or custom components?
A library can speed up streaming and state handling, while custom components give more control over rendering and unusual flows. Decide according to how standard your interface is, and check that any library supports stopping, tools and your backend's event format.