Home/ Cookbook/ States/ Empty, loading, error

Build the three states every screen needs

By the end of this recipe you'll have a single Resource component that handles initial-load skeletons, paginate-more spinners, optimistic create/update, the "we tried and failed" error with retry, and an empty state with a primary CTA — composed so each state looks like the eventual content, not a generic placeholder.

States 3 primitives · 1 block · 0 patterns ~25 min React · @corelithzw/react

Overview

A discriminated-union state model — loading | empty | loaded | error — and a render branch per state that looks like the same screen, just at a different moment in time.

Most apps treat empty, loading and error as afterthoughts. They reach for a generic full-screen spinner, then an emoji-and-tagline empty page, then a red banner — three different layouts, three different mental models, and the user feels every transition as a jolt. The right call is the opposite: design the loaded state first, then derive each other state from it by replacing content with a shimmer, a CTA or an apology. Layout stays put; only content swaps.

Loading screens should look like the eventual content, not a spinner. The shimmer is a row card with no text; the empty state lives where the rows would have been; the error lives in the same slot. The user's eye doesn't relearn the page every time the data changes.

Optimistic create gets a fourth state implicitly — the new row appears in the list with a quiet "saving…" caption, and reconciles or rolls back when the response lands. We treat that as a tiny in-list state, not a global one, because the rest of the page is still trustworthy.

Skip this recipe if your data is local-first or pre-loaded — most of these states collapse to "loaded" and the others never run. Use it anywhere you have a network fetch with a non-trivial chance of being empty or failing.

What you'll build

One layout, four states. Each state lives in the same rectangle as the loaded view.

Suppliers
01 Loading — skeleton in row shape
Suppliers
No suppliers yet
Add your first supplier to start logging deliveries.
Add supplier
02 Empty — primary CTA
Suppliers
We couldn't load
The connection dropped. Last sync 12 min ago.
Try again
03 Error — retry with last-sync time

You can see this live in

Every Huchu portal uses this state shell. Pick one — empty inboxes, skeleton lists, network errors all behave the same way.

Required pieces

Everything this recipe pulls from @corelithzw/react. Each link opens the reference page.

@corelithzw/react exports used here: Stack, Skeleton, Spinner, EmptyState, Alert, Button, RowCard, useToast. Every other recipe in this batch composes Resource from the same parts.

Step-by-step build

01

Model the four states as a discriminated union

Two booleans (isLoading, hasError) and a nullable list will get you into "loading + error at the same time" within a week. A discriminated union forces every render branch to be one and only one state, and the compiler tells you when you forgot the empty branch.

Code-only step

'loading' → failure → 'error'

'loaded' → refetch failure → 'error' (with lastGood)

No state is "loading + error". Use the union, not booleans.

Switch to the Code tab to see the snippet.

Step 1 · State model@corelithzw/react
02

Make the loading state look like the eventual content

Use the same Skeleton primitive with the row's exact height and corner radius. The page header stays real (because it doesn't depend on data), and the rows shimmer in the shape they'll resolve to. The eye doesn't relearn the layout.

Suppliers
Step 2 · Loading shape
03

The empty state has exactly one primary CTA

EmptyState takes an icon, a title, a sentence and a single primary action. No secondary action, no marketing copy. The CTA gets focus on mount so a keyboard user can act immediately.

No suppliers yet
Add your first supplier to start logging deliveries.
Add supplier
Step 3 · Empty
04

The error state shows what to do, not what went wrong

"Network timeout" tells the user nothing they can act on. "We couldn't load — last sync 12 min ago" tells them how stale the screen is and offers Retry. If we have lastGood data, render it underneath with a banner so the page stays useful while the user decides.

We couldn't load
The connection dropped. Last sync 12 min ago.
Try again
Step 4 · Error
05

Optimistic create with rollback on failure

When the user adds a row, insert it locally with a temp id and a quiet "saving…" caption. On success, swap the temp id for the server's id; on failure, remove the row and show a useToast message that includes "Try again". The rest of the page never enters a loading state.

Mukamba Distributors saving…
Tendai Wholesale
Bulawayo Foods
Step 5 · Optimistic

Final composition

A reusable Resource component. Pass it a fetcher and a row renderer; it handles loading, empty, loaded, error and "load more". Drop in SuppliersScreen as the shape every list page in the app should follow.

loaded
Mukamba Distributors
Tendai Wholesale
Bulawayo Foods
empty
No suppliers yet
Add your first to start.
Add supplier
loading
error
We couldn't load
Last sync 12 min ago.
Try again
Resource.tsx@corelithzw/react

Variations

Three common forks. Same state model, different surface.

Inline error in a list row

When only one row fails (e.g. a payment that wouldn't confirm), keep it in the list with a danger badge. Don't tip the whole page into the error state.

// Per-row error — page stays loaded
<RowCard
  label={row.name}
  tone={row.error ? 'danger' : 'neutral'}
  trailing={row.error
    ? <Button variant="link"
        onClick={() => retry(row.id)}>Retry</Button>
    : 'chevron'}
/>

Skeleton above the fold only

For long pages, only shimmer what's visible — 4 rows, not 40. The rest reveals as IntersectionObserver pages it in.

// Visible-only skeletons
const visibleRows = 4;
{Array.from({ length: visibleRows }).map((_, i) =>
  <Skeleton key={i} height={56} radius="md" />
)}
// <LoadMoreSentinel /> below the fold

Offline-first variant

When the API is unreachable, switch the error banner to "Offline — changes will sync when you reconnect" and keep the create flow optimistic. The toast wording changes; the state machine does not.

// Detect offline once, branch the message
const online = useOnlineStatus();
<Alert tone="warning">
  {online
    ? `Could not refresh: ${err.message}`
    : 'Offline — changes will sync later.'}
</Alert>

Accessibility

What this recipe takes care of for you.

  • Loading is announced exactly once. The skeleton wrapper carries aria-busy="true" and aria-live="polite"; the live region says "Loading suppliers" on mount and stays quiet for every shimmer frame.
  • The CTA on empty gets focus. autoFocus on the primary button means a keyboard user can press Enter immediately after the page renders empty — no extra tab.
  • Error keeps the page useful. When lastGood exists we render an Alert tone="warning" with an inline Retry — the screen-reader user hears the staleness and the action together, and the list below is still navigable.
  • Spinners have a label. The paginate-more Spinner carries aria-label="Loading more". Without it, screen readers say nothing and the user can't tell whether their tap registered.
  • Optimistic rows are announced as pending. The temporary row carries aria-live="polite" with "saving Mukamba Distributors"; on success the live region updates to "saved", on failure to "could not save".
  • State transitions are non-modal. Going from loading to empty to error never opens a dialog. The page region stays focused, the heading stays in the DOM, and Tab order is preserved across the swap.
  • Reduced motion respected. When prefers-reduced-motion: reduce is set, Skeleton drops the shimmer and renders a solid muted block. The shape still communicates "loading"; the animation doesn't make anyone seasick.