Topic 14.2
Loading, Error and Empty States
In one line
Every request can be loading, failed, empty or successful, so model it with one status value, design a screen for each state (skeletons, retry buttons, empty messages, optimistic hints), and keep the layout from jumping when data arrives.
Think of it like this
Ordering at a café. While your coffee is being made you get a buzzer (loading). If the machine breaks, someone tells you and offers tea instead (error with a way forward). If they're out of cake, the menu says "sold out" rather than showing an empty plate (empty state). And when you order, the cashier writes your name on the cup right away, before it's ready (optimistic hint). A good screen talks to the user in each of these moments.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Status
- Which stage a request is in: idle, loading, success or error.
- Empty state
- The screen for a successful request that returned no items, with a message and a next step.
- Skeleton
- A grey placeholder with the same shape and size as the content that's still loading.
- Layout shift
- Content jumping to a new position after the page is visible, usually because something above it changed size.
- CLS
- Cumulative Layout Shift: Google's score for how much a page jumps around while loading. Lower is better.
- Retry
- Running a failed request again, by the user's click or automatically.
- Exponential backoff
- Waiting longer and longer between automatic retries, such as 1 s, 2 s, 4 s.
- Optimistic update
- Showing the result of an action before the server confirms it, and undoing it if the server says no.
- aria-busy
- An attribute that tells assistive technology a region is still loading.
Step by step
01List the states before writing code
For every screen that loads data, write down what the user sees in each state. This is a design task as much as a coding one.
02Model them as one value
One state object that always has a status. Setting a new status replaces the whole object, so a stale error can't linger next to fresh data.
// Bad: 8 combinations, several impossible
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [data, setData] = useState(null);
// Good: exactly 3 shapes
const [state, setState] = useState({ status: "loading" });
// { status: "loading" }
// { status: "error", error }
// { status: "success", data }03Render each state, error and loading first
A switch on the status keeps each screen separate and readable. Inside success, branch once more for the empty case.
switch (state.status) {
case "loading":
return <OrdersSkeleton />;
case "error":
return <ErrorMessage error={state.error} onRetry={retry} />;
case "success":
if (state.data.length === 0) return <p>No orders yet.</p>;
return <OrderList orders={state.data} />;
}04Retry by re-running the effect
Add an attempt number to the effect's dependencies. The Try again button increments it, so React runs the cleanup (marking the old request as ignored) and the effect again. The first live example fails once, then succeeds on retry.
05Reserve space for the content
Give skeleton rows exactly the size of real rows. The second live example measures the list before and after the data arrives: the same height, so nothing below it moves.
06Show user actions optimistically
Add the item immediately with a saving flag, swap in the server's version on success, remove it and explain on failure. The third live example shows a success and a failure.
Try it yourself
- 1
Make the retry fail too
In the first example, change
if (calls === 1)toif (calls <= 2). Predict the six status lines before you run it. Then think: after how many failures should an app stop offering a retry and suggest something else? - 2
Swap the skeleton for nothing
In the skeleton example, change
[1, 2, 3].map(...)to[]so nothing renders while loading. Predict both heights. (Answer: 0 px, then 160 px: the paragraph below jumps down by 160 px.) - 3
Add a refresh that keeps old data
In the weather example, add a
refreshingflag: when Try again is clicked after a success, keep showing the old temperature withopacity: 0.5instead of the loading text. You'll need to stop the effect from setting{ status: "loading" }when data already exists.
Code & diagrams
Loading, then the error with a Try again button, then the script clicks it: loading again and success. Remove the auto-click line at the bottom to click it yourself.
Preview
Console
Nothing logged yet.Expected console output
status: loading
status: error
status: loading
status: successThree rows of 48 px plus 8 px gaps, in both states. Press Run again to watch the grey rows turn into people without anything moving.
Preview
Console
Nothing logged yet.Expected console output
list height while loading: 160 px
list height when loaded: 160 pxThe new task appears instantly, greyed out while saving. The failing one appears, then disappears with an explanation. Without the undo, the list would show a task the server never stored.
Preview
Console
Nothing logged yet.Expected console output
start: (empty state)
after click: Buy milk (saving...)
saved: Buy milk
after click: Buy milk, fail task (saving...)
failed: Buy milk | Server refused: fail taskBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Use three booleans and forget to reset one
Replace the status object with isLoading, error and data states, render {isLoading && ...}{error && ...}{data && ...}, and in the effect call setIsLoading(true) at the start but forget setError(null).
Break #2
Read data before checking the status
Put const count = state.data.length; at the top of a component, before the switch.
Myth vs fact
Myth
A spinner is always the right loading indicator.
Fact
For content with a known shape, a skeleton keeps the layout stable and feels faster. For very short waits, showing nothing (or old data) for ~200 ms avoids a distracting flash.
Myth
An empty list needs no special UI.
Fact
A blank area looks broken. An empty state explains what would be here and offers the next action.
Myth
Optimistic updates are risky because they can lie.
Fact
They're safe when you mark items as pending and undo with a message on failure. Avoid them only where a wrong guess is costly (payments, irreversible actions).
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Avoid a spinner flash: show the loading UI only if the request takes longer than about 200 ms, and once shown keep it for at least ~400 ms. React's Suspense does a version of this for you: it throttles revealing new content so fallbacks and content don't flicker (Topic 14.3).
- ▸
Layout shift isn't only from lists: images without
width/height(or CSSaspect-ratio), late-loading fonts and banners inserted at the top are common causes. Measure with the Performance panel or theweb-vitalslibrary (Topic 23.4). - ▸
Errors are data too: log them (Topic 23.5) with the request id, and map server error codes to messages in one place so every screen speaks the same language. A 401 should usually redirect to login rather than show a retry button.
- ▸
A state machine (Topic 15.5, XState) is the formal version of the status pattern: it lists the states and which events may move between them, which rules out impossible transitions like "success" while a retry is in flight.
Remember this
- 1
A request moves through states: idle (nothing asked yet), loading (waiting), success (data arrived), error (it failed). Success has a special case worth its own design: empty (it worked, but there are zero items). If you keep this in separate booleans (
isLoading,hasError,data), you can reach impossible mixes such asisLoading === truewith an old error still showing. Model it as one value instead:{ status: "loading" },{ status: "error", error },{ status: "success", data }. Each status carries exactly the data it needs, so data and error can't both be set (Topic 6.1 calls this avoiding contradictions). In TypeScript this is a discriminated union (Topic 12.6). - 2
Render by status. A
switch (state.status)or a few earlyreturns give each state its own JSX. The order matters: checkerrorandloadingbefore you touchdata, or you'll readdata.itemswhiledatais stillundefined. For the empty state, checkdata.length === 0and show a helpful message and the next action ("No tasks yet. Add your first one."), never a blank area: users can't tell "empty" from "broken". - 3
Loading UI: spinner or skeleton. A spinner says "wait". A skeleton is a grey placeholder in the same shape and size as the real content (lines for text, circles for avatars). Skeletons feel faster because the page structure appears at once, and they prevent layout shift: content jumping when data arrives and pushes things down. Layout shift annoys users (they click the wrong thing) and is measured by Google as CLS, Cumulative Layout Shift (Topic 23.4). Rules: give placeholders the real size (fixed heights,
aspect-ratiofor images), mark the loading region witharia-busy="true", and announce changes politely withrole="status"so screen readers know (Topic 18.3). Don't flash a spinner for very fast responses: wait about 200 ms before showing one, or keep showing old data. - 4
Errors the user can act on. Show what failed in plain words, keep the rest of the page working, and offer a retry. A clean way to retry with an effect: keep an
attemptcounter in state, include it in the effect's dependencies, and have the button dosetAttempt(a => a + 1); the effect runs again. Put error messages inrole="alert"so they're announced. Distinguish errors where retry helps (network, 503) from ones where it doesn't (404, 403: show "not found" or "no access"). Data libraries retry automatically with exponential backoff: waiting longer between each try (1 s, 2 s, 4 s) so a struggling server isn't hammered. - 5
Initial load vs refresh. Once data has loaded, refreshing it (new filter, background update) shouldn't throw the user back to a full skeleton. Keep showing the old data and add a small hint (dim it, or a thin "Updating..." bar). TanStack Query exposes this split as
isPending(no data yet) vsisFetching(any request in flight, Topic 14.6), and transitions do the same for Suspense (Topic 14.3). - 6
Optimistic hints. For actions the user takes (add a task, like a post), you can show the result before the server confirms it, because it almost always succeeds. This is an optimistic update: add the item at once, marked as pending (greyed, "saving..."), replace it with the server's version when the response arrives, and undo it with a message if the request fails. It makes apps feel instant. React 19 has a hook for this,
useOptimistic(Topic 19.6); the live example builds it by hand so you see every step.
Explain it without notes
Why model a request with one status value instead of separate booleans?
What is layout shift, why does it matter, and how do skeletons help?
How would you implement a retry button for data fetched in an effect?
Explain an optimistic update and what must happen when the server rejects it.
Practice
Write a useFetch(fn, deps) hook that returns { status, data, error, retry } using one state object and the ignore flag.
Write a ProductList that shows a skeleton of 4 cards while loading, an empty state when the array is empty, and the products otherwise.
Add an optimistic "like" button: the count goes up at once, and goes back down with an error message if fakeLike() rejects.
Trade-offs
- ↔
Skeletons need extra components that mirror the real layout and must be kept in sync with it. A simple spinner is cheaper to build but causes more layout shift.
- ↔
Optimistic updates make the app feel instant, but add rollback code and can briefly show something that turns out false. Use them for frequent, low-risk actions; wait for the server on payments and deletes that can't be undone.
Done when you can
I can model a request with one status value and render each state.
I always design an empty state and an error state with a way forward.
I can build a skeleton that prevents layout shift.
I can add a retry button to effect-based fetching.
I can write an optimistic update that rolls back on failure.