---
title: 'Loading and Error States'
source: 'https://academia.sh/en/courses/frontend-architecture/loading-and-error-states'
course: 'Application Architecture: Routing, State and Data'
language: en
updated: '2026-08-17T18:11:05+00:00'
license: 'CC BY-SA 4.0'
---

# Loading and Error States

Reducing the states a view can be in while it waits for data to a finite set — the contradictions logical flags produce, the transition table, filtering race conditions with a stamp, and each state's counterpart on screen.

The previous three lessons built the ways of getting data. What they share is that data
is, for a while, not yet there. The measurement history screen cannot sit empty in the
meantime: it has to show something. What gets shown depends on **which state the screen
is in at that moment** — is it loading for the first time, does it have stale data, did
the request fail, did the result come back empty.

This lesson reduces those states to a finite set. The reduction is not a matter of
tidiness: as shown below, tracking the states as separate logical flags produces
combinations that contradict each other, and those combinations show up on screen as
real defects.

## The Contradiction Logical Flags Produce

The most commonly used starting point is three flags: `loading`, `error`, `data`. Three
booleans have eight combinations. Only four of them are meaningful.

- Loading true, data empty, no error — initial load. Meaningful.
- Loading false, data present, no error — ready. Meaningful.
- Loading false, data empty, error present — failed. Meaningful.
- Loading true, data present, no error — refreshing. Meaningful.
- Loading true, error present — a request is in flight but an error is shown. A
  spinning loading indicator and an error box sit side by side on screen.
- Loading false, data empty, no error — says nothing. The screen is empty; the user
  cannot tell whether the request has not started or has finished with no results.
- Data present, error present — which one gets shown? This decision gets made again in
  every component, and made differently.

The flags' problem is that **they can change independently of each other**. A single
line that forgets to clear the old error when a new request starts produces the fifth
combination. The defect is invisible at compile time and only surfaces during a specific
sequence of clicks.

The fix is replacing the three flags with a single field: the view is in exactly one
state at a time, and only defined transitions exist between states. An invalid
combination becomes unrepresentable this way.

## Seven States and a Transition Table

The states a data-fetching screen needs are: **empty** (not yet requested), **loading**
(waiting with no data yet), **ready** (data present), **empty result** (the request
succeeded and the result set is empty), **refreshing** (refreshing while data is already
present), **error** (no data present, the request failed), and **stale** (old data
present, the refresh failed).

Separating the last two states is the core of this design. Turning the screen into an
error box when a refresh fails while there is data to show means taking away information
the user already has. The stale state keeps the old data on screen and reports the
refresh failure with a separate notice.

```js
// view-state.mjs — view state machine and transition table
const STATES = ["empty", "loading", "ready", "emptyResult", "refreshing", "error", "stale"];
const EVENTS = ["request", "gotData", "gotEmpty", "failed", "cancel"];

// Data-dependent targets are written as functions.
const readyIfNonEmpty = (u) => (u.data.length > 0 ? "ready" : "emptyResult");

const TABLE = {
  empty:       { request: "loading" },
  loading:     { gotData: "ready", gotEmpty: "emptyResult", failed: "error", cancel: "empty" },
  ready:       { request: "refreshing" },
  emptyResult: { request: "refreshing" },
  refreshing:  { gotData: "ready", gotEmpty: "emptyResult", failed: "stale", cancel: readyIfNonEmpty },
  error:       { request: "loading" },
  stale:       { request: "refreshing" },
};

function transition(unit, event, payload = null) {
  const target = TABLE[unit.state]?.[event];
  if (target === undefined) return { ...unit, ignored: unit.ignored + 1 };
  const next = { ...unit };
  if (event === "gotData") next.data = payload;
  if (event === "gotEmpty") next.data = [];
  if (event === "failed") next.error = payload;
  if (event === "gotData" || event === "gotEmpty") next.error = null;
  next.state = typeof target === "function" ? target(next) : target;
  return next;
}

// --- Printing the transition table -----------------------------------------
const cell = (s, e) => {
  const t = TABLE[s]?.[e];
  return t === undefined ? "·" : typeof t === "function" ? "data-dependent" : t;
};
console.log("state".padEnd(12) + EVENTS.map((e) => e.padEnd(16)).join(""));
for (const s of STATES) {
  console.log(s.padEnd(12) + EVENTS.map((e) => cell(s, e).padEnd(16)).join(""));
}

// --- Testing sequences -------------------------------------------------------
const MEASUREMENT = [{ id: "m-114", value: -4.2 }];
const SEQUENCES = [
  ["initial load succeeds",  [["request"], ["gotData", MEASUREMENT]]],
  ["initial load empty",     [["request"], ["gotEmpty"]]],
  ["initial load fails",     [["request"], ["failed", "network"]]],
  ["retry after failure",    [["request"], ["failed", "network"], ["request"], ["gotData", MEASUREMENT]]],
  ["refresh failure",        [["request"], ["gotData", MEASUREMENT], ["request"], ["failed", "server"]]],
  ["refresh canceled",       [["request"], ["gotData", MEASUREMENT], ["request"], ["cancel"]]],
  ["invalid order",          [["gotData", MEASUREMENT], ["cancel"], ["request"]]],
];

console.log("");
for (const [name, events] of SEQUENCES) {
  let unit = { state: "empty", data: [], error: null, ignored: 0 };
  const trace = ["empty"];
  for (const [event, payload] of events) {
    unit = transition(unit, event, payload);
    trace.push(unit.state);
  }
  console.log(
    name.padEnd(22), trace.join(" -> ").padEnd(46),
    `data=${unit.data.length} error=${unit.error} ignored=${unit.ignored}`);
}

// --- Race guard: a late response is ignored ---------------------------------
console.log("");
let unit = { state: "empty", data: [], error: null, ignored: 0 };
let stamp = 0;
const request = () => { stamp += 1; unit = transition(unit, "request"); return stamp; };
const respond = (ownStamp, event, payload) => {
  if (ownStamp !== stamp) { console.log(`  stamp ${ownStamp}: stale, ignored`); return; }
  unit = transition(unit, event, payload);
  console.log(`  stamp ${ownStamp}: applied -> ${unit.state}`);
};

const s1 = request();                       // search for "nor"
const s2 = request();                       // search for "north"
respond(s2, "gotData", [{ id: "m-114" }, { id: "m-113" }]);
respond(s1, "gotData", [{ id: "m-001" }]);   // late-arriving stale response
console.log("final state:", unit.state, "| records:", unit.data.length);
```

```
state       request         gotData         gotEmpty        failed          cancel
empty       loading         ·               ·               ·               ·
loading     ·               ready           emptyResult     error           empty
ready       refreshing      ·               ·               ·               ·
emptyResult refreshing      ·               ·               ·               ·
refreshing  ·               ready           emptyResult     stale           data-dependent
error       loading         ·               ·               ·               ·
stale       refreshing      ·               ·               ·               ·

initial load succeeds  empty -> loading -> ready                      data=1 error=null ignored=0
initial load empty     empty -> loading -> emptyResult                data=0 error=null ignored=0
initial load fails     empty -> loading -> error                      data=0 error=network ignored=0
retry after failure    empty -> loading -> error -> loading -> ready  data=1 error=null ignored=0
refresh failure        empty -> loading -> ready -> refreshing -> stale data=1 error=server ignored=0
refresh canceled       empty -> loading -> ready -> refreshing -> ready data=1 error=null ignored=0
invalid order          empty -> empty -> empty -> loading             data=0 error=null ignored=2

  stamp 2: applied -> ready
  stamp 1: stale, ignored
final state: ready | records: 2
```

## What the Table Shows

The dots in the table mark **undefined transitions**, and that is where the real
information is. The `failed` event cannot arrive while in the `ready` state, because
there is no request in flight while ready. This is not a rule explicitly forbidden
somewhere in the code; it is a cell the table leaves blank. The seventh sequence tests
exactly this: a response and a cancellation arriving without a request having been fired
were both ignored, and the counter shows two. In a flag-based implementation, those same
two events would have silently corrupted the state.

The fifth and sixth sequences show two different exits from the `refreshing` state. On
failure it moved to `stale` and kept `data=1` — the old measurement list stayed on
screen. On cancellation it returned to `ready` and the error field stayed empty; the
previous lesson's rule is at work here — cancellation is not an error.

The fourth sequence shows recovery after an error. Only `request` leaves the `error`
state; that is the counterpart of a "try again" button. Clearing the error field when a
new request starts happens in one single place inside the transition function, not at
every call site.

The state machine's side benefit is testability. The seven sequences above are a unit
test suite; the view's behavior can be verified without ever rendering the screen. This
separation is not possible with flags scattered through a component.

## Race Condition and the Stamp

The last section closes the view-side half of the problem left open in the REST Client
lesson. The user typed "nor" into the station filter, then "north"; two requests went
out. The second request was answered first, the first arrived late. If the late response
is applied, the screen shows "nor" results while the filter box reads "north."

The fix is a **request stamp**: every new request increments a counter and carries its
own stamp. When a response arrives, its stamp is compared with the currently valid
stamp; if they do not match, the response is not applied. The output's last three lines
show this: the second request was applied, the first was treated as stale, and two
records remained on screen.

A stamp does not replace canceling the request; the two are used together. Cancellation
frees the network resource but does not stop a request that cannot be canceled — one whose
response is already on its way, for example — from arriving late. The stamp is that
last line of defense.

The same mechanism can also be built per key: instead of a counter, "which filter this
was for" is stored, and an incoming response is dropped if it does not match its own key.
In server state management the cache key already does this job; the stamp provides the
same protection for screens that do not use a cache.

## Each State's Counterpart on Screen

Once the set of states is fixed, each state's visual counterpart is a separate decision.
The Interface Design Fundamentals course's Loading and Empty States and Error and
Warning States lessons set the criteria for these decisions; only the parts tied to the
state machine are noted here.

What gets shown in the **loading** state depends on the shape of the expected content.
For a list whose structure is known, a placeholder skeleton reserves the space the
incoming content will fill, and no shift occurs once the content arrives. For a result
whose structure is unknown, a neutral indicator is enough.

The **refreshing** state must not look the same as `loading`. Turning the screen into a
skeleton while data is already present takes away the content the user is reading. The
right presentation leaves the existing content in place and reports the refresh with a
small indicator off to the side.

**Empty result** and **error** call for separate messages. An empty result says the
system worked correctly and no record met the criteria; the recovery path is changing
the criteria. An error says the system could not respond; the recovery path is trying
again. Presenting the two in the same box steers the user toward the wrong action.

The **stale** state carries two pieces of information at once: the data on screen is
valid but may not be current. It is presented by leaving the data in place and adding a
strip that reports the refresh failed and when the last successful update happened. The
"try again" action lives on that strip.

Timing itself is also a decision. Showing the loading indicator when the response
arrives very fast produces a momentary flicker on screen; the indicator is delayed to
appear only after a short grace period. Once it has appeared, it also stays on screen for
at least a short minimum, or the same flicker happens on exit. Both thresholds sit
outside the state machine: the machine says `loading`, and the presentation layer
decides when to draw the indicator.

## Summary

- Tracking loading, error, and data as separate logical flags produces eight
  combinations; half are contradictory, and those contradictions show up on screen as
  real defects.
- A single-field state machine makes invalid combinations unrepresentable; the empty
  cells in the table mark undefined transitions, and an unexpected event is silently
  ignored.
- A refresh failing while data is present is a separate state; instead of turning the
  screen into an error box, the old data is kept and the refresh failure is reported
  separately.
- Empty result and error suggest different recovery paths: one is changing the
  criteria, the other is trying again.
- A request stamp filters out a late response; it does not replace cancellation, it
  works alongside it.
- When the loading indicator appears and how long it stays at minimum are decisions of
  the presentation layer; the state machine only says which state it is in.

## Next Step

The state machine treated the `failed` event as a single gate: an error arrived, the
state changed, the user was shown a "try again" button. But some errors should not be
shown to the user at all. A request cut off on a weak connection can succeed when
retried two hundred milliseconds later; the user sees nothing in between. Which errors
get retried automatically, how long to wait between attempts, and why that wait needs to
be randomized is the subject of the next lesson.
