Skip to content

Data state

One wrapper that renders a component's skeleton, empty, error and success states.

Status
Stable
Category
Feedback
Adoption
Not used yet
import { DataState } from "@oration/canon/components/data-state";
packages/canon/src/components/data-state.tsx

Invoices on hold

Loading
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 EmptyState on its own for filtered-to-nothing and no-results states that don't come from a query.
  • With useDelayedFlag to 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

Retry is an outline button, and empty-state actions are outline unless filling the list is the one thing the view is for. A failed card never adds a second filled button to the page.

The Thirteen-Fourteen Rule

At 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

The request timed out. Try again soon.
  1. Illustration. A line illustration. Empty states pick one (done, search, filters…); errors always use error at 80px.
  2. Title. One line, semibold: what is empty or what failed.
  3. Description. One muted sentence: what shows up here, or what to do about the failure.
  4. Action or Retry. The empty state's next step, or the outline Retry button that calls query.retry.
  5. Wrapper. <div data-slot="data-state" data-status> with aria-busy while loading, plus the .t-skel reveal class. data-status is loading, error, empty or content; data-phase is loading, enter, revealing or done.
  6. 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"

Check the spelling, or search by remit-to email.
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

The PDF service didn't answer. Try again in a moment.
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

Loading
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

Loading

Rail card, size sm

Loading
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>    );}
States
StateTreatment
LoadingYour skeleton, plus a visually hidden Loading. The wrapper is aria-busy.
WarmA key that resolved once this session renders its content straight away, with no skeleton and no animation.
Successchildren(data), revealed over the skeleton: a 400ms cross-fade with a 2px cross-blur.
EmptyEmptyState from empty, when isEmpty(data) is true. Both props are needed.
ErrorErrorState with errorTitle, the error's message plus Try again in a moment., and Retry. Announced politely.
RetryingRetry starts a new attempt: back to the skeleton, then success or the error again.
Smallsize="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 empty and isEmpty are given and isEmpty(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 AnimatePresence with mode="popLayout" and initial={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: true always fails, enabled: false stays 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-data forgets them.
  • query.retry() starts a new attempt for the same key. Once settled, data is re-read from load on every render, so live mock data stays current.
  • useDelayedFlag(flag, delay = 200) turns true only after flag has stayed true for delay ms, and false the moment flag does.

Do and don't#

Loading
Loading
Do. Give each data component its own DataState, so a failed card fails alone and the rest of the page keeps working.
Loading
Don't. Hold the whole page behind one spinner until every request finishes.

Couldn't load invoices on hold

The request timed out. Try again in a moment.
Do. Name what failed in errorTitle: Couldn't load invoices on hold.

Couldn't load this

Error 504: upstream request timeout (GET /v2/invoices?status=hold)
Don't. Leave the default Couldn't load this, or show the raw error, so nobody knows which part broke.
Do. Shape the skeleton like the content, row for row, so the crossfade doesn't move anything.
Don't. Show a centered spinner in a list that then jumps to its full height.

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-busy and holds a visually hidden Loading; skeletons from @oration/canon/components/skeletons are aria-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.
Keyboard interactions
KeysAction
TabReaches Retry or the empty state's action.
EnterRetries, or runs the empty state's action.

Design tokens#

Design tokens
TokenUsed for
--mutedSkeleton bones
--foregroundState titles
--muted-foregroundState descriptions
text-13 / text-xsTitle and description at sm
--reveal-dur / --reveal-blur / --reveal-easeThe skeleton reveal: 400ms, 2px, ease-in-out
--pulse-dur / --pulse-minThe skeleton pulse while loading: 1000ms, to 0.5
spring.fast / exit.fast80ms fade in and 60ms fade out between settled views
IllustrationLine illustrations in ink steps

API reference#

DataState

Renders a query's states in place. Generic over the query's data type T.

Props of DataState
PropTypeDefaultDescription
queryRequiredMockQuery<T>No default{ status, data, error, retry }, from useMockQuery.
skeletonRequiredReact.ReactNodeNo defaultPlaceholder shaped like the content.
childrenRequired(data: T) => React.ReactNodeNo defaultRenders the content. Only called with data.
isEmpty(data: T) => booleanNo defaultTrue when the data has nothing to show. Needs empty.
emptyEmptyStatePropsNo default{ illustration?, title, description?, action? } for the empty view.
errorTitlestring"Couldn't load this"Names what failed.
errorDescriptionstringNo defaultReplaces the default, which is the error's message plus Try again in a moment.
size"sm" | "md""md"sm for cards and rails.
classNamestringNo defaultClasses for the wrapper.
contentClassNamestringNo defaultClasses for the element around each view, such as h-full or flex flex-col.

EmptyState

Illustration, title, sentence and next action.

Props of EmptyState
PropTypeDefaultDescription
titleRequiredstringNo defaultWhat is empty.
illustrationIllustrationNameNo defaultShown at 120px (md) or 80px (sm).
descriptionReact.ReactNodeNo defaultOne sentence.
actionReact.ReactNodeNo defaultButtons or links, centered under the text.
size"sm" | "md""md"Padding, illustration and type size.
classNamestringNo defaultClasses for the root.

ErrorState

A failed read, said plainly, with Retry.

Props of ErrorState
PropTypeDefaultDescription
titlestring"Couldn't load this"Names what failed.
descriptionReact.ReactNode"The request didn't finish. Try again in a moment."What happened and what to do.
onRetry() => voidNo defaultShows the Retry button when set.
size"sm" | "md""md"Padding, type size and a 28px or 24px Retry.
classNamestringNo defaultClasses for the root.

useDelayedFlag

useDelayedFlag(flag, delay?) returns true once flag has stayed true for delay ms.

Props of useDelayedFlag
PropTypeDefaultDescription
flagRequiredbooleanNo defaultThe raw condition, such as query.status === "loading".
delaynumber200How long flag must hold, in ms.

useMockQuery

useMockQuery(key, load, options?) from @oration/canon/hooks/use-mock-query. Returns a MockQuery<T>.

Props of useMockQuery
PropTypeDefaultDescription
keyRequiredstringNo defaultIdentifies the read. Resolved keys stay warm for the session.
loadRequired() => TNo defaultReturns the mock data.
options.delaynumberNo defaultBase wait in ms before the latency multiplier. Defaults to 250 to 850ms per key.
options.failbooleanfalseAlways fail, to show the error state.
options.enabledbooleantrueWhile 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.