A list page is one table plus four decisions: what a row says, what a search reads, how many rows
a reader sees at once, and what a press on a row means. [DataTable](/components/data-display/data-table)
states the contract and every prop. This page is the order the decisions are made in, and it ends
with the states the table cannot finish the page without.

## Describe the columns

A column is data, not markup: a `header` and a `cell` renderer that receives the row.

```tsx
import { Badge, type ColumnDef } from "@asheeui/web";

interface Invoice {
  id: string;
  number: string;
  customer: string;
  issued: string;
  status: "Paid" | "Due" | "Overdue";
  total: number;
}

const columns: ColumnDef<Invoice>[] = [
  { id: "number", header: "Invoice", cell: (invoice) => invoice.number },
  { id: "customer", header: "Customer", cell: (invoice) => invoice.customer },
  { id: "issued", header: "Issued", cell: (invoice) => invoice.issued },
  {
    id: "status",
    header: "Status",
    cell: (invoice) => (
      <Badge color={invoice.status === "Overdue" ? "danger" : "secondary"}>
        {invoice.status}
      </Badge>
    ),
  },
  {
    id: "total",
    header: "Total",
    cell: (invoice) => money.format(invoice.total),
  },
];
```

Four rules cover most of it:

- **`cell` returns the node**, so a status is a [Badge](/components/data-display/badge), an
  invoice number is a link, and money goes through one formatter rather than being pasted into
  three columns.
- **`id` is the column's key, and the column's position is what it falls back to.** State it on
  every column: a list whose columns can be reordered or inserted into will otherwise re-use one
  column's key for another.
- **`header` is a node too**, so text is the norm and a sort control belongs inside it when the
  header is where a reader will look for one.
- **`rowKeyAccessor` is the row's identity.** Without it a row is keyed by its position, which is
  wrong for anything that can be filtered or sorted, and `selectedRowKey` then compares against
  an index rather than an invoice.

### The order is the page's

`DataTable` does not sort. It renders the rows in the order it is given, and it will not reorder
them behind your back, because an order is a decision about the data and only the page knows what
the data is. What a column can do is **state** the order it is already in, and that claim goes to
assistive technology as `aria-sort` on the header cell:

```tsx
const [ascending, setAscending] = useState(true);

const rows = useMemo(
  () =>
    [...invoices].sort((a, b) =>
      ascending ? a.total - b.total : b.total - a.total,
    ),
  [invoices, ascending],
);

const columns: ColumnDef<Invoice>[] = [
  { id: "number", header: "Invoice", cell: (invoice) => invoice.number },
  { id: "customer", header: "Customer", cell: (invoice) => invoice.customer },
  {
    id: "total",
    header: "Total",
    cell: (invoice) => money.format(invoice.total),
    // The order these rows are in, stated on the column that produced it.
    sortDirection: ascending ? "ascending" : "descending",
  },
];

<DataTable
  title="Invoices"
  data={rows}
  columns={columns}
  rowKeyAccessor={(invoice) => invoice.id}
  toolbar={
    <Button
      size="sm"
      variant="bordered"
      onClick={() => setAscending((value) => !value)}>
      Total: {ascending ? "lowest first" : "highest first"}
    </Button>
  }
/>;
```

Two smaller rules follow. State `sortDirection` on the one column that produced the order, not on
the others; and leave it unset on a column that was not involved, rather than `"none"`, which
claims the column is sorted with no direction, a different, and untrue, statement.

## Decide what the search reads

One function answers it: `searchAccessor` reads the text a row is matched by.

```tsx
<DataTable
  searchAccessor={(invoice) => `${invoice.number} ${invoice.customer}`}
  // …
/>
```

The search field is drawn because of that function: the table has something to search, so it
offers the control, unless `searchable` is turned off, which is the one thing the configuration
can do to hide it. The comparison is a case-insensitive substring match against the string the
accessor returns, over the rows the table was handed, the table filters the list it was given,
not the cells it drew.

That last clause is the one to get right. The accessor reads the **row**, not the formatted cell,
so searching `4820` finds an invoice whose cell reads `£4,820.00` only if the number is in the
accessor. Include the forms a reader would actually type, the reference without its prefix, the
customer, an email, and leave out the formatting.

- **A new query is a new result set**, so the table goes back to its first page. An uncontrolled
  table resets itself; a controlled one reports `onPageChange(1)`.
- **`searchLabel` names the field and is its placeholder.** State it once under
  `components.datatable` when the product searches invoices rather than everything.
- **Searching on the server is `onSearchChange` alone.** The table reports the query and draws
  the rows you hand it next. `searchDebounce` decides when a query is reported: it is registered
  `0`, which is no wait at all and a report per keystroke, and any other value waits for the
  reader to stop typing and then commits the search once, the query, the return to the first
  page and the `onSearchChange` call all land together, so a value turns a request per keystroke
  into a request per pause. State it in configuration, or on the instance for one table.
- **Give both only when you mean both.** With an accessor present the table filters the rows it
  receives, so a server that has already filtered would have its page trimmed again by substring
  match.

## Page it

`paginated` is registered `true` and `pageSize` `10`, so a table of forty rows is four pages
without a word of configuration, and the pager appears only when there is more than one page to
move between. The count under the table is a sentence rather than a control, `rowCountLabel` with
`{first}`, `{last}` and `{total}` filled in, so a product that words it differently states the
template once.

```tsx
<AsheeUIProvider
  config={{
    components: {
      datatable: {
        searchLabel: "Search invoices",
        pageSize: 25,
        emptyTitle: "No invoices yet",
        emptyDescription: "An issued invoice appears here.",
      },
    },
  }}>
  <App />
</AsheeUIProvider>
```

Three behaviours are worth knowing before the pager surprises you:

- **A page is a slice of the array the table was given.** The rows on screen come from `data`, so
  the table can page what it holds and nothing else, but the count and the page count come from
  `rowCount` when you state it, which is how a table holding one page of a remote list still
  draws every page.
- **Uncontrolled by default.** `defaultPage` is where it opens; `page` plus `onPageChange` make
  the page yours. When you own the page, own the size too: changing `pageSize` does not send the
  reader back to the first page, so set it yourself.
- **The footer is one region.** Hiding the count with `showRowCount={false}` leaves the pager;
  turning `paginated` off as well leaves no footer at all.

That is also the answer for a list the server pages. The table cannot count rows it has never
seen, so it asks for the total: `rowCount` is the size of the result set the server reported, and
from it the pager draws every page and the count sentence states `Showing 11 to 20 of 348` while
`data` stays the one page that was fetched. Hand it the page, keep `page` controlled, and let the
table's own footer do the work, there is nothing left for the page to draw.

```tsx
<DataTable
  title="Invoices"
  data={page.rows}
  columns={columns}
  rowKeyAccessor={(invoice) => invoice.id}
  rowCount={page.total}
  page={pageNumber}
  onPageChange={setPageNumber}
  onSearchChange={(query) => void refetch({ query, page: 1 })}
  isLoading={isLoading}
/>
```

State a fresh `rowCount` with every response, because a query is a new result set and the total
moves with it; a total left at the previous query's number draws pages nothing is behind. The
`pageSize` the table computes with, the registered `10` unless the instance or the configuration
says otherwise, has to be the size the server returned, or the count and the page boundaries
describe a different list. And the count is announced by the table already, because it is a
`role="status"` region, so a sentence drawn beside it would say the same thing twice.

## Act on a row

One table, one meaning for a press. Three arrangements do the job; pick the one that matches what
the press does.

### A link in a cell

```tsx
{
  id: "number",
  header: "Invoice",
  cell: (invoice) => (
    <Link href={`/invoices/${invoice.id}`}>{invoice.number}</Link>
  ),
}
```

The default, and the one to reach for first: it is a real anchor, so it can be opened in a new
tab, copied, and reached with one tab stop per row, and the destination is a URL rather than a
handler, which is what a router adapter makes cheap ([Substitution](/concepts/substitution)). The
rest of the row stays inert, and that is not a defect, a row that is a link while containing links
is two targets for one gesture.

### A clickable row

```tsx
<DataTable
  data={invoices}
  columns={columns}
  rowKeyAccessor={(invoice) => invoice.id}
  onRowClick={(invoice) => setExpanded(invoice.id)}
/>
```

Passing `onRowClick` is the whole switch: every row becomes focusable, answers to <kbd>Enter</kbd>
and <kbd>Space</kbd>, and reports the row it was given. Use it when a press opens something in
place, a detail panel, a preview, a decision with no destination of its own.

Two costs, stated so they are chosen rather than discovered: the row is announced as a row and
not as a control, so nothing tells a reader that pressing it does anything; and a control inside a
cell is inside the row, so pressing that control presses the row as well. If the row is clickable,
do not also put a link in a cell, or stop the press reaching the row in the cell's own handler.

### A chosen row

```tsx
const [chosen, setChosen] = useState<string | number>();

<DataTable
  data={invoices}
  columns={columns}
  rowKeyAccessor={(invoice) => invoice.id}
  selectedRowKey={chosen}
  onRowClick={(invoice) => setChosen(invoice.id)}
/>;

// The panel comes from the whole list, not from the page the table is showing.
const invoice = invoices.find((candidate) => candidate.id === chosen);
```

`selectedRowKey` marks one row: filled with the table's accent, and marked `aria-current` for a
reader, so the choice is announced rather than only coloured. The page owns it, the table reports
presses and marks the row it is told to mark.

- **It is compared against `rowKeyAccessor`'s value**, which is why that accessor stops being
  optional the moment a row can be chosen.
- **A key that is not on the current page marks nothing**, so derive the detail panel from the
  full list.
- **It is one row.** A set of chosen rows is a checkbox column and its own state; the table will
  not mark it.

## Say what each state is

A list page has four states, and the table answers for three of them. Knowing which is which is
the difference between a finished page and one that only renders when the request succeeds.

| State | Who draws it | What the reader is told |
| --- | --- | --- |
| Loading | `isLoading` replaces the rows with `LoadingState` | That the region is on its way, and no count of zero, because the footer goes with the rows |
| Empty | `EmptyState`, with `emptyTitle` and `emptyDescription` | That the collection has nothing in it yet, in the registered wording |
| No results | The same `EmptyState`, announced because it answers a query | That nothing matched, and that clearing the search shows everything |
| Error | Not the table's: `ErrorState`, drawn where the table would be | What failed, and a retry |

**Loading** is the state of a first load. It replaces the rows rather than dimming them, which is
the right trade when there is nothing yet worth keeping on screen and the wrong one when there is,
so pass it while the first page is on its way and leave it off for a refresh of rows already shown.

**Empty** and **no results** are one component with two meanings, and the table distinguishes them
by announcement: an empty presentation that answers a query is a status region, so the sentence is
heard; one that is part of the page's first markup stays quiet, because nothing changed. If the two
need different words, "No invoices yet" is not "Nothing matched", own the query so the copy can
follow it:

```tsx
const [query, setQuery] = useState("");

<DataTable
  data={invoices}
  columns={columns}
  rowKeyAccessor={(invoice) => invoice.id}
  searchAccessor={(invoice) => `${invoice.number} ${invoice.customer}`}
  onSearchChange={setQuery}
  emptyTitle={query ? "Nothing matched" : "No invoices yet"}
  emptyDescription={
    query
      ? "Try a different search, or clear it to see everything."
      : "An issued invoice appears here."
  }
/>;
```

The accessor is still there, so the table keeps filtering its own rows; hearing the query changes
nothing about the filtering, and gives the copy something to branch on.

**Error** belongs to the page, and it belongs where the table is: a table you cannot fill is not
worth reading around.

```tsx
{error ? (
  <ErrorState
    title="Invoices could not be loaded"
    description="The request failed, so nothing was shown."
    detail={error.message}
    onRetry={refetch}
  />
) : (
  <DataTable data={invoices} columns={columns} isLoading={isLoading} />
)}
```

[ErrorState](/components/feedback/error-state) is an `alert`, carries the retry and puts the
technical message behind a disclosure, which is what makes it a finished state rather than a
sentence. The one cost of replacing the table is that its search field goes with it, if a query
has to survive a failed request, keep the search above the table with
[SearchInput](/components/forms/search-input) and hand the table the rows the search returned.

## Accessibility checklist

- The rows are a real `<table>` with header cells, so a value is heard with its column's name;
  the [Table](/components/data-display/table) page states the structure.
- The count is a status region and an empty result that answers a query is too, so the numbers and
  the explanation are heard rather than only seen, [Announcements](/concepts/announcements)
  covers which surface says what.
- A clickable row is focusable and takes <kbd>Enter</kbd> and <kbd>Space</kbd>, but it is
  announced as a row, so the primary action also needs a route a reader is told about: the link in
  the cell.
- The chosen row is `aria-current`, not a colour, a selection that exists only as a fill is
  invisible to a reader.
- The search field is named by `searchLabel`, and the control that empties it returns focus to the
  field.
- One query means one result set: a reader who searches from page 3 is taken to page 1 rather than
  left looking at an empty page.

## Next

- [DataTable](/components/data-display/data-table) for every prop, the registered values and what
  the composition draws.
- [Table](/components/data-display/table) for the primitive underneath, when the furniture is not
  wanted.
- [A custom theme](/guides/custom-theme) for the palette this table is wearing.
