Agent docs

PageLoading, PageError, ListState

The page-level loading and error states on the same primitive as EmptyState, and the ListState switch that picks one of five states for a list surface.

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: through renderNode, 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 by error.
  • 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 to totalCount.
  • 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 from query (quoted in the title), noun and onClear (the Clear button); pass filteredEmpty only 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#

CombinationProducesWhere usedWhy
ListState around a DataTable with loading, error, totalCount and filteredCount from the loaderA list page whose five states are one switchthe list recipe (method/app-patterns/10-recipe-browsable-list.md), the playground /demo/jobsThe 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 pagethe playground galleryA failed load names what to do next; a bare message strands the reader
PageLoading compact inside a CardA card whose body is loading while the page around it is notthe playground galleryThe page frame stays put; only the card waits

Banned combinations#

  • PageLoading as a Suspense fallback for a whole page. That is PageSkeleton, 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 ListState exists. The order is the contract (error beats loading beats empty beats filtered-empty); every hand copy gets one of them wrong eventually.
  • query left 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.

  1. 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.
  2. 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.
  3. Does this app's kit pin reach the version this component or prop landed in? ListState landed with the list recipe; check packages/ui/CHANGELOG.md against the app's pin.
  4. 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 action slot's control names itself.
  5. 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 for ListState's empty.
  • PageSkeleton, the page-shaped Suspense fallback that precedes the data step.
  • DataTable and the table hooks, in the list recipe.
  • Button, in the action slot, 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>
@versable-git/ui · reference, canon, and method, read in place