Lesson 15 / 22
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.
Contents
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.
// 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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.