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.
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:
filteredreadsprops.rowsand the query. Changing the sort does not re-run it.sortedreadsfiltered()and the sort state. It copies before sorting, becauseArray.prototype.sortmutates andfiltered()may beprops.rowsitself.
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.
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}makesFormatch rows by id. Whensorted()returns the same ids in a new order,Forkeeps 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 readsrow().name.- The header cells come from a constant array, so they use unkeyed
For, where the callback receives the item itself. fallbackrenders whilesorted()is empty. In atbodyit has to be a table row.aria-sort={ariaSort(column.key)}is a binding: it re-evaluates whensortchanges, and only the attribute is rewritten.
Using it#
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.