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.
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.
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
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.
'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.
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.
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.
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.
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.
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.
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"andaria-live="polite"; the live region says "Loading suppliers" on mount and stays quiet for every shimmer frame. - The CTA on empty gets focus.
autoFocuson 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
lastGoodexists we render anAlert 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
Spinnercarriesaria-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: reduceis set,Skeletondrops the shimmer and renders a solid muted block. The shape still communicates "loading"; the animation doesn't make anyone seasick.