Loading, Error, Empty — The States You Did Not Render
An interaction has at least four states and usually five. The missing failure branch is the most common defect in frontend code, and empty is not loading.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
How many states does a piece of remote data actually have, and which of them is my interface silently missing?
A person clicks something and wants to know one of three things at every moment: is it working, did it work, or did it not. An interface that answers none of those leaves them clicking again.
A boolean. isLoading is true while the request is out and false when it comes back; render a spinner in one case and the data in the other. It reads well and it matches how the request feels while you are writing it.
The boolean has no room for failure. When the request rejects, isLoading becomes false and data stays empty, so the screen renders the success branch over nothing — usually a blank panel with no explanation.
- The boolean has no room for failure. When the request rejects,
isLoadingbecomes false anddatastays empty, so the screen renders the success branch over nothing — usually a blank panel with no explanation. - It cannot distinguish "we have not asked yet" from "we asked and there is nothing". A filter that matches no rows and a request that has not started look identical, and one of them needs a message about the filter.
- It cannot express stale: showing the data you already have while a background refresh runs. With a boolean you must either hide the data behind a spinner or hide the fact that a refresh is happening (Stale-While-Revalidate).
- Two independent booleans —
isLoadingandhasError— permit states that cannot exist.loading && errorrenders both, and nobody wrote what that should look like. - Nothing here is announced. Every state change in this list is visual only, so a screen-reader user experiences a page that silently changes underneath them (Live Regions and Announcement).
What is actually happening
In the browser, not in the framework.
- A remote value is a state machine, not a variable.
idle → pending → success | error, withsuccess → pendingfor a refresh and astaleflag that says the displayed data is real but no longer known to be current. - Empty is a success, not a third loading state. The request completed, the answer was zero rows, and that answer is information the user needs — often about the filter they applied rather than about the data (Filtering: An Allowlist With an Index Bill in API Design).
- The machine has one input the naive version lacks: the *reason* for the error. Offline, unauthorised, forbidden, not found, rate-limited and server-broken are six different messages and three different recovery actions (The Error Model: Structure Over Apology in API Design).
- Rendering a skeleton is a claim about the shape of the answer. If the skeleton shows six rows and the answer has one, the layout moves after the data arrives, and the user watched a lie for a second (Visual Stability).
- Every transition in the machine is an event a screen reader can be told about — but only through a live region that already existed in the DOM before the change happened.
What this makes the browser do
And which of it is avoidable.
- Skeletons are real DOM. Fifty skeleton rows are fifty elements to build, style, lay out and paint — then throw away and do again with the real data (What a Mutation Costs).
- A shimmer animation that animates
background-positionrepaints on every frame for the whole duration of the request; one that animatestransformon a masked layer usually does not (Cheap and Expensive Animation). - Swapping a skeleton for content of a different height forces layout for everything below it, which is why the reserved space needs to be the size of the answer rather than a pleasant default (Visual Stability).
- Avoidable: rebuilding the entire region on every state change. Keeping a stable container and swapping only its contents preserves scroll position and the accessibility tree around it.
Four states, and the fifth you have already shipped
Written out as a table, the gap in the boolean version is immediate. Each row is a distinct thing the user needs to know, a distinct thing assistive technology needs to be told, and a distinct bug that appears when the row is missing.
The stale row is the one that surprises people. It is not a loading state and not an error: it is real data, previously correct, now being re-checked. Every application with a background refresh has it whether or not anyone modelled it, and modelling it is what lets you keep content on screen during a refetch instead of blanking the page (Stale-While-Revalidate).
- One
statusfield, not three booleans — the impossible combinations should be unrepresentable, not merely unlikely. errorcarries a *classification*, not just a flag. Offline, unauthorised, forbidden, not found, rate-limited and server-error are different messages and different recovery actions.emptybelongs tosuccess. It means the request worked and the answer was nothing.
| State | What the user must see | What must be announced | The bug when it is missing |
|---|---|---|---|
| idle | The control that starts it, and nothing pretending to be data | Nothing — silence is correct here | An empty panel on first paint that looks like a failed load |
| pending | A busy indicator with a name, after a short delay | "Loading orders" via role="status" | The user clicks again, and now two requests are in flight |
| success | The data, in a stable container | Optionally the result count — "12 orders" | None; this is the branch that always works |
| empty | A sentence saying what is absent and why | "No orders match this filter" | Indistinguishable from a broken load; the user blames the app for their own filter |
| error | The reason, and an action that could fix it | The message via role="alert", with focus placed usefully | A blank screen, or a render that throws reading a property of undefined |
| stale | The previous data, plus a quiet refreshing hint | Nothing, unless the content actually changed | Content vanishes behind a skeleton on every poll, and the reading position is lost |
Announced, not just rendered
A spinner is a visual convention. It carries no name, no role and no text, so for a screen-reader user the page simply goes quiet and then, some seconds later, contains different things. An error rendered as a red border is worse: it is a change with no announcement and no accessible description, and the user has no way to know a change happened at all.
The fix is small and specific. A container with role="status" that already exists in the DOM, whose *text* changes as the machine moves. The region must pre-exist — a live region created at the same moment as its content is unreliably announced, which is the single most common reason a correct-looking implementation is silent.
semantics A stable container that is present from first render. Inside it, a role="status" element for progress and result counts and a role="alert" element for failures. The data itself keeps its own semantics — a table, a list — and is never wrapped in a role that describes the request rather than the content.
| Tab | Reaches the retry or the recovery action when the region is in error. Nothing in pending should steal focus, and nothing focusable should be removed while it holds focus. |
| Enter / Space | Activates retry from a real button, which is why it must be a button and not a clickable div (Semantics Are Behaviour). |
| Shift + Tab | Returns to the control that triggered the request. It must still exist — replacing the whole region on failure destroys the user's place in the page. |
- — Never remove the focused element to show a spinner. If the trigger must become busy, keep it focusable and describe the state rather than disabling it out of the tab order.
- — On a failure that followed a deliberate submission, move focus to the error message or to the recovery action. On a failure of a background refresh, do not move focus at all — the user did not ask for anything.
- — After a successful load that replaces the region's contents, leave focus where it was. Moving it "to the new content" interrupts anyone who was already somewhere.
- —
pending: a short, specific phrase — "Loading orders" — not "Loading". - —
success: nothing, or a result count if the count is what the user was after. Announcing every successful refresh of a polling dashboard is a form of harassment. - —
empty: the reason, including the filter if a filter caused it. - —
error: the classification in plain language plus the action available, throughrole="alert".
usually broken by The pattern invites two failures that both look correct in the markup. The first is creating the live region and its message in the same update, so there is no change *inside* an existing region for assistive technology to observe and nothing is announced. The second is setting aria-busy="true" on the container while loading and never clearing it, which suppresses the announcement of the result as well — a page that is permanently, silently busy (Live Regions and Announcement).
Skeletons that lie
A skeleton is a promise about the answer: this many rows, this tall, in this arrangement. When the promise is kept it is genuinely better than a spinner, because the layout is already correct when the data lands and nothing moves. When it is not kept, it is a layout shift dressed as a courtesy — and the user was reading the position where content was about to appear.
The honest version reserves space for the *container*, not for imaginary content, whenever the shape of the answer is unknown. It also refuses to promise a count it does not have: six shimmering rows followed by "No orders yet" is the worst outcome in this lesson, because it shows a person their data and then takes it away.
{isLoading && (
<>
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="skeleton-row" /> // 6 rows, always
))}
</>
)}
{!isLoading && <OrderList orders={orders} />}
// answer has 1 row -> everything below jumps up five rows
// answer has 0 rows -> the user was shown six orders that do not exist
// answer is an error -> nothing at all is rendered<div className="orders" style={{ minBlockSize: 'var(--orders-reserved)' }}>
{status === 'pending' && (
<p role="status">Loading orders…</p>
)}
{status === 'success' && orders.length > 0 && <OrderList orders={orders} />}
{status === 'success' && orders.length === 0 && (
<p>No orders match this filter. <button onClick={clearFilters}>Clear filters</button></p>
)}
{status === 'error' && (
<p role="alert">{message(error)} <button onClick={retry}>Try again</button></p>
)}
</div>
// the container reserves space; the contents never promise a countThe reserved container removes the layout shift without asserting anything false about the answer, and all four outcomes render inside one stable element — so the live region survives every transition and the surrounding page geometry never moves. The guessing version optimises the one case where the guess is right and regresses the other three (Visual Stability).
How to build it
Most important first.
- Make the states a single discriminated value, so the impossible combinations cannot be represented. One
statusfield beats three booleans for exactly the same reason a tagged union beats a bag of optional fields. - Write the error branch first, before the success branch. It is the branch that will not otherwise be written, and writing it first forces the question of what the user can actually *do* about each failure.
- Distinguish empty from loading from error visually and in text. "No orders yet" and "Could not load orders" and a spinner are three different screens, and a user who cannot tell them apart cannot tell you what went wrong.
- Give every failure a recovery action in reach: a retry button for a transient failure, a sign-in link for an expired session, a "clear filters" for an empty result caused by the filter (Retries, and the Duplicate Order).
- Prefer showing stale data with a quiet refreshing indicator over replacing it with a skeleton. A person mid-sentence in a table does not want their content to vanish because a poll fired (Stale-While-Revalidate).
- Announce transitions through a live region that is present in the DOM from the start, and reserve layout space so the announcement does not arrive with a jump (Live Regions and Announcement).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- Loading must be announced.
role="status"(anaria-live="polite"region) containing the words "Loading orders" is announced when it appears; an animateddivwith no text is not announced at all. - Errors must be announced more assertively and must be reachable.
role="alert"interrupts; the message must also sit next to the control the user was operating, and focus should move to it or to the retry action when the failure was the result of a deliberate submission (Errors People Can Actually Perceive). - Empty is content, not absence. "No results for 'refund'" is announced; an empty container is silence indistinguishable from the loading state.
- Do not mark the container
aria-busy="true"and leave it that way. Assistive technology suppresses updates inside a busy region, so a forgotten flag means the successful result is never announced either. - A disabled retry button removes it from the tab order and from most screen-reader interaction. Prefer keeping it focusable and describing the busy state, so the user is never focused on nothing (Keyboard Operability).
What can go wrong
- The error state exists but is unreachable in practice — rendered inside a component that only mounts on success. The branch was written and can never run.
- A generic "Something went wrong" for all six error classes, which converts a solvable user problem (sign in again) into a support ticket.
- A retry button that re-runs the same request against the same expired session, forever, because the failure was never classified.
- Skeletons sized for a design mock rather than for real data, so the page reflows once per request and the reflow lands under the user's cursor (Visual Stability).
- An error toast that disappears on a timer. A screen-reader user, a user who looked away, and a user on a slow device may all miss it entirely.
- The mitigation failing: a live region added correctly, but the whole region is removed and re-added on each state change, so the browser never sees a change *inside* an existing region and announces nothing.
- A refetch triggered while an earlier one is still pending can resolve second, moving the machine from
successback throughpendingto a *stale* answer (Out-of-Order Responses). - An error from an abandoned request can arrive after a newer request has already succeeded, flipping a good screen into a failure state for a request nobody is waiting for (Cancelling a Request Nobody Is Waiting For).
- A live region announcement races the DOM change it describes: change the text and the surrounding structure in the same task, and screen readers vary in whether they announce the new text, the old text, or nothing.
- Error text goes to the user. Server messages that leak stack traces, internal hostnames or SQL fragments are a disclosure, and the client is the wrong place to sanitise them — but it is the place where you notice (Error Handling and Information Leakage in Security).
- Render error bodies as text. An error message inserted through an HTML sink is a script-injection path from anything that can influence the message (Cross-Site Scripting).
- Do not distinguish "no such account" from "wrong password" in the UI copy just because the API distinguished them. Enumeration is a real attack and the frontend is where it becomes visible.
- A failure that reveals whether a resource *exists* to a user who is not allowed to see it is an authorization leak. 404 and 403 are a product decision as much as a status decision (Authorization-Aware UI).
- "Loading and error are enough." Empty and stale are the two that get skipped, and both are common: most filters eventually match nothing and most data is eventually shown after it was fetched.
- "The error boundary catches it." A framework error boundary catches an exception thrown during render. A rejected promise inside an effect is not that: nothing catches it unless you write the catch yourself.
- "A red border communicates the error." It does not exist for a screen-reader user, it does not exist for many colour-blind users, and it does not say what to do next.
- "Skeletons are always better than spinners." A skeleton that does not match the answer is a layout shift with extra steps.
- "We can add the empty state when it comes up." It comes up on the first real customer with no data — which is every customer on their first day.
Measuring it, and what changes in the field
- Instrument the states, not just the requests: how often does each screen reach
error, and with what classification? A failure rate no one has ever plotted is a failure rate no one believes (Frontend Error Tracking). - Track the time spent in
pendingat the high percentiles from real users. The median is fine everywhere; the tail is where the spinner becomes an abandonment (Tail Latency: Why p50 Being Fine Does Not Help in Observability). - Use the accessibility inspector to confirm the live region exists and carries the role you think it does — the failure here is silent by construction (Accessibility Testing).
- On a fast connection,
pendingis over so quickly that a spinner is a flash of noise. On a slow one it is the whole experience, which is why a small delay before showing a spinner and a minimum display time once shown are both worth having. - On a slow device, the skeleton itself costs render time, so a heavy skeleton delays the content it is standing in for (List Virtualization).
- On an unreliable connection,
erroris not exceptional — it is a routine state that some users see several times a session, which changes how much design it deserves (Offline UX). - In a long-lived tab,
stalebecomes the dominant state: most of what a dashboard shows was fetched minutes ago and is displayed as though it were current (Long-Lived Clients and Version Skew).
- Five states cost more code than one boolean, and most of that code runs rarely. It is genuinely more to write, review and test — and it is the difference between an application that degrades and one that goes blank.
- Skeletons feel faster and constrain your layout: they only work when you know the shape of the answer in advance, and they make the empty state harder because the user has just been shown a picture of rows that do not exist.
- Delaying the spinner avoids flashing on fast connections at the cost of a short unresponsive-looking gap on slow ones. There is no setting that is right for both.
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALThe state machine and the requirement to announce transitions apply to every browser and every framework, because they follow from the fact that a request has more than two outcomes and that visual change alone is not perceivable by everyone.
- PLATFORM-SPECIFICLive-region behaviour is a property of the screen reader and operating system, not of the browser: NVDA, JAWS and VoiceOver differ in whether a region added at the same time as its content is announced, and in how they queue
politeagainstassertive. Test the combination, never one browser alone. - FRAMEWORK-SPECIFICData libraries expose this machine directly — a
statusplus anisFetchingflag for the stale case — while a hand-rolled effect gives you nothing and a server-component render gives you a different set of boundaries entirely (Route Loading Boundaries). The states are the same; who owns them is not.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Testing & Reliability Engineering — the failure branch is the branch no manual test exercises, which is why fault injection at the network layer belongs in the test suite rather than in a checklist.