Designing every state: loading, empty, error (and the ones teams forget)
A practical guide to designing loading, empty, error, denied and session-expired states in React, with accessible patterns, code examples and a checklist.
By The M1rror team8 min read
Most screens are designed once, in their happiest moment: the data has loaded, there are exactly six tidy rows, and every request succeeded. Users spend a surprising amount of time somewhere else. They wait for data, start with nothing, hit a network failure, open a page they aren't allowed to see, or come back after lunch to an expired session. Those moments decide whether an app feels solid or fragile. This guide walks through each state, what good looks like, how to build it accessibly in React, and how to make sure none get skipped.
Start with a state inventory
Before designing anything, list the states every data-driven screen can be in. A practical baseline:
- Default: the data is here and the user can act.
- Loading: the first fetch, and later refreshes.
- Empty: there's nothing yet, nothing matches a filter, or the user cleared everything.
- Error: the page failed, part of it failed, or a form input is invalid.
- Denied: the user is signed in but isn't allowed to see or do this.
- Session expired: the user was allowed a moment ago and now needs to sign in again.
Then add the edge cases that bite in production: very long names, a single item, hundreds of items, slow connections, and partial success where some requests return and others don't.
Make the inventory a table per screen and fill in every cell, even if the answer is "same as the list page" or "not needed here because…". An unfilled cell is how a blank white rectangle reaches production.
Model the states in code, not in booleans
Many state bugs come from tracking isLoading, isError and data as separate flags, which allows impossible combinations such as loading and errored at once. A discriminated union makes the states explicit and forces every one to be handled:
type ViewState<T> =
| { kind: 'loading' }
| { kind: 'empty'; reason: 'first-use' | 'no-results' }
| { kind: 'error'; message: string; retry: () => void }
| { kind: 'denied' }
| { kind: 'session-expired' }
| { kind: 'ready'; data: T };
function ProjectList({ state }: { state: ViewState<Project[]> }) {
switch (state.kind) {
case 'loading':
return <ProjectListSkeleton />;
case 'empty':
return state.reason === 'first-use' ? <FirstProjectPrompt /> : <NoMatches />;
case 'error':
return <LoadError message={state.message} onRetry={state.retry} />;
case 'denied':
return <NoAccess />;
case 'session-expired':
return <SignInAgain />;
case 'ready':
return <ProjectTable projects={state.data} />;
}
}With TypeScript's exhaustiveness checking, adding a new state means the compiler points at every component that doesn't handle it yet.
Loading: show progress, hold the layout
Match the indicator to the wait
Nielsen Norman Group's article on progress indicators recommends showing an indicator for anything that takes longer than about a second, a looped indicator such as a spinner for waits of roughly 2–10 seconds, and a percent-done indicator for processes of 10 seconds or more. Their example of an exception is worth stealing: a step count like "Updating address 3 of 50" works even for shorter tasks.
In practice that means:
- Don't flash a spinner for fast requests. Delay showing it by a few hundred milliseconds so quick responses don't flicker.
- Use a skeleton or spinner for typical page loads.
- Use a real progress bar, with a step count or percentage, for imports, exports and generation jobs.
Reserve space so nothing jumps
Content that pops in and pushes everything down is a layout shift. web.dev's guide to Cumulative Layout Shift says a good score is 0.1 or less and, for user-initiated requests that take time, recommends creating space right away and showing a loading indicator so content doesn't jump when the response arrives. Skeletons do exactly that when they're the same size as the real content.
.skeleton-row {
block-size: 3.5rem; /* match the real row height */
border-radius: var(--radius-md);
background: var(--color-surface-muted);
animation: pulse 1.6s ease-in-out infinite;
}
@keyframes pulse {
50% { opacity: 0.55; }
}
@media (prefers-reduced-motion: reduce) {
.skeleton-row { animation: none; }
}The prefers-reduced-motion media query detects whether the user has asked their device to minimise non-essential motion, as MDN explains. A static skeleton still communicates "loading" perfectly well.
Tell assistive technology too
A skeleton is invisible to a screen reader. WCAG 2.2 success criterion 4.1.3, Status Messages (Level AA), covers this: status messages, including busy indicators, must be exposed to assistive technology without moving focus. Two tools help:
aria-busy="true"on the region being updated tells assistive technology the content isn't finished, so it can wait before announcing changes (MDN on aria-busy).- A
role="status"element is a polite live region; MDN notes it has an implicitaria-live="polite"and should not receive focus when it updates (MDN on the status role).
<section aria-labelledby="projects-heading" aria-busy={isLoading}>
<h2 id="projects-heading">Projects</h2>
<p role="status" className="sr-only">
{isLoading ? 'Loading projects…' : `${count} projects loaded`}
</p>
{isLoading ? <ProjectListSkeleton /> : <ProjectTable projects={projects} />}
</section>Keep the status element mounted all the time and change its text. Live regions that appear at the same moment as their content are often not announced.
Empty: three different situations
"Empty" usually covers at least three cases, and they need different designs.
First use
The user has never created anything. This is onboarding real estate. NN/g's article on empty states makes three points that map directly onto design: use the empty state to communicate system status, to provide learning cues about what the feature does, and to provide a direct pathway to the key task. A good first-use state has one sentence of explanation and one primary button.
No results
The user searched or filtered and nothing matched. Don't reuse the onboarding message here; it reads as if their data has vanished. Say what was searched, and offer to clear filters or broaden the search.
Cleared
The user finished everything, such as an inbox at zero or a task list completed. This deserves a calm confirmation, not a call to create more.
One more rule from the NN/g article: never show "No records" while data is still loading. An empty state that flashes before the real content arrives teaches users not to trust it. The union type above prevents this because empty and loading can't both be true.
Error: say what happened and what to do
NN/g's error-message guidelines are a solid checklist: place the message close to its source, use noticeable, redundant and accessible indicators, write in plain language, describe the issue precisely, offer constructive advice, don't blame the user, and preserve the user's input.
Two WCAG criteria turn some of that into requirements:
- 3.3.1 Error Identification (Level A): when an input error is detected automatically, identify the item in error and describe it in text.
- 1.4.1 Use of Color (Level A): colour must not be the only visual means of conveying information. A red border alone isn't enough; add an icon and a message.
Think about scope, too. A failed sidebar widget shouldn't take down the whole page. Wrap independent regions in their own error boundary or query state so the rest of the screen keeps working, and give each failure its own retry.
function LoadError({ message, onRetry }: { message: string; onRetry: () => void }) {
return (
<div className="state state--error">
<AlertIcon aria-hidden="true" />
<p>
<strong>We couldn't load your projects.</strong> {message}
</p>
<Button onClick={onRetry}>Try again</Button>
</div>
);
}Never paste a raw stack trace or provider error into the UI. Log the details and show the user something they can act on.
The states teams forget
Denied
A signed-in user without permission is a different situation from a missing page. Tell them they don't have access, and if it's appropriate, who can grant it or how to request it. Don't show a disabled copy of the full screen; it invites people to try every greyed-out button.
There's one exception. If revealing that a resource exists would leak information, for example another organisation's project, show the same "not found" page you'd show for a bad URL.
Session expired
This is the state that loses people's work. WCAG 2.2 has an AAA criterion, 2.2.5 Re-authenticating, which says that when an authenticated session expires, the user can continue the activity without loss of data after re-authenticating. Even if you aren't targeting AAA, it's the right goal:
- Detect the expired session at the request that fails, not by redirecting the whole app.
- Keep unsaved form state in memory or local storage.
- Sign the user back in, ideally in place, and replay or restore what they were doing.
Partial and stale
Dashboards often load some panels and fail others, or show cached data while refreshing. Label stale data ("Updated 10 minutes ago") and show which panel failed rather than blanking all of them.
Make missing states visible in your workflow
The hardest part isn't designing these states. It's noticing when one hasn't been designed. A few habits help:
- Add every state to your component stories or test fixtures so they can be reviewed side by side.
- Review states in design review, not just the default screen.
- Check new code for data-fetching components that only render the success path.
This is the part M1rror is built around. In its Screens mode, each screen carries the states Default, Loading, Empty, Error, Denied and Session expired at phone, tablet and desktop sizes. A state that isn't designed says so, and you can start it from Default, design it, ask the agent to design it, or mark it "Not needed on this screen" with a written rationale. On the code side, its pull request design checks flag missing states, alongside off-theme colours and off-scale spacing, on added lines. See design a screen and its states and pull request checks.
A checklist for every data-driven screen
- Default designed with realistic content, including long and short values
- Loading: skeleton or spinner sized to the real content, delayed for fast responses
- Loading:
aria-busyon the region and a persistentrole="status"message - Loading: animation respects
prefers-reduced-motion - Empty: first-use, no-results and cleared cases designed separately
- Empty: never shown while loading
- Error: message near the source, in plain language, with a next step
- Error: icon and text, not colour alone; user input preserved
- Error: independent regions fail independently
- Denied: explains access and how to request it, or shows not found where existence is sensitive
- Session expired: user can sign back in without losing work
- Any skipped state has a written reason
Wrapping up
Designing every state isn't extra polish. It's most of what users actually experience between the screenshots. Start with an inventory, model the states explicitly in code, borrow the well-tested guidance on progress, empty states and errors, and give denied and session-expired screens the same care as the default. If your states look inconsistent with each other, design tokens are the quickest way to make them share one visual language.