Skip to main content
ANVISoftware Solutions
Lesson 10 of 18Intermediate15 min

Loading and Error States

By the end of this lesson

Design for slow and failed requests as first-class states.

A screen that loads data has four outcomes, not one. It can be waiting, it can have failed, it can have succeeded with nothing to show, and it can have succeeded with data.

All four are part of the design. The reason so many applications handle them badly is that the fourth is the only one anyone thinks about while building, and the others get bolted on after someone reports a blank page.

One distinction is worth stating plainly, because getting it wrong is common: an empty list is not an error. A filter that matches no employees is the system working correctly. Showing a red failure message for it tells the user something is broken when nothing is.

Modelling the states so impossible combinations cannot happen
TypeScript
// Three separate flags allow states that make no sense:
// isLoading true and error set, or data present while still loading.
interface LooseState {
  isLoading: boolean;
  error: string | null;
  employees: Employee[];
}

// One value with four shapes. Anything not listed here cannot occur.
type DirectoryState =
  | { status: "loading" }
  | { status: "error"; message: string }
  | { status: "empty" }
  | { status: "ready"; employees: Employee[] };
  • The loose version has eight combinations of its two flags and its array, and only four of them mean anything. The other four are bugs waiting for a code path that produces them.
  • The union gives each state its own shape, and each shape carries exactly the data that state needs. There is no message when you are loading and no employees when you have failed, because those fields do not exist there.
  • TypeScript narrows by the status field, so inside a check for status === "ready" the employees array is known to exist. Reading it in the error branch does not compile — the compiler enforces the design.
  • Whether "empty" deserves its own status or is derived from a ready state with a zero-length array is a fair question. Keeping it separate makes the four branches explicit in the type and stops anyone forgetting it; deriving it is less code. Either way, handle it.
EmployeeDirectory.tsx — all four branches, none of them an afterthought
TSX
"use client";

import { useEffect, useState } from "react";

export function EmployeeDirectory({ department }: { department: string }) {
  const [state, setState] = useState<DirectoryState>({ status: "loading" });
  const [attempt, setAttempt] = useState(0);

  useEffect(() => {
    const controller = new AbortController();
    let ignore = false;

    setState({ status: "loading" });

    fetch("/api/employees?department=" + encodeURIComponent(department), {
      signal: controller.signal,
    })
      .then((response) => {
        if (!response.ok) throw new Error(String(response.status));
        return response.json() as Promise<Employee[]>;
      })
      .then((employees) => {
        if (ignore) return;
        setState(
          employees.length === 0
            ? { status: "empty" }
            : { status: "ready", employees }
        );
      })
      .catch((caught: Error) => {
        if (ignore || caught.name === "AbortError") return;
        setState({ status: "error", message: "We could not load the directory." });
      });

    return () => {
      ignore = true;
      controller.abort();
    };
  }, [department, attempt]);

  return (
    <div aria-live="polite" aria-busy={state.status === "loading"}>
      {state.status === "loading" && <EmployeeTableSkeleton rows={5} />}

      {state.status === "error" && (
        <div role="alert">
          <p>{state.message}</p>
          <button type="button" onClick={() => setAttempt((count) => count + 1)}>
            Try again
          </button>
        </div>
      )}

      {state.status === "empty" && (
        <p>No employees in {department} yet. Add one to get started.</p>
      )}

      {state.status === "ready" && <EmployeeTable employees={state.employees} />}
    </div>
  );
}
  • Every branch is written out, so there is no path through this component that renders nothing. A blank screen is the usual symptom of a missing branch.
  • The retry works by incrementing a counter that is in the effect's dependencies. That makes retrying a re-run of the same synchronisation rather than a separate code path, so it cannot drift from the original request.
  • aria-live="polite" on the container means a screen reader announces the content when it changes, which is how someone who cannot see the skeleton learns that results have arrived. aria-busy says the region is still working.
  • role="alert" on the error makes that announcement immediate rather than polite, which is appropriate for a failure the user has to act on.
  • The error message is written for the person reading it. It does not include the status code — that belongs in a log, where it helps, not on screen, where it does not.
  • The empty state suggests the next action. "No results" is a dead end; "add one to get started" is a way forward.

Two ways to show that something is loading. They are not interchangeable:

 SpinnerSkeleton
What it communicatesSomething is happening, of unknown shapeContent of this shape is arriving here
LayoutUsually collapses the space, so the page jumps when content landsReserves the space, so the page does not move
Best forShort, self-contained waits: a button submitting, a small panelA list, a table, a card grid — anywhere the result has a predictable shape
RiskLooks broken if the wait is long, and offers no sense of progressMisleading if the real content is a different shape, and fiddly to maintain as the layout changes
Accessibility noteNeeds accompanying text, since a spinning graphic announces nothingNeeds the same: mark it aria-hidden and put the status in a live region

Details that separate a considered set of states from a bolted-on one:

  • Do not flash a loader for a request that usually takes 80ms. Delay showing it by a couple of hundred milliseconds, so fast responses never produce a flicker
  • Keep the previous results on screen while refreshing, with a subtle busy indicator, rather than emptying the list and rebuilding it
  • Give the error message an action: retry, adjust the filters, or contact someone. A message with no next step leaves the user stuck
  • Distinguish "we could not reach the server" from "you do not have access to this", because the second is not worth retrying
  • Reserve the space the content will occupy, so the page does not jump as things arrive — a moving layout causes mis-clicks
  • Log the technical detail and show the human version. The status code, the URL and the request id belong in your logs
  • Check the four states in a test, not just the happy path. They are easy to break and nobody notices until a user does

Summary

  • Loading, error, empty and ready are four states, and all four are design work
  • An empty result is success, not failure — say what it means and what to do next
  • Modelling the states as one union prevents combinations that cannot make sense
  • Skeletons reserve layout and suit predictable content; spinners suit short, contained waits
  • Announce changes with a live region, show a human error message, and log the technical one

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Try it yourself

Try it yourself

Take the lesson list on a course page and give it all four states. Include a retry on the error branch and a useful sentence on the empty one.

Then force each state deliberately: point the request at a URL that does not exist, at one that returns an empty array, and at one that never resolves.

Show solution

Forcing the states is the part that matters. Every one of these branches is code that runs rarely, and code that runs rarely is where bugs live — the error branch that references a field the error state does not have will only be found when you make it render.

Deriving isEmpty from the ready state keeps the union smaller, and works here because a lesson list has no meaningful difference between "loaded nothing" and "empty". A search screen might want them separate, since "no lessons yet" and "no lessons match your filter" call for different sentences.

The empty message names the thing that is missing and what to do about it. Compare "No results" — technically accurate, and no help to someone wondering whether they filtered too hard or the course is genuinely new.

TSX
type LessonListState =
  | { status: "loading" }
  | { status: "error"; message: string }
  | { status: "ready"; lessons: Lesson[] };

function LessonListView({ state, onRetry }: { state: LessonListState; onRetry: () => void }) {
  if (state.status === "loading") {
    return <LessonListSkeleton rows={4} />;
  }

  if (state.status === "error") {
    return (
      <div role="alert">
        <p>{state.message}</p>
        <button type="button" onClick={onRetry}>
          Try again
        </button>
      </div>
    );
  }

  if (state.lessons.length === 0) {
    return <p>No lessons are published in this module yet. Check the course outline for what is planned.</p>;
  }

  return (
    <ul>
      {state.lessons.map((lesson) => (
        <li key={lesson.slug}>{lesson.title}</li>
      ))}
    </ul>
  );
}

Think about it

Think about it

A directory refreshes every 30 seconds. Should the refresh show the loading state?

Consider someone halfway through reading row twelve when the refresh happens.

Show solution

No. Replacing the list with a skeleton every 30 seconds destroys what the user is reading, jumps the page, and makes an application that is working correctly feel unstable.

The useful distinction is between a first load and a refresh. A first load has nothing to show, so a skeleton is the honest representation. A refresh already has content, so keep it on screen and indicate the update quietly — aria-busy on the region, perhaps a small timestamp or a subtle highlight on rows that changed.

That means the loading state is not one state after all. "Loading with nothing" and "loading with previous results" call for different treatment, which is a good argument for a status union that can say so: a ready state with a refreshing flag, for instance.

There is a real trade-off. Keeping stale content visible means the user may act on data that is a few seconds out of date. For a directory that is fine. For a stock price or a seat availability screen, it is not, and there the interruption is the lesser cost.

Saved in this browser only.