Loading, Empty, and Error States
Most UI work focuses on the screen with data in it. Users spend a surprising amount of time on every other version of that screen: waiting for the first load, looking at a list with nothing in it yet, staring at a failed request, using slightly old data while a refresh happens, or opening the app on a train with no signal. These states decide whether an app feels solid or broken, and they are where accessibility, layout stability, and error handling are most often neglected.
A reliable approach is to treat every data-backed view as a small state machine and design each state deliberately — before the happy path is finished, not after the bug reports arrive.
TL;DR
- Every data view has at least loading, empty, error, and success states — and often refreshing, partial, stale, and offline.
- Distinguish initial loading (no data yet) from background refreshing (data shown, update in progress).
- Prefer skeletons that match the final layout; delay spinners briefly to avoid flashes on fast responses.
- Write specific empty states: "no projects yet" (with a create action) differs from "no results for this filter".
- Make errors actionable: say what failed, keep what still works, and offer retry.
- Announce changes to assistive technology (
aria-busy, live regions) and reserve space to avoid layout shift.
Quick Example
A React list with TanStack Query that handles every state explicitly:
isPending is true only when there is no data at all; isFetching is true during any request, including background refreshes. Using the wrong one is how lists blank out every time a window regains focus.
Core Concepts
The States
Skeletons Versus Spinners
Skeletons — gray placeholders shaped like the content — reduce perceived wait and prevent layout shift because they reserve the final space. Spinners suit actions and small regions. For fast responses, delay any loading indicator by roughly 200–300 ms, so users on fast connections do not see a flash; once shown, keep it visible briefly to avoid flicker.
Suspense and Error Boundaries
In React, Suspense boundaries show fallbacks while children load, and error boundaries catch rendering and data errors for a subtree. Placing boundaries around independent regions lets one failing widget show its own error while the rest of the page works. See React Suspense.
Errors Worth Distinguishing
Accessibility
- Set
aria-busy="true"on regions while they load, and remove it when content arrives. - Announce meaningful outcomes — "12 results", "Couldn't load projects" — through a polite live region, not every intermediate state.
- Move focus sensibly after errors in user-initiated actions, for example to the error summary of a form. See Accessible Forms and Error Recovery.
- Do not rely on animation alone: skeleton shimmer should respect
prefers-reduced-motion. - Give retry buttons clear labels — "Retry loading projects", not just "Retry" when several exist.
Layout Stability
Loading states are a major source of Cumulative Layout Shift. Reserve space with skeletons sized like real content, set dimensions on images and embeds, and avoid inserting banners above existing content after load. See Core Web Vitals.
Best Practices
Design All States Up Front
Add loading, empty, and error states to designs and acceptance criteria. Storybook stories for each state make them reviewable and testable.
Keep Showing Data During Refreshes
Background refreshes, pagination, and filter changes should keep current data visible with a subtle indicator. Replacing content with a skeleton on every refetch feels broken.
Fail Small
Scope errors to the smallest region that failed. A failed recommendations widget should not replace the whole page with an error screen.
Test Each State
Mock slow, empty, and failing responses in component and end-to-end tests — for example with Mock Service Worker — so these paths do not regress unnoticed.
Common Mistakes
Treating Empty as Loading
Generic Error Messages
Spinners That Replace Content
FAQ
Should I use skeletons or spinners?
Skeletons for content regions whose layout you know, because they reduce perceived waiting and prevent layout shift. Spinners for actions in progress, small regions, and unknown layouts.
How do I avoid loading flashes on fast connections?
Delay showing loading indicators by a few hundred milliseconds, and once shown, keep them for a minimum duration. React transitions and placeholder data also keep previous content on screen during quick updates.
What should an empty state include?
What the area is for, why it is empty, and the most useful next action — such as creating the first item, importing data, or clearing filters. Distinguish first-use empty states from "no results" states.
How should errors be shown to users?
Close to where the failure happened, in plain language, with what the user can do next — retry, sign in again, contact an admin — while leaving working parts of the page usable.
How do I make loading states accessible?
Mark loading regions with aria-busy, announce important results or failures through polite live regions, manage focus after user-initiated errors, and respect reduced-motion preferences.
Related Topics
- Data Fetching — Fetching strategies and caching
- TanStack Query — Query status flags and caching
- React Suspense — Declarative loading boundaries
- Accessibility — Inclusive interface design
- Accessible Forms and Error Recovery — Error handling in forms
- Core Web Vitals — Layout shift and loading metrics