skip to content

Loading, Error & Empty States

What the tree renders while a request is in flight or after it fails: a branch per state, or a subtree declaring it is not ready so one ancestor fallback covers it. Most of perceived speed lives here.

on this pageshow

questions

6

Which on-screen states must a component that loads data from a server render, besides the successful result?

level: juniorimportance: must knowfreq 78%

answer

  1. more than loading and loaded
  2. one branch per outcome
  3. empty is not a failure
  4. two flags, six situations
  5. one named status, not booleans

basics

~20 s

Besides the data, a remote-data component needs branches for idle, loading, failure, and a successful response that carried nothing. Each is a different sentence to the user; collapsing them into one blank region is the usual bug.

solid answer

~40 s

A request is a small state machine, so the component renders one branch per state rather than data-or-spinner. The states worth a branch are **idle** (nothing asked for yet, which only exists when the user triggers the request), **loading** with nothing to show yet, **ready** with data, **empty** — a perfectly good response that carried no items — and **error**. A sixth condition matters once anything is on screen: **refetching**, a request in flight while a previous value is still displayed, which should keep the content and hint quietly rather than fall back to the loading branch. Modelling this as one named status is safer than separate `loading` and `error` booleans, because two booleans describe four combinations and nothing stops the impossible ones being set.

code

pseudocode · 24 lines
pseudocode
state status = "idle"      # idle | loading | refetching | ready | empty | error | stale
state items  = none
state failure = none

action load(params):
    if items is none: status = "loading"     # first load only
    else:             status = "refetching"  # keep what is on screen
    result = request(params)
    if result.failed:
        failure = result.failure
        status  = if items is none then "error" else "stale"
    else:
        items  = result.items
        failure = none
        status = if items.count == 0 then "empty" else "ready"

render:
    when status is "idle"       -> prompt: what this will show, and how to start
    when status is "loading"    -> placeholder shaped like the coming content
    when status is "empty"      -> "nothing here yet" + the action that creates one
    when status is "error"      -> failure message in this region + retry trigger
    when status is "stale"      -> items + non-blocking failure notice + retry trigger
    when status is "refetching" -> items + a quiet in-progress hint
    when status is "ready"      -> items

go deeper

for a junior

Recall the list: nothing requested yet, in flight, succeeded with data, succeeded with nothing, failed. Then check your own component actually renders something different for each of them.

for a middle

Explain why three facts produce six situations, and why a derived status removes the impossible ones. Be able to say what each branch should reserve in layout and what it should offer the user.

for a senior

Show the judgment: which regions may be replaced by a placeholder, which must keep their previous value, and how failure is surfaced without taking down the surrounding screen. Point at the review habits that catch a missing branch.

for a principal

Frame it as a contract every data-backed surface in the app owes, and decide how much of it is enforced by shared components versus left to each team. The cost of no convention is inconsistent copy and screens that jump.

## A request is a state machine, not a boolean Server-owned data arrives after the first render, so a component that depends on it renders at least once without it. What it puts on screen in that gap — and in the gaps that come later — is not a detail of the request; it is most of what a user experiences as speed and reliability. The conditions that deserve their own rendered branch are: 1. **Idle** — nothing has been requested yet. This state only really exists when the request is triggered by the user: a search that runs on submit, a panel that loads when it is opened, a report that loads when a date range is chosen. A component that requests as soon as it mounts passes through idle too fast to be worth designing for. 2. **Loading, first time** — a request is in flight and there is no previous value at all. This is the state where a placeholder costs nothing, because there is nothing on screen to preserve. 3. **Ready** — a response arrived and it has something to show. 4. **Empty** — a response arrived, it was entirely successful, and it carried no items. Not a failure. Worth noting that emptiness is *derived* from the payload rather than reported by the request: the request has succeeded. It earns a branch because the user needs a different sentence, not because the transport did something new. 5. **Error** — no usable data: the request never reached the server, the server refused it, or the payload could not be understood. 6. **Refetching** — a request is in flight *while a previous value is still on screen*. Most screens spend more time here than in first-load, because of polling, revalidation, and parameter changes. ## The two-boolean trap Teams usually start with `loading` and `error` flags plus the data, then discover that the three of them encode more situations than the render code handles: | `loading` | `error` | value held | What the component should actually render | |---|---|---|---| | true | none | none | first-load placeholder in the shape of the coming content | | true | none | present | the previous value, plus a quiet in-progress hint | | false | set | none | error message, in this region, with a retry trigger | | false | set | present | the previous value, plus a non-blocking failure notice | | false | none | none | the empty branch — the response arrived with nothing | | false | none | present | the data | Two flags do not make two branches; three facts make six. Code written as `if (loading) … else if (error) … else render(data)` silently merges the last two rows, so a successful empty response renders as a bare region with no explanation, and a failure that leaves the flag set in only one path leaves a placeholder spinning forever. ## What each branch owes the user - **Idle** — say what will happen and how to start it, never a placeholder for unstarted work. - **Loading** — reserve roughly the space the content will occupy, so the page does not jump when it lands. Shaped placeholders do that better than a small centred indicator, at the cost of being wrong when the real content is a different size. - **Empty** — name the reason and offer the next step, which differs sharply between "you have not created anything yet" and "your filter excluded everything". - **Error** — describe the failure in the user's terms, keep it inside the region that failed, and offer a way to try again. Raw failure text and technical codes are not a message. - **Refetching** — keep the content, indicate progress subtly, and do not move layout. Replacing readable content with a placeholder on every refresh is experienced as the screen breaking. ## One status value instead of flags Deriving a single named status — and reading the branch from it — removes the impossible combinations by construction, and makes the empty case impossible to forget because it must be named. The plumbing differs by reactivity model: a runtime that re-runs the component function on every write recomputes the branch as part of that re-run; a fine-grained runtime recomputes only the derived status and swaps the branch; a compile-time runtime resolves the branch structure ahead of time and patches in place. The set of states, and the obligation to render each one, is identical in all three. ## Where the branches live A tree can express pendingness two ways: each component renders its own branch, or a subtree declares that it cannot render yet and one ancestor boundary renders a single fallback for all of it. That choice changes how many placeholders a user sees; it does not change the list above, because something still has to render empty and failed. ## How this shows up in review - A component with a spinner branch and a data branch, and nothing else. - An empty list rendered as a successful nothing, with no copy at all. - A failure handler that substitutes an empty collection, turning every failure into an empty state. - A refresh that blanks a region the user was reading.

  • Why is one named status usually safer than separate loading and error booleans?
    Two booleans describe four combinations, most of which are impossible, and nothing in the type prevents setting them. A single status can hold only one value at a time, so "loading and failed" cannot be represented, and the empty case has to be named rather than falling out of an else branch by accident.
  • Is the idle state worth modelling for a component that requests as soon as it appears?
    Rarely. If the request starts on mount, the first render is already the loading state and idle is unobservable. Idle earns its branch when the user triggers the request — a search before the first submit, a lazily opened panel — because something has to be on screen that is not a placeholder for work nobody started.
  • Should the loading branch always replace the content it is standing in for?
    Only when there is no content to keep. With a previous value on screen, replacing it is a downgrade: the user loses what they were reading and the layout moves twice. Keep the value, mark that a request is in flight, and reserve the placeholder for the case where the region is genuinely empty.

saying these in an interview costs you the question

  • Thinks a loading flag plus the data covers every case
  • Renders a successful empty response as a blank region with no message
  • Treats a response with no items as a failed request
  • Leaves a placeholder on screen forever because no branch handles failure
  • Keeps loading and error booleans that can both be true at once
  • Catches the failure and substitutes an empty collection
open as a page

How does rendering a pending state from each component's own flags differ from one ancestor fallback covering a subtree that declared itself not ready?

level: middleimportance: must knowfreq 62%

basics

~20 s

Per-component flags give each component its own placeholder: fine granularity, many indicators, coordination by hand. A subtree that declares itself not ready hands one ancestor fallback the whole region: one coherent placeholder, but everything inside waits for the slowest part.

open as a page

For a list fed by a server request, why do a first-run empty collection and a filtered zero-results view need different branches?

level: middleimportance: should knowfreq 56%

basics

~20 s

The payload is the same empty collection, but the cause and the next step differ. Nothing created yet needs the action that creates the first item; a filter that excluded everything needs widening or clearing. One shared message misleads one audience.

open as a page

When a data request fails, how do you decide between rendering an error branch in the component and letting the failure escalate to an ancestor handler?

level: seniorimportance: should knowfreq 58%

basics

~20 s

Decide by blast radius and by what can be retried. A local branch keeps the rest of the screen alive and a precise retry next to the component that issued the request. Escalate when the failure invalidates the whole region.

open as a page

A dashboard re-requests its data every 30 seconds and every panel blanks back to its loading placeholder on each poll — why, and what fixes it?

level: seniorimportance: should knowfreq 54%

basics

~20 s

Each panel branches on “a request is in flight” instead of “there is nothing to show”. With a previous value held, keep rendering it and mark progress quietly; reserve the placeholder for the load that has nothing to replace.

open as a page

As a lead, how would you set one convention for loading, error and empty states across many screens built by several teams?

level: principalimportance: nice to knowfreq 38%

basics

~20 s

Standardise the vocabulary and the obligations: the named states every data-backed surface must handle, the copy pattern and action each branch carries, the escalation policy. Leave placeholder shape and fallback granularity to the team that knows the screen.

open as a page