Three exports that make a page's lifecycle read as one family: PageLoading
while data is on its way, PageError when it failed, and ListState, the
switch a list surface wraps its body in so loading, error, empty, filtered
empty and loaded are one decision instead of five branches written by hand.
All three sit on the same PageInfo primitive as EmptyState, so the four
states share icon disc, title, description and action.
The canon behind it. method/design-language/04-loading-and-states.md §A
(a surface owns its five states and the reader can tell them apart at a
glance) and method/app-patterns/10-recipe-browsable-list.md (the list recipe
names ListState as the branch selector).
When to reach for it#
A route or a card whose content arrives asynchronously and can fail. ListState
whenever the surface is a list that can be empty in two different ways: nothing
exists yet, or a filter removed everything. Reach for EmptyState alone when
the surface has no loading or error branch of its own.
Do not reach for PageLoading for a control-level wait (a button's own
loading, a field's spinner); those components carry their own. Do not reach
for PageError for a validation message on a form; that is the field's error
slot.
Contract#
PageLoading and PageError share PageStateBaseProps:
title,description: throughrenderNode, string or JSX.action: a free JSX slot, a retry button or a support link.compact: tighter spacing for nested placements (a card, a panel).skeleton: a placeholder block instead of content.- Slot classnames per
PageStateClassNames:""the container,iconWrap,icon,title,description,action.
PageLoading adds spinnerSize (px) and spinnerClassName (defaults to the
primary colour).
PageError adds the IconImportProps trio (Icon, iconSize, iconProps,
iconClassName), tone (a PageInfoTone, default error), iconCircle
(the soft tinted disc, on by default because errors deserve weight) and
iconPulse (a slow pulse for waiting states that should read alive).
ListState is the switch:
loading: still fetching; beaten only byerror.error: anything truthy wins over every other branch.totalCount(required): records that exist before any filter.filteredCount: records surviving the current filter or search; defaults tototalCount.loadingState,errorState(a node or a function of the error),empty,filteredEmpty: the four branches; defaults are<PageLoading/>,<PageError/>,<EmptyState/>, and<FilteredEmpty/>for filtered-empty, built fromquery(quoted in the title),nounandonClear(the Clear button); passfilteredEmptyonly to replace it whole.children: the loaded surface.
Priority order, fixed: error, then loading, then empty (totalCount === 0),
then filtered empty (filteredCount === 0), then children.
Adoption#
PageLoading, measured 2026-09-04: no shipped usage in either app. Both branch
on data == null and render a page-shaped skeleton instead, which is the house
rule (doc 4 §A1), not a missed convention. PageLoading stays the preset for the
case neither app has yet: a full panel with no known shape to skeleton.
PageError, measured 2026-09-04: walmart uses it for failed loads across a dozen
pages; speedway not at all, because speedway's loaders throw to a route-level
ErrorBoundary (speedway/app/root.tsx:566), so a failure there is caught by the
route rather than rendered in the page. Both are correct for their framework.
Sanctioned combinations#
| Combination | Produces | Where used | Why |
|---|---|---|---|
ListState around a DataTable with loading, error, totalCount and filteredCount from the loader | A list page whose five states are one switch | the list recipe (method/app-patterns/10-recipe-browsable-list.md), the playground /demo/jobs | The recipe's whole point: no hand-written branch order, and filtered-empty is never mistaken for empty |
PageError with action a retry Button and tone="error" | The failed-load page | the playground gallery | A failed load names what to do next; a bare message strands the reader |
PageLoading compact inside a Card | A card whose body is loading while the page around it is not | the playground gallery | The page frame stays put; only the card waits |
Banned combinations#
PageLoadingas a Suspense fallback for a whole page. That isPageSkeleton, which keeps the page's own shape; a centred spinner where a table will land makes the layout jump when data arrives.- Hand-writing the five-state branch order around a list when
ListStateexists. The order is the contract (error beats loading beats empty beats filtered-empty); every hand copy gets one of them wrong eventually. queryleft off when the surface has a search box. The filtered-empty title must quote the query so the reader knows the filter, not the data, is why the list is empty.
Before you adopt this#
Five questions to answer before reaching for these.
- Does the shell, a parent layout, or a global provider already render this? A route-level Suspense boundary may already show
PageSkeleton; the page state is for the data step after that. - Does this app already ship a local implementation of the same thing? Speedway's early routes hand-wrote the branch order; the recipe replaces them one screen at a time.
- Does this app's kit pin reach the version this component or prop landed in?
ListStatelanded with the list recipe; checkpackages/ui/CHANGELOG.mdagainst the app's pin. - Does the component derive its own accessible name and keyboard path, or must the call site supply them? The states are static content with a live region for loading; the
actionslot's control names itself. - Which canon §A rules bind this surface, and which does the composition break? Canon 04 §A: five states, each distinguishable; filtered-empty quotes the query.
Travels with#
EmptyState, the fourth state in the same family, and the default forListState'sempty.PageSkeleton, the page-shaped Suspense fallback that precedes the data step.DataTableand the table hooks, in the list recipe.Button, in theactionslot, for retry and for the empty state's primary action.
Snippet#
import { Button, DataTable, EmptyState, ListState, PageError } from "@versable-git/ui";<ListState loading={isLoading} error={error} totalCount={total} filteredCount={rows.length} errorState={(e) => <PageError description={String(e)} action={<Button content="Retry" onClick={refetch} />} />} empty={<EmptyState title="No jobs yet" action={<Button content="New job" href="/jobs/new" />} />} query={query} noun="jobs" onClear={() => setQuery("")}> <DataTable rows={rows} columns={columns} /></ListState>