The Three UI States Everyone Forgets (Until a User Finds Them First)

Loading, empty, and error states are where real users actually live. Here's how happy-path-only components sneak into production, and how to catch them in review.

nothing yetoopsloadingemptyerror
Jim
Senior Frontend Engineer
Dec 3, 2025
6 min read

A while back I watched a session recording that I still think about. A user opened our dashboard, and for eleven seconds — eleven! — they stared at a completely blank white rectangle. No spinner, no skeleton, nothing. Then they refreshed the page. Then they refreshed again. Then they left.

The API was having a slow day. The component was fine, technically. It fetched data, and when the data arrived, it rendered beautifully. The problem was everything that happened before the data arrived, and everything that would have happened if it never arrived at all.

That component had one state: the happy path. Real users get three more.

The four states of every data-driven component

Any component that fetches data has, at minimum, four states:

  1. Loading — the request is in flight.
  2. Error — the request failed.
  3. Empty — the request succeeded, but there's nothing to show.
  4. Success — the request succeeded and there's data.

Here's the uncomfortable part: when we build a feature, we spend 95% of our time looking at state number four. Our local API is fast, our seed data is plentiful, and our network never fails. So the other three states get whatever falls out of the code by accident — which is usually a blank screen, an unhandled exception, or a heading floating above nothing.

// The classic happy-path-only component
function InvoiceList() {
  const { data } = useInvoices();
  return (
    <ul>
      {data.invoices.map((inv) => (
        <InvoiceRow key={inv.id} invoice={inv} />
      ))}
    </ul>
  );
}

This crashes while loading (data is undefined), crashes on error, and renders an empty <ul> when the user has no invoices. Three bugs, zero lines of visibly "wrong" code. That's what makes this class of bug so slippery in review: the code that's missing doesn't show up in the diff.

Loading: the state users see most

For any user on a phone, on hotel Wi-Fi, or on the other side of the planet from your servers, loading isn't an edge case — it's the first impression of every screen.

A few things I look for in review:

  • Is there any loading UI at all? A skeleton that mirrors the final layout is ideal because it prevents layout shift. A spinner is acceptable. A blank region is not.
  • Does the loading state match the shape of the content? If a skeleton renders three gray bars and the real content is a table, the page "jumps" when data lands. Users perceive that jump as slowness even when the load was fast.
  • Is there a flash for fast responses? A spinner that appears for 40ms and vanishes reads as flicker. Delaying the loading indicator by ~150–300ms makes fast loads feel instant and slow loads feel handled.

Empty: the state that's actually a feature

An empty state is not an error, and it's not nothing — it's often the very first thing a brand-new user sees. New account, zero invoices, zero projects, zero anything. If your empty state is a bare heading above whitespace, your onboarding experience is a bare heading above whitespace.

Good empty states answer three questions: What is this area for? Why is it empty? What should I do next?

if (invoices.length === 0) {
  return (
    <EmptyState
      icon={<ReceiptIcon />}
      title="No invoices yet"
      description="Invoices you create will show up here."
      action={<Button onClick={openCreateModal}>Create your first invoice</Button>}
    />
  );
}

One subtle trap: distinguish "empty because there's no data" from "empty because your filters matched nothing." Those deserve different messages. "No invoices yet" is confusing when the user has 400 invoices and an overly aggressive date filter. "No invoices match these filters — clear filters?" turns a dead end into a way forward.

Error: the state that decides whether users trust you

Errors will happen. The only question is whether the user experiences them as "this product told me something went wrong and helped me recover" or "this product silently broke."

What I want to see in an error state:

  • Human language. "We couldn't load your invoices" beats Error: Request failed with status code 500 every time. Raw error objects belong in your logging pipeline, not your UI.
  • A recovery path. A "Try again" button that actually re-triggers the fetch. Retrying by hard-refreshing the page means losing scroll position, form input, and patience.
  • Contained blast radius. One failed widget shouldn't take down the whole dashboard. Error boundaries (or per-query error handling) let the rest of the page keep working.

Why this matters more in the age of AI-generated code

Here's a pattern I've noticed reviewing PRs over the last couple of years: AI coding assistants are remarkably good at generating the happy path. Ask for "a component that lists invoices" and you'll get clean, idiomatic, working code — for state number four. Sometimes you'll get a token if (isLoading) return <div>Loading...</div> that no designer has ever seen.

This isn't a knock on the tools; it's a shift in where review attention needs to go. The generated code will look complete. It compiles, it renders, the demo works. The missing states are invisible unless you go looking for them. So when a data-fetching component shows up in a PR — human-written or AI-assisted — I've started asking the same four questions every time:

  1. What does this render while the request is in flight?
  2. What does this render if the request fails?
  3. What does this render if the response is empty?
  4. Do the answers to 1–3 look intentional, or accidental?

If the author (or the assistant) hasn't thought about it, question two usually surfaces it immediately.

A checklist you can actually use

You don't need a heavyweight process here — just a habit. Before approving any component that fetches data:

  • Throttle it. Open dev tools, set the network to "Slow 3G," and watch the component load. Is what you see acceptable?
  • Break it. Block the request or return a 500 from your mock. Is the error state helpful? Is there a retry?
  • Drain it. Return an empty array. Does the empty state guide the user somewhere useful?
  • Check the filter case. If the view supports filtering or search, does "no results" read differently from "no data"?
  • Look for layout shift. Does the transition from loading to loaded jump around?

Five checks, maybe three minutes. Compare that to the cost of a confused user staring at a blank rectangle for eleven seconds — or worse, quietly deciding your product is broken and never telling you.

The happy path is table stakes. The other three states are where craft shows up.

reactuxerror-handlingcode-reviewcomponents
Written by
Jim
Senior Frontend Engineer · Firetrail review team
More Frontend