Skip to content
Chapter 27Lesson 5

Four states, not one

A design discipline for React data surfaces that models loading, empty, error, and populated as one typed state contract built on shadcn primitives and accessible live regions.

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 sitting 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. This lesson is the discipline that composes them into a component that is genuinely finished rather than only half-built.

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 of them is on screen at a time.

The one that trips people up is empty. Both loading and empty show nothing on screen, 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. They also need different announcements: “Loading…” versus a heading a screen reader can actually read. Conflate them and you get the most common version of this bug, a spinner that spins forever on an account that has no data, because the code is still waiting for rows that are never coming.

Before you read on, sort each scenario into the state it actually describes.

Each line describes one moment in a component's life. Sort it into the state it belongs to. Drag each item into the bucket it belongs to, then press Check.

Loading Before the answer arrives
Empty The answer arrived, and it's 'none'
The request is still in flight
We don’t yet know whether there are any rows
The placeholder skeleton is on screen
The query succeeded and returned zero rows
A brand-new account that has never created an invoice
The active filter matched nothing

One note before any code: this is a lesson about components, not about fetching. The four-state contract is a property of the component itself, independent of where its data comes from. 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 down to a single value that tells the component which state it is in. Later chapters wire that value to real server data: the App Router and streaming chapter covers route-level loading with Suspense, and you build the full server-data lifecycle when you write a real URL-driven list view. 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 made visible, and the four-state contract is its best demonstration.

Look at what the union buys you. Each variant carries only the data that state owns: the error variant has an error and no invoices, the populated variant has invoices and no error. The shapes the booleans permitted but the domain forbids, such as loading and errored and full all at once, are now unwritable, because no variant has that shape. Three independent booleans can express eight combinations (two to the third) for four real states, and the extra four were never anything but bugs. The union closes that gap to zero.

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 already met this narrowing when you learned discriminated unions in the TypeScript chapters, and rendering UI is where you reach for it most. If you want the compiler to force you to handle every state, a default branch that assigns state to a never turns a forgotten case into a compile error rather than a blank screen. Recognize that trick when you see it; this lesson won’t drill 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: skeletons, spinners, and what you know

Section titled “The loading state: skeletons, spinners, and what you know”

When the shape of what’s coming is known (table rows, a card grid, a list, a dashboard widget) the 2026 default is a skeleton . shadcn’s Skeleton is a plain <div> with animate-pulse bg-muted rounded. Unlike most primitives, it’s pure Tailwind and cn, with no Radix behind it. You install it the same way as everything else, pnpm dlx shadcn@latest add skeleton, and compose it.

The principle that makes a skeleton worth shipping is also 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 as you typically render. If the skeleton is a generic grey blob sized differently from the table that replaces it, the page shifts the instant data arrives, which is exactly the jank the skeleton was supposed to prevent. Build the skeleton from the populated layout, not as an afterthought.

Here is the skeleton for the invoices table. Three things are happening in it, and they matter in sequence.

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 here and the table swaps in without nudging a single 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 is for sighted users, the live region is for everyone else, and you ship both every time.

1 / 1

A skeleton is the right tool when you know the shape. When you don’t, reach for a spinner instead. shadcn now ships a first-class Spinner primitive (a spinning Lucide loader with animate-spin) for short, indeterminate work where the layout shape is genuinely unknown or irrelevant, like a button mid-submit or a quick action firing. The distinction is worth keeping in your head as a single line:

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 (this one is Radix-backed), driven by a real percentage. When the progress is unknown, use an indeterminate Progress or one honest status line (“Importing…”). One rule admits no exceptions: 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 the empty state. For the invoices table that’s “No invoices yet,” “Create your first invoice to get started,” and a New invoice button. The CTA is what separates a useful empty state from a polite shrug. An empty state with no action is 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: not a loose block, but a specific set of parts you assemble.

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 is different 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.

Name all four, because 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. The empty state generally does not need a live region. It’s a settled state, normal page content, so a real semantic heading (EmptyTitle renders one) and a focusable, labeled CTA button are enough for a screen reader to perceive and act on it. Loading and error are different: they’re async changes, things that appear after the page has already settled, which is exactly what a live region exists to announce. So loading and error get live regions, and empty doesn’t. The difference isn’t arbitrary: it tracks whether the state is a change the user needs to be told about, or just content that’s 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, when 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>
);

The correlationId and retry are abstracted props here. Wiring a real refetch and generating a real reference id belong to the data-fetching and observability chapters. What matters now is the shape: this is exactly what case 'error' renders, and it does two jobs at once.

The first job is recovery, the second is diagnosis. “Something went wrong” with neither (no retry, no reference) is the error state that helps nobody: the user can’t recover, and support can’t find the failure. So give the user a way to recover, which is the Retry, and a diagnostic handle, which is a correlation id the user can read off to support. Keep the user-facing message human and the operator-facing detail (the id) terse and copyable. That split between what the user reads and what the operator traces is a habit you’ll formalize later. Never dump a raw stack trace at the user.

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

This isn’t a violation of the live-region pre-mount rule from the ARIA 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, since loading gives way to error the moment the request rejects. Both role="alert" and role="status" are designed to announce on insertion for exactly this case: a region that arrives as an async change is announced precisely because it just appeared. Swapping whole regions in a state machine and mutating text inside a long-lived region are two different situations, and these roles are the tool for the first.

One distinction confuses everyone the first time, so hold onto it. The error this lesson handles is a data error: the query rejected, you caught it, and you render the error variant of your state. That is different from a React error boundary (Next.js’s error.tsx), which catches an exception thrown during render at a tree boundary. They’re two different mechanisms for two different failures: a data error flows through your status union as state, while a render exception is thrown and caught by the framework’s boundary. Alert does not replace error.tsx, and error.tsx does not replace your error state. You’ll meet error boundaries properly in the security and resilience chapter. For now, just know they sit at different layers.

So far the four states have been a static list. They are actually a small machine, and the transitions are the point.

A component doesn’t merely have four states; it moves between them on events. It starts in loading. If the request resolves with rows it goes to populated, if it resolves with none it goes to empty, and if it rejects it goes to error. From populated, a refetch sends it back to loading. From error, a retry sends it back 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 on that diagram hides a real decision: populated back to loading on a refetch. The naive move is to flip a loading flag, which replaces the populated table with a full skeleton and flashes the user back to a loading shell they already passed. That’s jank. Once data has loaded once, 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 discipline starts here. 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. The pattern gets factored 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, so no screen re-implements the ladder by hand.

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

Now do it once for real. 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 accessibility twin.

The shadcn primitives aren’t available in this runtime, so the starter includes tiny local stubs that stand in for Skeleton, Empty, and Alert. They’re marked clearly. This 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 round on the facts that carry this lesson. Several are deliberately counter-intuitive, because that’s where the bugs live.

    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.

    You now have the contract: four states, named before you write the populated view; one status value driving the render so the compiler forbids the impossible combinations; and each visual state paired with the announcement a screen-reader user needs. Hold it from the first commit, and the bugs that make a SaaS feel unfinished never get written in the first place: the void on a fresh account, the page that jumps on every load, the error that tells support nothing.