Build a sortable, selectable data table
· 6 min read · oks-ui team
Search, sort, select, paginate — almost every admin screen needs the same data table, and almost every team ends up hand-rolling it because no single component quite covers all four. Here's the whole thing with Table, Pagination, and FormFieldSet, wired together with plain React state.
1. The shape of the data
Table takes rows as plain objects and a getRowKey function — no adapter layer, no special row type.
type Row = { id: number; name: string; email: string; role: string };
const DATA: Row[] = [
{ id: 1, name: "Ada Lovelace", email: "ada@acme.com", role: "Owner" },
{ id: 2, name: "Grace Hopper", email: "grace@acme.com", role: "Admin" },
{ id: 3, name: "Alan Turing", email: "alan@acme.com", role: "Member" },
// ...
];2. Search filters the rows, not the table
Table itself has no search box — it just renders whatever rows you give it. Filtering lives in your own state, one useMemo:
const [query, setQuery] = useState("");
const [page, setPage] = useState(1);
const filtered = useMemo(
() => DATA.filter((r) => r.name.toLowerCase().includes(query.toLowerCase())),
[query],
);3. Sorting, selection, and the table itself
Pass sortable: true on a column and Table handles the click-to-sort UI; omit onSortChange and it sorts client-side automatically — supply it and you get the descriptor back instead, for a server-side sort. selectionMode="multiple" plus selectedKeys/onSelectionChange gets you checkboxes and an indeterminate header state for free:
const [selected, setSelected] = useState<Set<number>>(new Set());
<Table
aria-label="Team members"
columns={[
{ key: "name", header: "Name", sortable: true },
{ key: "email", header: "Email" },
{ key: "role", header: "Role" },
]}
rows={pageRows}
getRowKey={(r) => r.id}
selectionMode="multiple"
selectedKeys={selected}
onSelectionChange={(keys) => setSelected(keys as Set<number>)}
/>Table throws in development if you skip it — a data table with no accessible name is unusable with a screen reader, so the component won't let you forget it.4. Pagination, wired to the same filtered list
Pagination and PaginationSummary are just told the current page, the page count, and the total — they don't know or care that the underlying data came from a search filter:
const PAGE_SIZE = 4;
const pageRows = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
<PaginationSummary page={page} pageSize={PAGE_SIZE} total={filtered.length} />
<Pagination
page={page}
pageCount={Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))}
onChange={setPage}
/>Loading, empty, and expanded states
Three props round out the states a real table needs: isLoading swaps in skeleton rows, emptyContent replaces the body when rows is empty (it already defaults to a sensible EmptyState), and renderExpandedRow adds a chevron-toggle column for a details panel under any row — no layout work required for any of the three.
Full prop reference at the Table component page, and this exact example — search, sort, select, paginate, wired end to end — as a live preview with the complete source at the data table pattern.