Command Palette

Search for a command to run...

Hectal
PHASE 14Advanced ~33 min· topic 2 of 7

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.

List the states before writing codediagram
Rendering diagram…

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.

states.jswhole filejsx
// 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.

Orders.jsxwhole filejsx
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.

Show user actions optimisticallydiagram
Rendering diagram…

Try it yourself

  1. 1

    Make the retry fail too

    In the first example, change if (calls === 1) to if (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. 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. 3

    Add a refresh that keeps old data

    In the weather example, add a refreshing flag: when Try again is clicked after a success, keep showing the old temperature with opacity: 0.5 instead of the loading text. You'll need to stop the effect from setting { status: "loading" } when data already exists.

Code & diagrams

One status value, with retry New tab

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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

status: loading
status: error
status: loading
status: success
A skeleton that keeps the layout still New tab

Three 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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

list height while loading: 160 px
list height when loaded: 160 px
Empty state and an optimistic add with undo New tab

The 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

Press Run to see the page.

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 task

Break 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).

terminal
$ Preview after clicking Try again
── what you'll see ──
Loading weather...
Could not load the weather: Network error
 
(then, when the data arrives)
Could not load the weather: Network error
Pune: 31 °C

Break #2

Read data before checking the status

Put const count = state.data.length; at the top of a component, before the switch.

terminal
$ Console
── what you'll see ──
Uncaught TypeError: Cannot read properties of undefined (reading 'length')
An error occurred in the <Orders> component.
 
Consider adding an error boundary to your tree to customize error handling behavior.
Visit https://react.dev/link/error-boundaries to learn more about error boundaries.

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 CSS aspect-ratio), late-loading fonts and banners inserted at the top are common causes. Measure with the Performance panel or the web-vitals library (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. 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 as isLoading === true with 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. 2

    Render by status. A switch (state.status) or a few early returns give each state its own JSX. The order matters: check error and loading before you touch data, or you'll read data.items while data is still undefined. For the empty state, check data.length === 0 and 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. 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-ratio for images), mark the loading region with aria-busy="true", and announce changes politely with role="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. 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 attempt counter in state, include it in the effect's dependencies, and have the button do setAttempt(a => a + 1); the effect runs again. Put error messages in role="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. 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) vs isFetching (any request in flight, Topic 14.6), and transitions do the same for Suspense (Topic 14.3).

  6. 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

01

Why model a request with one status value instead of separate booleans?

02

What is layout shift, why does it matter, and how do skeletons help?

03

How would you implement a retry button for data fetched in an effect?

04

Explain an optimistic update and what must happen when the server rejects it.

Practice

01

Write a useFetch(fn, deps) hook that returns { status, data, error, retry } using one state object and the ignore flag.

02

Write a ProductList that shows a skeleton of 4 cards while loading, an empty state when the array is empty, and the products otherwise.

03

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.