Shipping AI Features in the Front End: Streaming UI, Loading States, and Trust

July 25, 2026

Every product seems to be adding an AI assistant, a smart search bar, or a "generate this for me" button. The demo version of these features—a text box, a spinner, a response—takes an afternoon. The production version is a genuinely different front-end engineering problem, because it has to handle three things traditional CRUD UIs mostly don't: streaming responses, non-deterministic output, and a system that can be confidently wrong.

Here's what that actually looks like in practice.

Streaming is the baseline, not a nice-to-have

Waiting 8 seconds for a full response with a spinner feels broken. Watching text stream in feels fast, even when the total time is the same or longer, because the user gets continuous feedback that something is happening.

export async function POST(req: Request) { const { messages } = await req.json(); const stream = await getModelStream(messages); // provider-specific streaming call return new Response(stream, { headers: { "Content-Type": "text/event-stream" }, }); }
"use client"; import { useState } from "react"; export function Chat() { const [response, setResponse] = useState(""); const [streaming, setStreaming] = useState(false); async function send(message: string) { setStreaming(true); setResponse(""); const res = await fetch("/api/chat", { method: "POST", body: JSON.stringify({ messages: [{ role: "user", content: message }] }), }); const reader = res.body!.getReader(); const decoder = new TextDecoder(); while (true) { const { done, value } = await reader.read(); if (done) break; setResponse((prev) => prev + decoder.decode(value, { stream: true })); } setStreaming(false); } return ( <div> <div aria-live="polite">{response}</div> {streaming && <span className="animate-pulse">▍</span>} </div> ); }

Note the aria-live="polite" region—streaming text needs to be announced to screen readers incrementally, not silently, or the feature is invisible to anyone using assistive tech.

Design for three states, not two

Traditional loading UI is binary: loading or loaded. AI features need a third state, because "the model is generating" and "the model is deciding what tool to call or what to search for" are different waits with different appropriate feedback:

StateWhat the user sees
Thinking / retrievingA specific indicator ("Searching your documents…"), not a generic spinner
StreamingText appearing incrementally, with a visible cursor or pulse
DoneFinal content, plus any actions (copy, regenerate, rate)

Collapsing "thinking" and "streaming" into one generic loading state is the single most common UX gap in AI features—users can't tell if the system is stuck or working.

Handle partial failure gracefully

Streams can fail mid-response—a dropped connection, a rate limit hit halfway through, a model provider timeout. Unlike a traditional API call that either succeeds or fails cleanly, you often end up with a partial, cut-off response on screen.

async function send(message: string) { setStreaming(true); setError(null); setResponse(""); try { // ...streaming loop from above } catch (err) { setError( response.length > 0 ? "The response was cut off. You can try regenerating it." : "Something went wrong. Please try again." ); } finally { setStreaming(false); } }

The message differs based on whether partial content exists—"cut off, here's a regenerate button" is a much better recovery path than silently discarding a half-formed answer the user already started reading.

Build in a way to correct the model

Because output is non-deterministic, the interface needs an explicit way for users to say "that's wrong" or "try again differently"—not just accept-or-nothing.

export function MessageActions({ onRegenerate, onCopy, onFeedback }: MessageActionsProps) { return ( <div className="flex gap-2 text-sm text-muted-foreground"> <button onClick={onRegenerate}>Regenerate</button> <button onClick={onCopy}>Copy</button> <button onClick={() => onFeedback("up")} aria-label="Good response">👍</button> <button onClick={() => onFeedback("down")} aria-label="Bad response">👎</button> </div> ); }

This isn't just UX polish—the feedback signal is often the only way a team learns which prompts or retrieval setups are actually producing bad output in production, since AI failures rarely throw errors you can catch in logs.

Make uncertainty visible, don't hide it

The interfaces that build the most trust are the ones that show their work instead of presenting output as flat, unqualified fact:

  • Cite sources when the response is grounded in retrieved documents, with links back to them.
  • Distinguish "I found this in your data" from "I'm inferring this," if your system can tell the difference.
  • For anything with real consequences—sending an email, executing a database change, deleting something—require an explicit confirmation step instead of letting the model act autonomously.
"use client"; export function AiActionConfirm({ action, onConfirm, onCancel }: AiActionConfirmProps) { return ( <div className="rounded-md border p-3"> <p className="text-sm">The assistant wants to: <strong>{action.summary}</strong></p> <div className="mt-2 flex gap-2"> <button onClick={onConfirm}>Confirm</button> <button onClick={onCancel}>Cancel</button> </div> </div> ); }

A model that asks before acting on something irreversible earns more long-term trust than one that acts instantly and is occasionally wrong.

The takeaway

AI features fail on the front end far more often from missing states and unclear feedback than from the model itself being weak. Streaming, distinct loading phases, graceful partial-failure handling, and a visible way to correct the system aren't extras—they're the actual product surface users judge "AI-powered" features by. Get the chat box working in an afternoon if you want; budget the real time for everything around it.

GitHub
LinkedIn