Skip to the content.

Design your loading and empty states before your happy path

Design your loading and empty states before your happy path

Every component that fetches data has at least four states, not one. There is loading (the request is in flight), empty (it succeeded but there is nothing to show), error (it failed), and loaded (the happy path everyone designs). The mock in Figma shows the loaded state; the other three are where real apps feel broken — a spinner that never resolves, a blank screen that looks like a bug when the list is simply empty, a silent failure that leaves the user staring at nothing. Designing all four up front is the difference between an app that feels finished and one that feels finished only when the network is fast and the data is present.

The four states of a data component: loading, empty, error, loaded Four panels side by side — a skeleton for loading, a friendly message for empty, a retry prompt for error, and the real content for loaded — showing all four must be designed. loading emptyNo results yet errorFailed —Retry loaded design all four — the middle two are where apps feel broken
The mock is the rightmost panel. The three to its left — loading, empty, error — are the states that decide whether the app feels solid or fragile.

Model the four states, then render each

The cleanest way to guarantee you handle all four is to make status a single value (the request lifecycle from another post) and branch on it exhaustively, so the compiler and your own eyes catch a missing case:

function Results({ status, items }) {
  switch (status) {
    case "loading": return <SkeletonList />;                 // structure, not a bare spinner
    case "error":   return <ErrorState onRetry={reload} />;  // a way OUT of the failure
    case "success":
      return items.length
        ? <List items={items} />
        : <EmptyState message="No results — try a broader search" />;  // empty ≠ error
    default: return null;
  }
}

The items.length check is the state people forget: a successful, empty response is not an error and not a bug — it needs its own friendly, actionable message.

Each non-happy state has a job

These states are not just “not the content” — each one has work to do. Loading should preserve layout (a skeleton, so nothing jumps when data lands) and only appear after a beat, so a fast response does not flash a spinner. Empty should explain why it is empty and offer the next step (“No orders yet — place your first”). Error must give a way forward — a retry button, a support link — never a dead end:

function EmptyState({ message, action }) {
  return (
    <div className="empty">
      <p>{message}</p>
      {action && <Button onClick={action.onClick}>{action.label}</Button>}
    </div>
  );
}

Designing them first changes the component

The practical reason to design the four states before the happy path is that it changes the component’s API. If you only think about “loaded,” you write a component that takes items and breaks when items is undefined during loading. If you design all four first, you naturally give it status, an error, and an empty message — and it is robust from the start rather than patched later under a bug report. This is also where the request/success/fail triple and the four-state UI meet: the triple produces the states, and this design work spends them. Handle all four deliberately and your app stops having a “works on my fast connection with seeded data” quality. The skeleton-list and toast-notifications exercises build the loading and error states specifically, because those are the two most often skipped until a user hits them.