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.
| Situation | What it says | The next step |
|---|---|---|
| First use | No invoices yet, and what will appear here | Create an invoice |
| No results | No invoices match the search, and what search looks at | Clear the search |
| All done | Nothing overdue: every sent invoice is paid or not yet due | None needed; it’s good news |
| No access | Who can see these invoices | Ask that person for access |
| Failed load | Not an empty state: what failed, in an error | Try 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:
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.