Skip to content
Chapter 27Lesson 5

The four states of a data surface

Model a React data surface's loading, empty, error, and populated states as one discriminated union, each paired with an accessible shadcn primitive.

The invoices table looks finished. Rows render, the columns line up, it is pixel-perfect in the demo against your seeded data. You ship it. Then a real user opens it on a brand-new account and sees a bare header above a void: no rows, no “create your first invoice,” nothing to do. Another opens it on hotel wifi and stares at a blank rectangle that, a beat later, snaps into a full table and shoves the rest of the page down. A third opens it the moment your API hiccups and gets a spinner that never stops.

The table was only ever designed for one of its states, but it has four.

By the end of this lesson you will design all four states of any data surface, loading, empty, error, and populated, as a contract you hold from the first commit. You will drive them from a single state value instead of a pile of booleans, and pair each visual state with what a screen-reader user needs to perceive it. You already have the pieces: the shadcn primitives from the start of this chapter, and the live-region vocabulary from the ARIA lesson.

Every list, table, card, and widget that shows data lives in exactly one of four states at any moment:

  • Loading. The data is in flight. There is nothing to show yet because the answer hasn’t arrived.
  • Empty. The data loaded successfully, and there is genuinely none: a fresh account, or a filter that matched nothing.
  • Error. The data didn’t load. The request failed.
  • Populated. The happy path. Data is in hand and you render it.

The four states are mutually exclusive, so exactly one is on screen at a time.

The one that trips people up is empty. Both loading and empty show nothing, so empty gets lumped in with loading, but they are opposites. Loading is the state before the answer arrives; empty is the answer, and the answer is “none.” They need different pixels, a placeholder skeleton versus an onboarding prompt, and different announcements, “Loading…” versus a heading a screen reader can read. Conflate them and you get the most common version of this bug: a spinner that spins forever on an account with no data, because the code is still waiting for rows that are never coming.

One note before any code: this is a lesson about components, not about fetching. The four-state contract belongs to the component itself, whatever its data source. A Server Component awaiting a query, a Client Component reading a cache, and a use(promise) boundary all surface these same four states, so you will not see fetch or await here. The data source is abstracted to a single value that tells the component which state it is in; later chapters wire that value to real server data. This lesson teaches the shape every one of them renders.

Here is the habit worth forming now: before you write the populated view, name the other three. A component that can only render populated is one-quarter done.

Three booleans is the bug; one status is the cure

Section titled “Three booleans is the bug; one status is the cure”

You have almost certainly written the component below, and almost certainly watched it rot. It is the natural first thing to reach for, and it is the bug at the center of this lesson. You track loading, error, and data as three independent pieces of state, then render them with a ladder of ifs.

invoices-panel.tsx
'use client';
type InvoicesPanelProps = {
isLoading: boolean;
error: Error | null;
invoices: Invoice[];
};
export const InvoicesPanel = ({
isLoading,
error,
invoices,
}: InvoicesPanelProps) => {
if (isLoading) return <TableSkeleton />;
if (error) return <ErrorCard error={error} />;
if (invoices.length === 0) return <EmptyState />;
return <InvoiceTable invoices={invoices} />;
};

Three independent booleans, and a render ladder whose correctness depends on the order of its checks. Swap the error and length === 0 lines and an empty state paints on top of a real error. Worse, the types let you set isLoading: true, a non-null error, and a full invoices array all at once, a combination the domain forbids but the booleans happily allow. And “loading while still showing the rows you already have” has no home here at all.

The cure is a discriminated union . Instead of three booleans you keep one status field, the discriminant , and the union lists the four shapes the state can take, one per line. This is the project’s “prefer discriminated unions over flag booleans” rule, and the four-state contract is its best demonstration.

The union erases the impossible states the booleans allowed. Three independent booleans express eight combinations for four real states; the extra four were never anything but bugs, and no variant has their shape, so they become unwritable.

The compiler then works for you. Switch on state.status, and inside each case TypeScript narrows the object to that one variant: in case 'populated', state.invoices is an Invoice[], with no optional chaining to write. You met this narrowing when you learned discriminated unions in the TypeScript chapters, and rendering UI is where you reach for it most. To make the compiler force you to handle every state, a default branch that assigns state to a never turns a forgotten case into a compile error; recognize the trick when you see it.

With the state union in place, the next three sections walk through the states that surround the happy path. Each comes with the shadcn primitive that ships its shell and the accessibility twin that makes it perceivable.

The loading state: skeleton, spinner, or progress

Section titled “The loading state: skeleton, spinner, or progress”

When you know the shape of what’s coming (table rows, a card grid, a list, a dashboard widget) the default is a skeleton . shadcn’s Skeleton is a plain <div> with animate-pulse bg-muted rounded. Install it like any other primitive, pnpm dlx shadcn@latest add skeleton, and compose it.

The principle that makes a skeleton worth shipping is the one most often skipped: the skeleton must occupy the same box the real content will. Same row heights, same column widths, same number of placeholder rows you typically render. If it’s a generic grey blob sized differently from the table that replaces it, the page shifts the instant data arrives, the exact jank the skeleton was meant to prevent. Build the skeleton from the populated layout.

export const InvoiceTableSkeleton = () => (
<div role="status" className="space-y-3">
<span className="sr-only">Loading invoices</span>
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex gap-4">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-20" />
</div>
))}
</div>
);

Render a fixed count of placeholder rows: five, matching how many invoice rows the real table usually shows. The shape on screen while loading is the shape that arrives.

export const InvoiceTableSkeleton = () => (
<div role="status" className="space-y-3">
<span className="sr-only">Loading invoices</span>
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex gap-4">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-20" />
</div>
))}
</div>
);

Each cell is a Skeleton sized to its real column: a narrow block for the amount, a wide one for the description. Match the populated widths and the table swaps in without nudging a pixel.

export const InvoiceTableSkeleton = () => (
<div role="status" className="space-y-3">
<span className="sr-only">Loading invoices</span>
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex gap-4">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-20" />
</div>
))}
</div>
);

A screen reader perceives nothing of a silent pulse. role="status" makes the wrapper a polite live region (it implies aria-live="polite"), and the sr-only “Loading invoices” is the text it announces. The skeleton serves sighted users, the live region serves everyone else, and you ship both every time.

1 / 1

When you don’t know the shape, reach for a spinner. shadcn ships a Spinner primitive (a spinning Lucide loader with animate-spin) for short, indeterminate work where the layout shape is unknown or irrelevant, like a button mid-submit or a quick action firing.

A spinner says “something is happening.” A skeleton says “this is coming.”

For long operations where progress is actually measurable, like a file upload or a bulk import, there’s a third affordance: shadcn’s Progress (Radix-backed), driven by a real percentage. When progress is unknown, use an indeterminate Progress or one honest status line (“Importing…”). Never animate a fake progress bar to look busy: it lies, it desyncs from reality, and users learn not to trust it.

Empty is the state juniors under-build the most, because it looks like nothing needs doing. In fact it needs the most copy of any of the four.

A useful empty state does a job rather than sitting blank. It has four parts: an icon or small illustration, a heading that names what’s missing, a one-line description, and the part that does the real work, a primary CTA that resolves it. For the invoices table that’s a FileText icon, “No invoices yet,” “Create your first invoice to get started,” and a New invoice button. Drop the CTA and you have a dead end, and a dead end on a fresh account is where new users quietly leave.

shadcn ships this as a composed Empty primitive: a specific set of parts you assemble, not a loose block.

no-invoices.tsx
10 collapsed lines
import { FileText } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty';
export const NoInvoices = () => (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<FileText />
</EmptyMedia>
<EmptyTitle>No invoices yet</EmptyTitle>
<EmptyDescription>Create your first invoice to get started.</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button>New invoice</Button>
</EmptyContent>
</Empty>
);

Now the part that separates a real empty state from a generic one: the copy must differ by cause. The same Empty primitive renders four different messages, because the right next action changes each time.

no-invoices.tsx
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<FileText />
</EmptyMedia>
<EmptyTitle>No invoices yet</EmptyTitle>
<EmptyDescription>Create your first invoice to get started.</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button>New invoice</Button>
</EmptyContent>
</Empty>
No data has ever existed. The CTA onboards: it points at the one action that fills the screen.

Those are two of four. The full set:

  1. First-run empty. No data has ever existed. The CTA onboards: “Create your first invoice.”
  2. Filtered empty. Data exists, but the active filter matched none of it. “No invoices match your filters” plus a Clear filters action, not an onboarding CTA, because the data is there and the filter is the obstacle.
  3. Search empty. A query returned nothing. Echo the query back, and offer to broaden or correct it.
  4. Permission empty. Data exists, but this user can’t see it. Explain access, not absence: “You don’t have access to this workspace’s invoices.” Never imply there’s nothing there when there is.

The classic mistake is shipping the first-run CTA into a filtered-empty state: telling a user who has 200 invoices behind an “Overdue” filter to “create your first invoice.” That copy is wrong in a way that makes the product feel like it isn’t paying attention.

An account with 200 invoices applies the Overdue filter and none match, so the panel renders its empty state. Which copy belongs here?

No invoices yetCreate your first invoice.

No invoices match your filtersClear filters.

No results.

You don’t have access to these invoices.

Empty differs from loading and error in one way worth drawing out, because it sharpens the accessibility model: empty generally does not need a live region. It’s settled content, normal page content, so a real semantic heading (EmptyTitle renders one) and a focusable, labeled CTA are enough for a screen reader to perceive and act on it. Loading and error are async changes, things that appear after the page has settled, which is exactly what a live region exists to announce. The rule tracks whether the state is a change the user needs to be told about, or just content that’s already there.

When a leaf component fails to load its data, say a dashboard card whose query rejected, it doesn’t crash the page. It shows a compact, recoverable error card with three things: a clear message, a Retry action, and, where you can, a code support can trace. shadcn’s Alert with variant="destructive" is the canonical container.

error-card.tsx
type ErrorCardProps = {
correlationId: string;
retry: () => void;
};
export const ErrorCard = ({ correlationId, retry }: ErrorCardProps) => (
<Alert variant="destructive" role="alert">
<CircleAlert />
<AlertTitle>Couldn't load invoices</AlertTitle>
<AlertDescription>
<p>Something interrupted the request. Try again in a moment.</p>
<p className="text-xs text-muted-foreground">Reference: {correlationId}</p>
<Button variant="outline" size="sm" onClick={retry}>
Retry
</Button>
</AlertDescription>
</Alert>
);

correlationId and retry are abstracted props; wiring a real refetch and a real reference id comes later. What matters now is the shape: this is exactly what case 'error' renders, and it does two jobs at once.

The first is recovery, the second is diagnosis. “Something went wrong” with neither helps nobody, since the user can’t recover and support can’t find the failure. The Retry gives the user a way out; a correlation id gives support a handle they can trace. Keep the user-facing message human and the id terse and copyable, and never dump a raw stack trace at the user.

The message lives in a role="alert" region so assistive technology announces it the moment it appears, and the Retry button is focusable and labeled. A failed load is a real alert, not a status, so reaching for alert here is exactly right; reaching for it on routine updates is the misuse the ARIA lesson warned about.

This doesn’t break the live-region pre-mount rule from that lesson, even though the region and its text mount together. That rule guards against toggling a persistent polite region in and out of the DOM and expecting it to announce. Here the whole region is swapped in as the state transition, the moment loading gives way to error. Both role="alert" and role="status" are built to announce on insertion for exactly this case: a region that arrives as an async change is announced because it just appeared. Swapping whole regions in a state machine and mutating text inside a long-lived region are different situations, and these roles serve the first.

One distinction trips everyone up at first. The error here is a data error: the query rejected, you caught it, and you render the error variant of your state. A React error boundary (Next.js’s error.tsx) is different: it catches an exception thrown during render at a tree boundary. A data error flows through your status union as state; a render exception is thrown and caught by the framework. Alert does not replace error.tsx, nor the reverse; they sit at different layers, and you’ll meet error boundaries properly later.

So far the four states have been a static list, but they form a small machine, and the transitions are the point. A component doesn’t just have four states; it moves between them on events. It starts in loading: resolving with rows goes to populated, resolving with none goes to empty, rejecting goes to error. A refetch from populated and a retry from error both return to loading. Walk the machine below: each state shows what it renders and how it’s announced, and each branch is the event that moves you on.

The four-state machine
stateDiagram-v2
  direction LR
  [*] --> loading
  loading --> populated : resolved with rows
  loading --> empty : resolved, none
  loading --> error : rejected
  populated --> loading : refetch
  empty --> loading : refetch
  error --> loading : retry

One transition hides a real decision: populated back to loading on a refetch. The naive move flips a loading flag, which replaces the table with a full skeleton and flashes the user back to a shell they already passed. Once data has loaded, a subsequent fetch should usually keep the stale data on screen and signal the refresh subtly (a thin Progress bar at the top, a small Spinner in the refresh button) rather than dropping to a skeleton. The server-state library you’ll meet later does stale-while-refetch by default, so once you’re on it, you get this for free.

You won’t re-implement this ladder in every component. It factors into a small wrapper, call it a DataPanel, that takes the four slots and the status and renders the right one:

data-panel.tsx
type DataPanelProps<T> = {
state: DataState<T>;
loading: ReactNode;
empty: ReactNode;
error: (error: Error, retry: () => void) => ReactNode;
children: (data: T) => ReactNode;
};

Every data surface in the projects ahead is one of these. You don’t need to write it today; just recognize the shape: one wrapper, four slots, the status choosing which renders.

There’s a fifth state worth learning to see: optimistic state. A mutation can show its after state immediately while the request is in flight, then roll back if it fails, with a role="alert" announcing the rollback (“Failed to save, reverted”). It belongs to a later chapter; for now, just file it next to the other four.

The component below tracks three booleans and renders only some of its states: no empty branch, no accessibility wiring. Refactor it to a single status discriminated union and render all four states, each with its primitive and its paired announcement.

The shadcn primitives aren’t available in this runtime, so the starter includes labelled local stubs for Skeleton, Empty, and Alert. The exercise is about the state logic and the accessibility wiring, not importing a library.

Refactor this panel from three booleans to one status discriminated union. Replace the prop bag with a single state prop typed InvoicesState, then switch on state.status to render all four states: a Skeleton for 'loading', an Empty with a heading and a CTA button for 'empty', an Alert with a Retry button for 'error', and the table for 'populated'. Put role="status" on the loading region and role="alert" on the error region. App renders all four panels at once — rewrite the four scenarios in the states array as union values and pass state to each panel so the preview fills in.

Preview
    Reference solution

    One state prop, the union listing the four shapes, and a switch over state.status whose branches each carry only that state’s data. Loading carries role="status", error carries role="alert", empty gets a real heading and a CTA, and App passes one union value per panel.

    type InvoicesState =
    | { status: 'loading' }
    | { status: 'empty' }
    | { status: 'error' }
    | { status: 'populated'; invoices: Invoice[] };
    const InvoicesPanel = ({ state }: { state: InvoicesState }) => {
    switch (state.status) {
    case 'loading':
    return (
    <div role="status">
    <span className="sr-only">Loading invoices</span>
    <Skeleton />
    </div>
    ); // role="status" could also sit on <Skeleton role="status" /> — the stubs forward it
    case 'empty':
    return (
    <Empty>
    <h3 className="font-medium">No invoices yet</h3>
    <p className="text-sm text-gray-500">Create your first invoice to get started.</p>
    <button className="mt-2 rounded bg-gray-900 px-3 py-1.5 text-white">New invoice</button>
    </Empty>
    );
    case 'error':
    return (
    <Alert role="alert">
    <p>Couldn't load invoices.</p>
    <button onClick={retry} className="mt-2 rounded border px-3 py-1.5">Retry</button>
    </Alert>
    );
    case 'populated':
    return (
    <table>
    <tbody>
    {state.invoices.map((invoice) => (
    <tr key={invoice.id}>
    <td>{invoice.client}</td>
    </tr>
    ))}
    </tbody>
    </table>
    );
    }
    };
    const states: { label: string; state: InvoicesState }[] = [
    { label: 'loading', state: { status: 'loading' } },
    { label: 'empty', state: { status: 'empty' } },
    { label: 'error', state: { status: 'error' } },
    { label: 'populated', state: { status: 'populated', invoices } },
    ];
    export function App() {
    return (
    <div className="space-y-4">
    {states.map(({ label, state }) => (
    <section key={label} data-state={label} className="rounded border p-3">
    <p className="mb-2 text-xs font-mono text-gray-500">{label}</p>
    <InvoicesPanel state={state} />
    </section>
    ))}
    </div>
    );
    }

    A quick check on the facts behind this lesson. Several are deliberately counter-intuitive, because that’s where the bugs hide.

    Each claim is about the four-state contract — several are deliberately counter-intuitive. Mark each statement True or False.

    Empty and loading are the same state — both show nothing.

    Opposite states. Loading is before the answer; empty is the answer, and it’s “none”. They need different UI and different announcements.

    Three independent booleans can represent combinations the four real states never allow.

    Two-to-the-third is eight combinations for four real states. The extra four — like loading and errored and full — are impossible states the union forbids by construction.

    A loading skeleton should match the dimensions of the content it stands in for.

    Otherwise the page shifts when data arrives — the exact layout jank the skeleton was meant to prevent.

    A spinner is the right default for content whose layout shape is known.

    A skeleton is. A spinner is for short, indeterminate work where the shape is unknown or irrelevant. Skeleton says “this is coming”; spinner says “something is happening”.

    Every empty state should show the same “create your first…” onboarding CTA.

    The copy must differ by cause. A filtered empty needs “Clear filters”; a permission empty explains access. Onboarding copy on a filtered list tells a 200-invoice user to start from zero.

    A failed data load should be announced with role="status".

    A failed load is a genuine error — role="alert". status is for routine, non-urgent updates.

    A React error boundary (error.tsx) and a data-fetch error state are the same mechanism.

    Different layers. An error boundary catches an exception thrown during render; the data-error state is a value flowing through your status union.

    When refetching data you’ve already loaded, you should replace the populated view with a full skeleton.

    That flashes the user back to a shell they passed. Keep the stale data on screen with a subtle refresh indicator instead.

    Hold the four-state contract from the first commit and the bugs that make a web app feel unfinished never get written: the void on a fresh account, the page that jumps on every load, the error that tells support nothing.