Dev.to AI πŸ€– Ai πŸ‘ 0 πŸ“– 6 min read

Building an AI chat UI with React and shadcn: start with the awkward states

By Alex, who builds Cuelark. The components linked here are ours. A chat screen looks finished long before it behaves properly. Put a few bubbles above a textarea and it photographs well. Then somebody presses Enter twi

By Alex, who builds Cuelark. The components linked here are ours.

A chat screen looks finished long before it behaves properly. Put a few bubbles above a textarea and it photographs well. Then somebody presses Enter twice, cancels a response, or pastes a file they did not mean to attach.

That is where I would start a React AI chat interface. Not the gradient. The state transitions.

We built Cuelark components for this layer of the application: a message layout and a composer you can adapt in your own project. They are UI source, not an AI backend. They do not choose a model, protect a secret API key, or make your data handling private by themselves.

This walkthrough focuses on the boundary between those pieces. Getting that boundary right makes the rest of the interface much less surprising.

Use source you can change

The shadcn approach is useful here because a chat interface rarely stays generic. Your application may need approval buttons, citations, a retry action or a warning before a file leaves the device. Owning the component source makes those changes straightforward to inspect.

Start with Cuelark's Message component and Prompt Input component. Use the installation instructions on those pages, then check the copied files and their dependencies in your project. Do not assume an example import path matches the aliases in your application.

For this article I checked the current component source rather than guessing from a screenshot. I also checked both documentation destinations over HTTP: two requests, two 200 responses. That confirms that the links resolve, not that an entire production chat application has passed a browser test suite.

There is no latency benchmark hidden in those numbers. Performance depends on your app, network and model service, and I have not measured those here.

Give messages stable identities

Store conversation turns as data. At minimum, each needs an ID, a role and its content. Give IDs to turns when you create them, not each time React renders.

A compact shape could look like this:

type Turn = {
  id: string;
  role: 'user' | 'assistant';
  text: string;
  state: 'pending' | 'complete' | 'failed' | 'cancelled';
};

That is an application design suggestion, not a type exported by Cuelark. The actual Message component takes a from value of user or assistant. Your application maps its own turn data into that presentation API.

Avoid using the array index as the key if you might retry, insert or remove turns. A stable ID also gives you something sensible to attach feedback and analytics to later, without treating message text as an identifier.

For a streaming assistant response, create one turn and update its text. Do not append a new message for every chunk. Otherwise copy actions, focus and screen-reader output become a mess surprisingly quickly.

Decide what belongs in a bubble

Cuelark's Message layout makes user turns right-aligned bubbles. Assistant turns read as a wider document on the left. That distinction is intentional: long answers and code are harder to read when squeezed into a decorative bubble.

The source limits user content to 80% width. Assistant content uses the available space. Those are layout choices you can edit, not a requirement of a chat protocol.

Keep plain text rendering as the default while you assemble the application. React text content is a safer starting point than injecting generated HTML. If you add Markdown later, decide explicitly how you sanitize HTML, handle links and render code blocks.

A model response is untrusted input. A nice message component does not change that. The same applies to text returned by a search tool or imported from a document.

Understand the composer's actual contract

PromptInput calls onSubmit with an object containing text and files. The text is trimmed. The files are browser File objects, not uploaded URLs.

It supports two UI statuses: idle and streaming. While streaming, the send control becomes a stop control. The callback for that control is onStop.

There is an important implementation detail: after it calls onSubmit, the composer clears its local text and files. It does not wait for your server to accept the request. If you want to restore a failed draft, save that submitted payload in application state before starting the network request.

I would rather show a plainly failed message with a retry button than quietly lose someone's draft. A polished animation cannot compensate for missing text.

Also note that an attachment alone can make a submission valid. If your backend only accepts text, turn attachments off instead of displaying an attachment button that leads nowhere.

Keep request state outside the component

Your application should own the request lifecycle. When submission starts, mark the request active, create the assistant placeholder and pass status="streaming" to the composer. On completion or failure, return to idle.

Protect the handler against a second submission while a request is already active. The composer changes its controls based on props, but your backend and application state still need a duplicate-request policy. A disabled button is not a server-side safety mechanism.

Choose how partial output behaves after an error. Keeping it with a visible failure note is often more helpful than deleting it. Make retry an explicit action so a temporary connection problem does not silently produce duplicate work or duplicate charges.

If your system uses a paid model, keep billing and limits on the backend too. None of that belongs in the visual component.

Stop should really stop

Wiring onStop to hide a spinner is not cancellation. Use it to cancel the browser request where possible, commonly through an AbortController, and connect that to your server's cancellation behavior.

Do not promise that aborting a browser request always stops upstream generation or billing. That depends on your backend and provider. Test it with the actual service you use.

The component listens for Escape while streaming. That is convenient, but it is also a global keyboard listener. If your app has a modal or editor that uses Escape, check how those interactions overlap.

Keep the partial answer visible and mark it cancelled. Otherwise the user can mistake an unfinished sentence for a completed response. Small wording choices matter here.

Attachments need a policy, not just a paperclip

The current composer accepts files through a picker, paste and drag-and-drop when attachments are enabled. It also shows removable file chips. It does not enforce your application's file-type or size policy for you.

Validate files before upload. Validate them again on the server. Decide where files are stored, how long they remain there, and whether a model provider receives them. Put that explanation near the attachment interaction rather than burying it in a vague privacy slogan.

For a text-only prototype, my preference is attachments={false}. It removes a whole set of misleading expectations. Add the feature when the backend and privacy explanation are ready.

Cuelark's browser-only file tools and an AI chat application are different products with different data paths. Do not borrow a β€œfiles stay local” claim for a chat interface that sends files to your server.

Test the keyboard before the screenshots

Enter sends; Shift+Enter inserts a line break. The source also checks composition state before treating Enter as submission, which matters for people using input methods to compose text.

Build a small acceptance checklist around actual behavior. Try an empty message, a message containing only spaces and a long paragraph. Try sending during an active response. Cancel, then submit again. If attachments are enabled, paste one and remove it before sending.

These are proposed acceptance cases, not claims of tests I have already executed in your project. Run them with keyboard navigation and on a touch device. Message actions that appear on hover also need a usable focus path; this source includes focus-within and touch visibility styles, but your integration can still break them.

Finally, decide how new output is announced to assistive technology. Announcing every token is usually unpleasant. A restrained status announcement is a better starting point to test.

FAQ

Does this include a model backend?

No. You supply request handling, credentials, conversation storage and any model integration. Keep secret credentials off the client.

Does the composer upload attachments automatically?

No. Its submission callback receives File objects. Your application decides what happens next.

Can I change the styling?

Yes. The source-copying approach is meant for local adaptation. Check dependencies and the project license before shipping.

What should I build first?

A text-only conversation with visible failure and working cancellation. Add richer formatting and attachments after those paths behave properly. It is less exciting in a screenshot, and considerably better to use.

πŸ“° Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.