Skip to contentAsheeUIAsheeUI

A data table

Columns, search, paging and row actions, and the four states a list page answers for.

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

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, 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:

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.

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

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

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

{
  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). 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

<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 Enter and Space, 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

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:

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.

{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 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 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 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 covers which surface says what.
  • A clickable row is focusable and takes Enter and Space, 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 for every prop, the registered values and what the composition draws.
  • Table for the primitive underneath, when the furniture is not wanted.
  • A custom theme for the palette this table is wearing.