Skip to content
Get the beta

Search the docs

Start with the beta
Installation
Build your first page
Thinking in Turtlekit
Apps, websites and embeds
Components or native elements
Colour
Typography
Layout
Spacing, icons and shape
Components
Root
Layout
Typography
Code block
Button
Icon button
Button group
Call to action
Toolbar
Field
Text field
Text area
Select
Combobox
Date input
Save bar
Newsletter signup
File upload
Calendar
One-time code
Checkbox
Radio group
Switch
Segmented control
Toggle
Slider
Badge
Card
Data list
Item
Icon tile
Num
Time
Metric
Accordion
FAQ
Avatar
Media
Scroll area
Resizable
Carousel
Image slider
Logo cloud
Testimonials
Table
Data table
Chart
Site header
Site footer
Tabs
Page header
Breadcrumb
Pagination
Sidebar
Navigation menu
Callout
Error summary
Toast
Empty state
Skeleton
Progress
Spinner
Tooltip
Dialog
Popover
Hover card
Dropdown menu
Context menu
Menubar
Command
Elements area
Visually hidden
Error boundary
Design rules
Staying within the kit
Layout
Icons
Images and media
Typography
Readability
Colour
Composition
Accessibility
Elements
Motion
Interaction
AI pitfalls
Pricing
React design patterns
Single responsibility in React components
Presentational and container components in React
Children, function children and render props in React
The variant pattern in React
Compound components in React
Higher-order components in React
State colocation in React
Lifting state up in React
Deriving state during render
One status instead of many booleans
Adjusting state when a prop changes
Reading context with use() in React 19
Context selectors in React
Custom hooks and hook composition in React
One effect per concern in React
The latest ref pattern in React
Effect Events with useEffectEvent in React
Detecting the first render in React
Exposing methods with useImperativeHandle in React
Debouncing input in React
useDeferredValue in React
Loading components on demand in React
Virtualising long lists in React
Guard clauses and early returns in React
Skeleton placeholders in React
Rendering partial data in React
Empty states in React
Error boundaries in React
Reading async data with use() and Suspense in React
useTransition and Actions in React
Optimistic UI updates with useOptimistic
useActionState and form Actions in React
useSyncExternalStore in React
Ref as a prop and ref cleanup in React 19
Accessible ids with useId in React
Portals in React
Roving focus and keyboard navigation in React
Controlled and uncontrolled components in React
The state reducer and prop getters in React
Headless components and the provider pattern in React
The polymorphic as prop in React

Appearance

These change every page and preview on this site.

Empty states in React

Turtlekit Editorial Team

A bookkeeper signed up on a Monday, imported last year’s clients and opened Invoices to start the month. The page showed its heading and nothing under it. She took it for a failed import, imported the clients again, and wrote to support when every one of them appeared twice.

Nothing had failed. She had no invoices because she hadn’t sent one yet, and the page had no words for that. The same blank appeared when a search matched nothing, and, because the list turned errors into an empty array, when the invoices couldn’t load at all. Three different situations met one empty panel.

An empty state is what a screen shows in place of content when there is none: what is empty, why, and the next step from there. Each reason gets its own, because “no invoices yet”, “nothing matches that search” and “your invoices couldn’t load” send people in different directions.

Three situations, one blank

The list as it shipped:

useEffect(() => {
  loadInvoices()
    .then(setInvoices)
    // Avoid: a failure becomes "you have no invoices"
    .catch(() => setInvoices([]));
}, [loadInvoices]);

return (
  <section aria-label="Invoices">
    <h2>Invoices</h2>
    {/* Avoid: no rows renders a heading over nothing */}
    <ul>
      {invoices.map((invoice) => (
        <li key={invoice.id}>{invoice.number}</li>
      ))}
    </ul>
  </section>
);

Two lines did the damage. The catch turns a failed request into an empty array, so the page tells someone with two hundred invoices that they have none, and they start to wonder what was deleted. And an empty array renders a heading over nothing, which says neither why nor what to do. A screen reader hears the heading and a list with no items, and stops there.

Empty is an answer

Every list answers a question: what is here? When the answer is “nothing”, the screen still owes a sentence, and the right sentence depends on why. An empty list and a failed load must never look the same.

The reasons a list is empty, and what each one says
SituationWhat it saysThe next step
First useNo invoices yet, and what will appear hereCreate an invoice
No resultsNo invoices match the search, and what search looks atClear the search
All doneNothing overdue: every sent invoice is paid or not yet dueNone needed; it’s good news
No accessWho can see these invoicesAsk that person for access
Failed loadNot an empty state: what failed, in an errorTry again

In code, the first two come from two questions asked of two different lists. Every invoice decides whether this is first use; the search results decide whether nothing matched:

invoices.length === 0NoInvoicesYetshown.length === 0NoMatchesthe invoices
Two questions, asked of two different lists: every invoice decides first use, the search results decide no results. A failed load never reaches either question.

An empty state for each reason

The list keeps failures out, then writes the two empty states it can reach, then chooses between them during render.

Keeping failures out of the list

The list takes invoices only as a real answer from the server. A failed load is handled above it, as an error with a way to try again, and never arrives as an empty array. That one change removes the most damaging blank of the three, because no rendering can tell an empty array that means “none” from one that means “unknown”.

First use: what will live here, and how to start

function NoInvoicesYet({ onCreate }: { onCreate: () => void }) {
  return (
    <div>
      <h3>No invoices yet</h3>
      <p>
        Invoices you send appear here, with what each client owes and
        when.
      </p>
      <button type="button" onClick={onCreate}>
        Create an invoice
      </button>
    </div>
  );
}

“Yet” tells a new user that the empty page is the start, not a fault. The sentence says what the list will hold, which doubles as a description of what the page is for, and the button is the one action that makes the list stop being empty.

No results: what matched nothing, and the way back

function NoMatches({
  query,
  onClear,
}: {
  query: string;
  onClear: () => void;
}) {
  return (
    <div>
      <h3>No invoices match “{query}”</h3>
      <p>
        Search looks at invoice numbers and client names. Check the
        spelling, or clear the search to see every invoice.
      </p>
      <button type="button" onClick={onClear}>
        Clear search
      </button>
    </div>
  );
}

The heading repeats the search in quotes, so a typo is visible at a glance. The sentence says what the search looks at, which explains most misses, and “Clear search” goes back to every invoice in one step.

Choosing between them

export function InvoiceList({
  invoices,
  onCreate,
}: {
  /** A real answer: a failed load never arrives here. */
  invoices: readonly Invoice[];
  onCreate: () => void;
}) {
  const id = useId();
  const search = useRef<HTMLInputElement>(null);
  const [query, setQuery] = useState("");
  const words = query.trim();
  const shown = words
    ? invoices.filter((i) => matches(i, words))
    : invoices;

  function clearSearch() {
    setQuery("");
    search.current?.focus(); // the button goes with the empty state
  }

  return (
    <section aria-labelledby={id}>
      <h2 id={id}>Invoices</h2>
      {invoices.length === 0 ? (
        <NoInvoicesYet onCreate={onCreate} />
      ) : (
        <>
          <label htmlFor={`${id}-search`}>Search invoices</label>
          <input
            id={`${id}-search`}
            ref={search}
            type="search"
            value={query}
            onChange={(event) => setQuery(event.target.value)}
          />
          <p role="status">
            {words ? summary(shown.length, words) : ""}
          </p>
          {shown.length === 0 ? (
            <NoMatches query={words} onClear={clearSearch} />
          ) : (
            <ul>
              {shown.map((invoice) => (
                <li key={invoice.id}>
                  <a href={`/invoices/${invoice.id}`}>
                    {invoice.number}
                  </a>
                  {`, ${invoice.client}, ${invoice.total}`}
                </li>
              ))}
            </ul>
          )}
        </>
      )}
    </section>
  );
}

Both questions are computed during render from the invoices and the query, so neither can fall out of step with what is on screen. First use has no search field, because there is nothing to search. Clearing the search moves focus to the field, since the button that had it goes with the empty state.

The finished list

"use client";
// The invoices list. With nothing to show, it says why and offers the
// next step: create the first invoice, or clear a search that matched
// nothing.
import { useId, useRef, useState } from "react";
import { matches } from "./invoice";
import type { Invoice } from "./invoice";

function NoInvoicesYet({ onCreate }: { onCreate: () => void }) {
  return (
    <div>
      <h3>No invoices yet</h3>
      <p>
        Invoices you send appear here, with what each client owes and
        when.
      </p>
      <button type="button" onClick={onCreate}>
        Create an invoice
      </button>
    </div>
  );
}

function NoMatches({
  query,
  onClear,
}: {
  query: string;
  onClear: () => void;
}) {
  return (
    <div>
      <h3>No invoices match “{query}”</h3>
      <p>
        Search looks at invoice numbers and client names. Check the
        spelling, or clear the search to see every invoice.
      </p>
      <button type="button" onClick={onClear}>
        Clear search
      </button>
    </div>
  );
}

const summary = (count: number, query: string) =>
  count === 1
    ? `1 invoice matches “${query}”`
    : `${count === 0 ? "No" : count} invoices match “${query}”`;

export function InvoiceList({
  invoices,
  onCreate,
}: {
  /** A real answer: a failed load never arrives here. */
  invoices: readonly Invoice[];
  onCreate: () => void;
}) {
  const id = useId();
  const search = useRef<HTMLInputElement>(null);
  const [query, setQuery] = useState("");
  const words = query.trim();
  const shown = words
    ? invoices.filter((i) => matches(i, words))
    : invoices;

  function clearSearch() {
    setQuery("");
    search.current?.focus(); // the button goes with the empty state
  }

  return (
    <section aria-labelledby={id}>
      <h2 id={id}>Invoices</h2>
      {invoices.length === 0 ? (
        <NoInvoicesYet onCreate={onCreate} />
      ) : (
        <>
          <label htmlFor={`${id}-search`}>Search invoices</label>
          <input
            id={`${id}-search`}
            ref={search}
            type="search"
            value={query}
            onChange={(event) => setQuery(event.target.value)}
          />
          <p role="status">
            {words ? summary(shown.length, words) : ""}
          </p>
          {shown.length === 0 ? (
            <NoMatches query={words} onClear={clearSearch} />
          ) : (
            <ul>
              {shown.map((invoice) => (
                <li key={invoice.id}>
                  <a href={`/invoices/${invoice.id}`}>
                    {invoice.number}
                  </a>
                  {`, ${invoice.client}, ${invoice.total}`}
                </li>
              ))}
            </ul>
          )}
        </>
      )}
    </section>
  );
}

A new account sees “No invoices yet” with a sentence about what will appear and a “Create an invoice” button. With invoices, a search for “brightsde” replaces the list with “No invoices match ‘brightsde’” and a “Clear search” button; the status line says the same. Clearing it brings back every invoice with the cursor in the search field.

What makes an empty state work

The code is short. Most of the work is in the words and in where focus and announcements go.

Words that point forward

The heading says what is empty in plain words. One sentence says why, or what will happen next, and one button takes the main next step, with at most a quieter second. The words describe the page, never the person: “No invoices yet”, not “You haven’t created any invoices”. An illustration earns its place only when it says something the words don’t.

“All done” is a kind of empty

A filter for overdue invoices that matches nothing is good news. Treating it as a failed search, with “Clear filter” as the next step, would send people away from the answer they wanted. Give it its own words, “Nothing overdue”, and no action at all.

Accessibility: say it once, then hand back the keyboard

The status line is on the page from the moment the list has invoices, empty, so each change to it is read: “1 invoice matches ‘bright’”, then “No invoices match ‘brightsde’”. It is polite, so it waits for the screen reader to finish what it is saying. The empty states’ headings sit under the list’s own heading in the page outline, where someone moving by headings will look.

“Clear search” removes itself: the list returns and the empty state goes. Focus would fall back to the page, so the handler moves it to the search field, which is where the next step happens. Typing fast updates the status on every key; if your screen reader users find that busy, update it after a pause in typing, as debouncing input shows.

Pitfall

Don’t decide first use before the data has loaded. A list that starts from an empty array shows “No invoices yet” for a moment to every returning user, and some of them will read it. Keep “not loaded” as its own value, undefined or a status, and show a skeleton for the wait.

Testing each reason

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test, vi } from "vitest";
import { InvoiceList } from "./invoice-list";
import type { Invoice } from "./invoice";

const INVOICES: Invoice[] = [
  {
    id: "inv_40",
    number: "INV-0040",
    client: "Brightside Studio",
    total: "€2,400.00",
  },
  {
    id: "inv_41",
    number: "INV-0041",
    client: "Northwind Traders",
    total: "€860.00",
  },
  {
    id: "inv_42",
    number: "INV-0042",
    client: "Harbour & Co",
    total: "€1,150.00",
  },
];

test("a new account is told how to start", async () => {
  const user = userEvent.setup();
  const onCreate = vi.fn();
  render(<InvoiceList invoices={[]} onCreate={onCreate} />);
  expect(
    screen.getByRole("heading", {
      level: 3,
      name: "No invoices yet",
    }),
  ).toBeVisible();
  // Nothing to search yet, so no search field and no list.
  expect(screen.queryByRole("searchbox")).toBeNull();
  expect(screen.queryByRole("list")).toBeNull();
  await user.click(
    screen.getByRole("button", { name: "Create an invoice" }),
  );
  expect(onCreate).toHaveBeenCalledOnce();
});

test("no match says so; clearing returns to the field", async () => {
  const user = userEvent.setup();
  render(<InvoiceList invoices={INVOICES} onCreate={() => {}} />);
  const search = screen.getByRole("searchbox", {
    name: "Search invoices",
  });
  expect(screen.getByRole("status")).toBeEmptyDOMElement();

  await user.type(search, "bright");
  expect(screen.getByRole("status")).toHaveTextContent(
    "1 invoice matches “bright”",
  );
  await user.type(search, "sde");
  expect(screen.getByRole("status")).toHaveTextContent(
    "No invoices match “brightsde”",
  );
  expect(
    screen.getByRole("heading", {
      level: 3,
      name: "No invoices match “brightsde”",
    }),
  ).toBeVisible();

  await user.click(
    screen.getByRole("button", { name: "Clear search" }),
  );
  expect(search).toHaveFocus();
  expect(search).toHaveValue("");
  expect(screen.getAllByRole("listitem")).toHaveLength(3);
  expect(screen.getByRole("status")).toBeEmptyDOMElement();
});

Both pass. The first checks that a new account gets a heading, a reason and an action, and no search field. The second walks a search from a match to no match and back, and checks where focus lands.

Server rendering

An empty state is plain content, in the server’s HTML as the list would be. Decide it from the server’s answer, never from a default, so the first HTML a new user receives already says what to do. A search that runs on the server returns the empty result the same way, with the query in the page, so the heading can repeat it.

React 19 and the Compiler

Nothing here depends on a React version. With useDeferredValue on a slow filter, derive the empty state and the status from the same deferred list, so both describe what is on screen rather than what was typed. The React Compiler may memoise the filtering; it doesn’t change which state shows.

When empty space should stay empty

Not every empty part of a screen needs a message. An optional section with nothing in it, a client’s notes or a list of attachments, can leave itself out, as rendering partial data describes, rather than explain its absence on every page. A designed empty state belongs where people expected content and need to know why it isn’t there.

Small containers get small answers. Inside a picker, one line such as “No clients match” is enough; a heading, a sentence and a button would crowd out the field above it.

How Turtlekit’s EmptyState names the situation

EmptyState takes the reason as its situation: first use, no results, done, error or no access. The situation sets the default icon and the component’s role, and the title, description and actions say the rest. Inside a DataTable, it goes in empty:

"use client";
// The invoices list with Turtlekit: DataTable shows the empty state
// in place of its rows, and EmptyState says which kind of empty it
// is.
import { FileText } from "lucide-react";
import { useRef, useState } from "react";
import { Button, DataTable, EmptyState, TextField } from "turtlekit";
import type { DataTableColumn } from "turtlekit";
import { matches } from "./invoice";
import type { Invoice } from "./invoice";

const COLUMNS: DataTableColumn<Invoice>[] = [
  { key: "number", header: "Invoice" },
  { key: "client", header: "Client" },
  { key: "total", header: "Total", numeric: true },
];

type Props = { invoices: Invoice[]; onCreate: () => void };

const FIRST_USE =
  "Invoices you send appear here, with what each client owes.";

export function Invoices({ invoices, onCreate }: Props) {
  const search = useRef<HTMLInputElement>(null);
  const [query, setQuery] = useState("");
  const words = query.trim();
  const shown = words
    ? invoices.filter((i) => matches(i, words))
    : invoices;
  const firstUse = invoices.length === 0;
  return (
    <DataTable
      caption="Invoices"
      columns={COLUMNS}
      rows={shown}
      getRowId={(invoice) => invoice.id}
      getRowLabel={(invoice) => invoice.number}
      toolbar={
        firstUse ? null : (
          <TextField
            ref={search}
            type="search"
            label="Search invoices"
            value={query}
            onChange={(event) => setQuery(event.target.value)}
          />
        )
      }
      empty={
        firstUse ? (
          <EmptyState
            situation="first-use"
            icon={<FileText />}
            title="No invoices yet"
            description={FIRST_USE}
            actions={
              <Button onClick={onCreate}>Create an invoice</Button>
            }
            headingLevel={3}
          />
        ) : (
          <EmptyState
            situation="no-results"
            size="1"
            title={`No invoices match “${words}”`}
            description="Search looks at numbers and client names."
            actions={
              <Button
                variant="soft"
                onClick={() => {
                  setQuery("");
                  search.current?.focus();
                }}
              >
                Clear search
              </Button>
            }
            headingLevel={3}
          />
        )
      }
    />
  );
}

The empty state stands alone in the table: with no rows, there are no checkboxes, sort controls or row actions acting on nothing. The table reads the number of results from a live region that is always there, so “0 results” is heard when a search empties it. The search field is left out on first use, and “Clear search” returns focus to it.

Built on this pattern in Turtlekit: Combobox and Empty state.

When the empty state says the wrong thing

Three symptoms cover most empty states that mislead.

“No invoices yet” flashes before the list appears

The list renders with a default empty array before the data arrives. Keep “not loaded” distinct from “none”, and show a placeholder for the wait.

People who have invoices see the first-use state

A failed request is being turned into an empty list. Let the failure reach an error state with a way to try again.

Focus disappears after “Clear search”

The button that had focus went with the empty state. Move focus to the search field in the same handler that clears it.

Write the empty states first

Every new account sees the empty list before it sees a full one, which makes the empty state the first thing the page says to anyone. The bookkeeper’s blank page answered no question at all, so she made one up and acted on it. The finished list can’t do that. A new account is told what will live there and how to start, a search that misses says what it searched for, and a failure never arrives as an empty list in the first place.

Skeleton placeholders in React

When the list is still on its way, so it isn’t empty yet.

Deriving state during render

When which empty state to show can be computed from the list and the search.

Guard clauses and early returns in React

When the empty case ends the render and the component should return early.

Debouncing input in React

When the status line should wait for a pause in typing before it speaks.