[]previous and next page

Sortable, filterable table

Build a table that filters with a text input, sorts by clicking a column header, reports the sort with aria-sort, and reorders its existing rows instead of recreating them.

For agentsUpdated

This recipe builds a compact data table. It demonstrates useMemo for a filter step that feeds a sort step, keyed For rows that are moved rather than rebuilt when the order changes, aria-sort on column headers, and an empty state through the fallback prop of For. The larger version in examples/data-table adds pagination, selection and editing on the same ideas.

Two memos, one pipeline#

The visible rows are props.rows, filtered by the query, then sorted. Each step is a useMemo:

  • filtered reads props.rows and the query. Changing the sort does not re-run it.
  • sorted reads filtered() and the sort state. It copies before sorting, because Array.prototype.sort mutates and filtered() may be props.rows itself.

Sorting is stable and the direction is applied to the comparison result, so rows that compare equal keep the order of props.rows in both directions.

The component#

A header click sets the column ascending; clicking the active column flips the direction. aria-sort is "none" on every sortable header that is not the active one, which is what screen readers need to announce a sortable column.

src/data-table.tsxtsx
import { useMemo, useStore } from "@shadow-js/core";
import { For } from "@shadow-js/web";

export interface Employee {
  id: number;
  name: string;
  role: string;
  salary: number;
}

type SortKey = "name" | "role" | "salary";
type Direction = "ascending" | "descending";

interface Sort {
  key: SortKey;
  direction: Direction;
}

const COLUMNS: readonly { key: SortKey; label: string }[] = [
  { key: "name", label: "Name" },
  { key: "role", label: "Role" },
  { key: "salary", label: "Salary" },
];

function compare(a: Employee, b: Employee, key: SortKey): number {
  if (key === "salary") return a.salary - b.salary;
  return a[key].localeCompare(b[key]);
}

export interface DataTableProps {
  rows: readonly Employee[];
}

export function DataTable(props: DataTableProps) {
  const [query, setQuery] = useStore("");
  const [sort, setSort] = useStore<Sort | undefined>(undefined);

  const filtered = useMemo(() => {
    const needle = query().trim().toLowerCase();
    if (needle === "") return props.rows;
    return props.rows.filter(
      (row) => row.name.toLowerCase().includes(needle) || row.role.toLowerCase().includes(needle),
    );
  });

  const sorted = useMemo(() => {
    const current = sort();
    if (current === undefined) return filtered();
    const sign = current.direction === "ascending" ? 1 : -1;
    return [...filtered()].sort((a, b) => sign * compare(a, b, current.key));
  });

  const ariaSort = (key: SortKey): "none" | Direction => {
    const current = sort();
    return current?.key === key ? current.direction : "none";
  };

  const toggle = (key: SortKey): void => {
    setSort((current) => ({
      key,
      direction:
        current?.key === key && current.direction === "ascending" ? "descending" : "ascending",
    }));
  };

  return (
    <div>
      <input
        type="search"
        aria-label="Filter employees"
        value={query()}
        onInput={(event) => setQuery(event.currentTarget.value)}
      />
      <p role="status">
        {sorted().length} of {props.rows.length} shown
      </p>
      <table>
        <thead>
          <tr>
            <For each={COLUMNS}>
              {(column) => (
                <th scope="col" aria-sort={ariaSort(column.key)}>
                  <button type="button" onClick={() => toggle(column.key)}>
                    {column.label}
                  </button>
                </th>
              )}
            </For>
          </tr>
        </thead>
        <tbody>
          <For
            each={sorted()}
            key={(row) => row.id}
            fallback={
              <tr>
                <td colSpan={3}>No employees match</td>
              </tr>
            }
          >
            {(row) => (
              <tr>
                <td>{row().name}</td>
                <td>{row().role}</td>
                <td>{row().salary}</td>
              </tr>
            )}
          </For>
        </tbody>
      </table>
    </div>
  );
}
  • key={(row) => row.id} makes For match rows by id. When sorted() returns the same ids in a new order, For keeps each row's DOM and moves it; only ids that appear or disappear create or dispose rows. The row callback receives an accessor, so it reads row().name.
  • The header cells come from a constant array, so they use unkeyed For, where the callback receives the item itself.
  • fallback renders while sorted() is empty. In a tbody it has to be a table row.
  • aria-sort={ariaSort(column.key)} is a binding: it re-evaluates when sort changes, and only the attribute is rewritten.

Using it#

src/demo.tsxtsx
import { DataTable, type Employee } from "./data-table";

const ROWS: Employee[] = [
  { id: 1, name: "Ada Lovelace", role: "Engineer", salary: 9000 },
  { id: 2, name: "Grace Hopper", role: "Admiral", salary: 12000 },
  { id: 3, name: "Alan Turing", role: "Researcher", salary: 10000 },
];

export function Demo() {
  return <DataTable rows={ROWS} />;
}

Sorting changes the order of the existing rows. Filtering removes nonmatching rows; rows that stay visible keep their DOM nodes. Because sorting reads the filtered result, changing the sort does not rerun filtering.

Related: useMemo, useStore, For, fireEvent.