Data state
One wrapper that renders a component's skeleton, empty, error and success states.
Invoices on hold
import { DataState } from "@oration/canon/components/data-state";import { SegmentedControl } from "@oration/canon/components/segmented-control";import { SkeletonRows } from "@oration/canon/components/skeletons";import { useMockQuery } from "@oration/canon/hooks/use-mock-query";import * as React from "react";export function Hero() { const headingId = React.useId(); const [view, setView] = React.useState< "loading" | "empty" | "error" | "success" >("success"); const [attempt, setAttempt] = React.useState(0); const onHold = [ { id: "INV-20417", supplier: "Northwind Freight", reason: "Doesn't match PO-88213", amount: "$18,240.00", }, { id: "INV-20431", supplier: "Halcyon Packaging", reason: "Missing W-9", amount: "$4,912.50", }, { id: "INV-20438", supplier: "Orchard Street Foods", reason: "Possible duplicate of INV-20402", amount: "$7,305.18", }, ]; const query = useMockQuery( `docs.data-state.hero.${view}.${attempt}`, () => (view === "empty" ? [] : onHold), { delay: 900, enabled: view !== "loading", fail: view === "error" }, ); return ( <div className="flex w-full max-w-lg flex-col gap-3 text-left"> <SegmentedControl label="State to show" value={view} onValueChange={(next) => { setView(next); setAttempt((n) => n + 1); }} options={[ { value: "loading", label: "Loading" }, { value: "empty", label: "Empty" }, { value: "error", label: "Error" }, { value: "success", label: "Success" }, ]} /> <section aria-labelledby={headingId} className="overflow-hidden rounded-xl bg-card shadow-border" > <h3 id={headingId} className="border-b border-border px-4 py-3 text-sm font-semibold" > Invoices on hold </h3> <DataState query={{ ...query, retry: () => { setView("success"); setAttempt((n) => n + 1); }, }} skeleton={ <SkeletonRows rows={3} avatar={false} className="px-2 py-1" /> } isEmpty={(list) => list.length === 0} empty={{ illustration: "done", title: "Nothing on hold", description: "Invoices that fail matching or are missing a W-9 wait here for review.", }} errorTitle="Couldn't load invoices on hold" > {(list) => ( <ul className="divide-y divide-border"> {list.map((invoice) => ( <li key={invoice.id} className="flex items-center gap-3 px-4 py-2.5" > <div className="min-w-0 flex-1"> <p className="truncate text-sm font-medium"> {invoice.supplier} </p> <p className="truncate text-13 text-muted-foreground"> {invoice.reason} </p> </div> <span className="text-sm tabular-nums"> {invoice.amount} </span> </li> ))} </ul> )} </DataState> </section> </div> );}Usage#
Data state renders one data component's loading, empty, error and success states in place. Give it a query from useMockQuery, a skeleton shaped like the content, an isEmpty test and an empty description, and its children only ever run with data. EmptyState and ErrorState are exported for states that don't come from a query, and useDelayedFlag holds back spinners and hints for work that finishes fast. The mistake to avoid is one spinner for the whole page: each component owns its states, so a failed stat strip doesn't blank the table under it.
When to use
- Around every component that reads data: a table, a list, a stat strip, a chart, a profile card.
- To show a skeleton shaped like the content while it loads, then reveal the content with the skeleton reveal without moving the layout.
- To say a read failed and offer Retry, inside the component that failed.
- With
EmptyStateon its own for filtered-to-nothing and no-results states that don't come from a query. - With
useDelayedFlagto show a spinner or a Still loading hint only after work has run for a beat.
When not to use
- For a first-run page that needs more than a sentence and an action. Compose the empty state yourself. Use Empty
- For an action in progress, such as Save or Send. Use Pending button
- For an action that failed. Reads fail in place; writes fail in a toast. Use Toast
- For a problem that affects the whole page, such as a disconnected integration. Use Alert
- For the placeholder shapes themselves. Use Skeleton
The One Filled Button Rule
The Thirteen-Fourteen Rule
md titles and descriptions are 14px. At sm the title steps down to 13px and the description to 12px meta. Nothing goes smaller.Anatomy#
Couldn't load payment runs
- Illustration. A line illustration. Empty states pick one (
done,search,filters…); errors always useerrorat 80px. - Title. One line, semibold: what is empty or what failed.
- Description. One muted sentence: what shows up here, or what to do about the failure.
- Action or Retry. The empty state's next step, or the outline Retry button that calls
query.retry. - Wrapper.
<div data-slot="data-state" data-status>witharia-busywhile loading, plus the.t-skelreveal class.data-statusisloading,error,emptyorcontent;data-phaseisloading,enter,revealingordone. - Status region. A visually hidden
role="status"that announces the error title and description.
Examples#
EmptyState on its own
Filtering and search happen on data you already have, so use EmptyState directly. Repeat the query, suggest another way in, and offer Clear search.
No suppliers match "Northwnd"
import { Button } from "@oration/canon/components/button";import { EmptyState } from "@oration/canon/components/data-state";import { Input } from "@oration/canon/components/input";import { SearchIcon } from "lucide-react";import * as React from "react";export function StandaloneEmpty() { const id = React.useId(); const [search, setSearch] = React.useState("Northwnd"); const suppliers = [ { name: "Northwind Freight", email: "ar@northwindfreight.com" }, { name: "Halcyon Packaging", email: "billing@halcyon.co" }, { name: "Orchard Street Foods", email: "accounts@orchardst.com" }, ]; const term = search.trim().toLowerCase(); const matches = suppliers.filter( (s) => s.name.toLowerCase().includes(term) || s.email.toLowerCase().includes(term), ); return ( <div className="flex w-full max-w-md flex-col gap-3"> <div className="relative"> <label htmlFor={`${id}-search`} className="sr-only"> Search suppliers </label> <SearchIcon aria-hidden="true" className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" /> <Input id={`${id}-search`} value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search suppliers" className="pl-8" /> </div> <div className="rounded-xl bg-card shadow-border"> {matches.length ? ( <ul className="divide-y divide-border"> {matches.map((supplier) => ( <li key={supplier.name} className="px-4 py-2.5"> <p className="text-sm font-medium"> {supplier.name} </p> <p className="text-13 text-muted-foreground"> {supplier.email} </p> </li> ))} </ul> ) : ( <EmptyState illustration="search" title={`No suppliers match "${search.trim()}"`} description="Check the spelling, or search by remit-to email." action={ <Button type="button" variant="outline" size="sm" onClick={() => setSearch("")} > Clear search </Button> } /> )} </div> </div> );}ErrorState on its own
For a read that doesn't come from a query, render ErrorState with onRetry and manage the retry yourself. Retry loads the preview.
Remittance preview
Couldn't render the preview
import { Button } from "@oration/canon/components/button";import { ErrorState } from "@oration/canon/components/data-state";import { Skeleton } from "@oration/canon/components/skeleton";import { SkeletonReveal } from "@oration/canon/components/skeleton-reveal";import { RotateCcwIcon } from "lucide-react";import * as React from "react";export function StandaloneError() { const [status, setStatus] = React.useState<"error" | "loading" | "ready">( "error", ); return ( <div className="w-full max-w-sm overflow-hidden rounded-xl bg-card shadow-border"> <p className="border-b border-border px-4 py-3 text-sm font-semibold"> Remittance preview </p> <SkeletonReveal loading={status === "loading"} skeleton={ <div className="flex flex-col gap-2 p-4"> <Skeleton className="h-3 w-2/3" /> <Skeleton className="h-3 w-1/2" /> <Skeleton className="h-3 w-3/4" /> </div> } > {status === "error" ? ( <ErrorState size="sm" title="Couldn't render the preview" description="The PDF service didn't answer. Try again in a moment." onRetry={() => { setStatus("loading"); window.setTimeout(() => setStatus("ready"), 800); }} /> ) : ( <div className="flex flex-col gap-1 p-4 text-13"> <p className="font-medium"> Payment to Northwind Freight </p> <p className="text-muted-foreground tabular-nums"> 3 invoices, $18,240.00, paid Friday, Oct 2 </p> <Button type="button" variant="ghost" size="xs" className="mt-1 self-start" onClick={() => setStatus("error")} > <RotateCcwIcon data-icon="inline-start" aria-hidden="true" /> Fail again </Button> </div> )} </SkeletonReveal> </div> );}A hint for slow loads
useDelayedFlag turns true only after loading has run for 1.2 seconds, so a quiet Still loading line appears on slow reads and never flashes on fast ones. Press Reload to watch it.
Suppliers paid this week
import { Button } from "@oration/canon/components/button";import { DataState, useDelayedFlag } from "@oration/canon/components/data-state";import { SkeletonRows } from "@oration/canon/components/skeletons";import { Spinner } from "@oration/canon/components/spinner";import { useMockQuery } from "@oration/canon/hooks/use-mock-query";import * as React from "react";export function DelayedHint() { const [run, setRun] = React.useState(0); const query = useMockQuery( `docs.data-state.delayed.${run}`, () => [ "Northwind Freight", "Halcyon Packaging", "Orchard Street Foods", ], { delay: 3200 }, ); const slow = useDelayedFlag(query.status === "loading", 1200); return ( <div className="flex w-full max-w-md flex-col gap-3"> <div className="overflow-hidden rounded-xl bg-card shadow-border"> <div className="flex items-center justify-between gap-3 border-b border-border px-4 py-2"> <p className="text-sm font-semibold"> Suppliers paid this week </p> <Button type="button" variant="ghost" size="sm" disabled={query.status === "loading"} onClick={() => setRun((n) => n + 1)} > Reload </Button> </div> <DataState query={query} skeleton={ <div> <SkeletonRows rows={3} avatar={false} lines={1} className="p-2" /> {slow ? ( <p className="flex items-center gap-2 px-4 pb-3 text-13 text-muted-foreground"> <Spinner className="size-3.5" /> Still loading suppliers </p> ) : null} </div> } errorTitle="Couldn't load suppliers" > {(names) => ( <ul className="divide-y divide-border"> {names.map((name) => ( <li key={name} className="px-4 py-2 text-sm"> {name} </li> ))} </ul> )} </DataState> </div> </div> );}States#
Panel, size md
Rail card, size sm
import { DataState } from "@oration/canon/components/data-state";import { SegmentedControl } from "@oration/canon/components/segmented-control";import { SkeletonRows } from "@oration/canon/components/skeletons";import { useMockQuery } from "@oration/canon/hooks/use-mock-query";import * as React from "react";export function SizesByState() { const [view, setView] = React.useState< "loading" | "empty" | "error" | "success" >("error"); const [attempt, setAttempt] = React.useState(0); const runs = [ { id: "run-1002", date: "Friday, Oct 2", invoices: 212, total: "$1,284,910.42", }, { id: "run-1003", date: "Friday, Oct 9", invoices: 64, total: "$318,402.00", }, ]; const options = { delay: 700, enabled: view !== "loading", fail: view === "error", }; const load = () => (view === "empty" ? [] : runs); const panel = useMockQuery( `docs.data-state.md.${view}.${attempt}`, load, options, ); const rail = useMockQuery( `docs.data-state.sm.${view}.${attempt}`, load, options, ); const retry = () => { setView("success"); setAttempt((n) => n + 1); }; const empty = { illustration: "schedule" as const, title: "No runs scheduled", description: "Approved invoices are batched into Friday runs.", }; return ( <div className="flex w-full flex-col gap-4"> <SegmentedControl label="State to show" value={view} onValueChange={(next) => { setView(next); setAttempt((n) => n + 1); }} options={[ { value: "loading", label: "Loading" }, { value: "empty", label: "Empty" }, { value: "error", label: "Error" }, { value: "success", label: "Success" }, ]} /> <div className="grid w-full items-start gap-4 md:grid-cols-[minmax(0,1fr)_16rem]"> <div className="flex min-w-0 flex-col gap-2"> <p className="text-13 text-muted-foreground"> Panel, size md </p> <div className="overflow-hidden rounded-xl bg-card shadow-border"> <DataState query={{ ...panel, retry }} skeleton={ <SkeletonRows rows={2} avatar="square" className="p-2" /> } isEmpty={(list) => list.length === 0} empty={empty} errorTitle="Couldn't load payment runs" > {(list) => ( <ul className="divide-y divide-border"> {list.map((run) => ( <li key={run.id} className="flex items-center gap-3 px-4 py-3" > <div className="min-w-0 flex-1"> <p className="text-sm font-medium"> {run.date} </p> <p className="text-13 text-muted-foreground tabular-nums"> {run.invoices} invoices </p> </div> <span className="text-sm tabular-nums"> {run.total} </span> </li> ))} </ul> )} </DataState> </div> </div> <div className="flex min-w-0 flex-col gap-2"> <p className="text-13 text-muted-foreground"> Rail card, size sm </p> <div className="overflow-hidden rounded-xl bg-card shadow-border"> <DataState size="sm" query={{ ...rail, retry }} skeleton={ <SkeletonRows rows={2} avatar={false} lines={1} className="p-1" /> } isEmpty={(list) => list.length === 0} empty={empty} errorTitle="Couldn't load runs" > {(list) => ( <ul className="divide-y divide-border"> {list.map((run) => ( <li key={run.id} className="flex items-center justify-between gap-2 px-3 py-2 text-13" > <span>{run.date}</span> <span className="text-muted-foreground tabular-nums"> {run.invoices} </span> </li> ))} </ul> )} </DataState> </div> </div> </div> </div> );}| State | Treatment |
|---|---|
| Loading | Your skeleton, plus a visually hidden Loading. The wrapper is aria-busy. |
| Warm | A key that resolved once this session renders its content straight away, with no skeleton and no animation. |
| Success | children(data), revealed over the skeleton: a 400ms cross-fade with a 2px cross-blur. |
| Empty | EmptyState from empty, when isEmpty(data) is true. Both props are needed. |
| Error | ErrorState with errorTitle, the error's message plus Try again in a moment., and Retry. Announced politely. |
| Retrying | Retry starts a new attempt: back to the skeleton, then success or the error again. |
| Small | size="sm" for cards and rails: tighter padding, the small illustration, a 13px title, a 12px description and an extra-small Retry. |
Behavior#
- The view is chosen in order: loading, then error, then empty (only when both
emptyandisEmptyare given andisEmpty(data)is true), then content. - Loading to the first settled view runs the skeleton reveal (transitions.dev): the skeleton pulses (1000ms, to 0.5 opacity), then content mounts under it and both layers cross-fade with a 2px cross-blur over 400ms ease-in-out before the skeleton unmounts. Going back to loading snaps, never animating the reverse. The first render of warm data never animates.
- Swaps between settled views (content to empty, error to content) are keyed inside
AnimatePresencewithmode="popLayout"andinitial={false}: fade in on the fast spring (80ms), out over a 60ms tween, the leaving view popped out of layout. useMockQuery(key, load, options)waits 250 to 850ms (stable per key) times the demo latency setting, then resolves.fail: truealways fails,enabled: falsestays loading, and the demo failures setting fails each key's first attempt.- Keys that resolved stay warm for the session, like a cache, until
replayLoading()from@oration/canon/lib/demo-dataforgets them. query.retry()starts a new attempt for the same key. Once settled,datais re-read fromloadon every render, so live mock data stays current.useDelayedFlag(flag, delay = 200)turns true only afterflaghas stayed true fordelayms, and false the momentflagdoes.
Do and don't#
DataState, so a failed card fails alone and the rest of the page keeps working.Couldn't load invoices on hold
errorTitle: Couldn't load invoices on hold.Couldn't load this
Content#
- Error titles name the thing: Couldn't load queue totals, Couldn't load callbacks. Use Couldn't, not Failed to or Error:.
- Error descriptions say what happened and what to do. The default is the error's message plus Try again in a moment.
- Empty titles say what's empty in plain words: Nothing on hold, No callbacks waiting. Not No data.
- Empty descriptions say what shows up here and how it gets here: Invoices that fail matching wait here for review.
- When a filter or search caused it, say so and offer the way back: No suppliers match with Clear search.
- Loading has no words. If it runs long, add one quiet line after a delay: Still loading invoices.
Accessibility#
- While loading the wrapper is
aria-busyand holds a visually hidden Loading; skeletons from@oration/canon/components/skeletonsarearia-hidden. - Errors are announced through a polite
role="status"region as the title and description. - Illustrations are decorative. The title and description carry the meaning.
- Retry and empty-state actions are real buttons with text labels, at 28px (
md) or 24px (sm). - Keep the empty state's action a link when it goes somewhere, such as Add a supplier opening another page.
| Keys | Action |
|---|---|
| Tab | Reaches Retry or the empty state's action. |
| Enter | Retries, or runs the empty state's action. |
Design tokens#
| Token | Used for |
|---|---|
--muted | Skeleton bones |
--foreground | State titles |
--muted-foreground | State descriptions |
text-13 / text-xs | Title and description at sm |
--reveal-dur / --reveal-blur / --reveal-ease | The skeleton reveal: 400ms, 2px, ease-in-out |
--pulse-dur / --pulse-min | The skeleton pulse while loading: 1000ms, to 0.5 |
spring.fast / exit.fast | 80ms fade in and 60ms fade out between settled views |
Illustration | Line illustrations in ink steps |
API reference#
DataState
Renders a query's states in place. Generic over the query's data type T.
| Prop | Type | Default | Description |
|---|---|---|---|
queryRequired | MockQuery<T> | No default | { status, data, error, retry }, from useMockQuery. |
skeletonRequired | React.ReactNode | No default | Placeholder shaped like the content. |
childrenRequired | (data: T) => React.ReactNode | No default | Renders the content. Only called with data. |
isEmpty | (data: T) => boolean | No default | True when the data has nothing to show. Needs empty. |
empty | EmptyStateProps | No default | { illustration?, title, description?, action? } for the empty view. |
errorTitle | string | "Couldn't load this" | Names what failed. |
errorDescription | string | No default | Replaces the default, which is the error's message plus Try again in a moment. |
size | "sm" | "md" | "md" | sm for cards and rails. |
className | string | No default | Classes for the wrapper. |
contentClassName | string | No default | Classes for the element around each view, such as h-full or flex flex-col. |
EmptyState
Illustration, title, sentence and next action.
| Prop | Type | Default | Description |
|---|---|---|---|
titleRequired | string | No default | What is empty. |
illustration | IllustrationName | No default | Shown at 120px (md) or 80px (sm). |
description | React.ReactNode | No default | One sentence. |
action | React.ReactNode | No default | Buttons or links, centered under the text. |
size | "sm" | "md" | "md" | Padding, illustration and type size. |
className | string | No default | Classes for the root. |
ErrorState
A failed read, said plainly, with Retry.
| Prop | Type | Default | Description |
|---|---|---|---|
title | string | "Couldn't load this" | Names what failed. |
description | React.ReactNode | "The request didn't finish. Try again in a moment." | What happened and what to do. |
onRetry | () => void | No default | Shows the Retry button when set. |
size | "sm" | "md" | "md" | Padding, type size and a 28px or 24px Retry. |
className | string | No default | Classes for the root. |
useDelayedFlag
useDelayedFlag(flag, delay?) returns true once flag has stayed true for delay ms.
| Prop | Type | Default | Description |
|---|---|---|---|
flagRequired | boolean | No default | The raw condition, such as query.status === "loading". |
delay | number | 200 | How long flag must hold, in ms. |
useMockQuery
useMockQuery(key, load, options?) from @oration/canon/hooks/use-mock-query. Returns a MockQuery<T>.
| Prop | Type | Default | Description |
|---|---|---|---|
keyRequired | string | No default | Identifies the read. Resolved keys stay warm for the session. |
loadRequired | () => T | No default | Returns the mock data. |
options.delay | number | No default | Base wait in ms before the latency multiplier. Defaults to 250 to 850ms per key. |
options.fail | boolean | false | Always fail, to show the error state. |
options.enabled | boolean | true | While false, stays in loading. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
DataState only accepts a MockQuery<T>. There is no adapter for a real data layer yet, so production reads will need one with the same { status, data, error, retry } shape.
Pressing Retry swaps the error view for the skeleton, so the focused button disappears and focus falls back to the page. Nothing moves focus to the content when it arrives.
Only errors are announced. The status region stays empty while loading and when content or an empty state arrives.
isEmpty is ignored without empty, so a list that forgot empty renders its children with no rows and no message.