Data Table
Sortable, filterable, paginated, selectable table with virtualization and CSV export, on TanStack Table.
DataTable is the Tier 1 face of a TanStack Table configuration: sort by
clicking headers, a global search input above the columns that filters every
column at once, pagination with a rows-per-page selector and Button
controls, a Checkbox selection column, sticky first/last column pinning,
automatic virtualization above ~200 rows, and client-side CSV export of the
visible columns. Sort → filter → paginate compose through TanStack's row
pipeline, so any order of operations gives the same result.
Installation
import { DataTable } from "@iammikz/srui";
Usage
| Ada Lovelace | [email protected] | Admin | active | |
| Grace Hopper | [email protected] | Editor | active | |
| Alan Turing | [email protected] | Viewer | invited | |
| Katherine Johnson | [email protected] | Editor | active | |
| Margaret Hamilton | [email protected] | Admin | inactive |
const columns: ColumnDef<Person, unknown>[] = [
{ accessorKey: "name", header: "Name" },
{ accessorKey: "email", header: "Email" },
{ accessorKey: "role", header: "Role" },
];
<DataTable
columns={columns}
data={people}
selectable
filterable
pageSize={5}
onRowSelectionChange={(rows) => console.log(rows)}
/>
Props
| Prop | Type | Default | Description |
|---|---|---|---|
columns | ColumnDef<T>[] | — | TanStack column definitions |
data | T[] | — | Row data |
sortable | boolean | true | Header-click sorting (aria-sort included) |
selectable | boolean | false | Checkbox column (select-all-page included) |
pageSize | number | — | Enables the paginated footer: rows-per-page selector plus Prev/Next Buttons |
manualPagination | boolean | false | Server-side pagination: data is just the current page; fetch in onPaginationChange |
rowCount | number | — | Total rows per the server response; drives "Page X of Y" when manual |
pageCount | number | — | Total pages, for APIs that can't report a row count (alternative to rowCount) |
pagination | PaginationState | — | Controlled pagination state ({ pageIndex, pageSize }); omit to let the table own it |
onPaginationChange | (pagination: PaginationState) => void | — | Fires with the resolved state on page/pageSize change — refetch here |
manualSorting | boolean | false | Server-side sorting: header clicks emit onSortingChange instead of sorting client-side |
sorting / onSortingChange | SortingState / (s: SortingState) => void | — | Controlled sorting state ([{ id, desc }]) / the refetch trigger |
manualFiltering | boolean | false | Server-side filtering: the search box emits onGlobalFilterChange instead of filtering client-side |
globalFilter / onGlobalFilterChange | string / (q: string) => void | — | Controlled search string / fires as the user types |
onRowSelectionChange | (rows: T[]) => void | — | Called with selected row objects |
filterable | boolean | false | Global search input above the columns (case-insensitive, matches any column) |
pinColumns | boolean | false | Sticky first/last columns (position: sticky) |
csvExport | boolean | true | Client-side "Export CSV" button |
Virtualization is automatic: without pageSize, datasets over 200 rows
render through @tanstack/react-virtual (a 10,000-row table scrolls at
60fps — try the button in the demo above).
Server-side pagination
pageSize alone paginates the array you pass in — the client-side default.
When your endpoint returns one page per request (?page=2&limit=10 → 10
rows plus a total), switch to manual mode: data becomes just the current
page, and the table pages through the server's totals instead.
GET /api/people?page=1&limit=10 → 10 rows, 257 total
| Person 1 | [email protected] | Admin | inactive |
| Person 2 | [email protected] | Editor | active |
| Person 3 | [email protected] | Viewer | active |
| Person 4 | [email protected] | Admin | active |
| Person 5 | [email protected] | Editor | active |
| Person 6 | [email protected] | Viewer | inactive |
| Person 7 | [email protected] | Admin | active |
| Person 8 | [email protected] | Editor | active |
| Person 9 | [email protected] | Viewer | active |
| Person 10 | [email protected] | Admin | active |
const [data, setData] = useState<Person[]>([]);
const [rowCount, setRowCount] = useState(0);
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
useEffect(() => {
fetch(`/api/people?page=${pagination.pageIndex + 1}&limit=${pagination.pageSize}`)
.then((res) => res.json())
.then((body) => {
setData(body.rows);
setRowCount(body.total);
});
}, [pagination]);
<DataTable
columns={columns}
data={data}
manualPagination
rowCount={rowCount}
pagination={pagination}
onPaginationChange={setPagination}
/>
onPaginationChangefires with the resolved{ pageIndex, pageSize }on Prev/Next and rows-per-page changes — that's your refetch trigger.paginationis optional: omit it and the table owns the state.- The same pattern covers sorting and search:
manualSorting+onSortingChange(header clicks emit[{ id, desc }]— sort server-side and refetch) andmanualFiltering+onGlobalFilterChange(the search box emits the query as the user types). Sorting, the global filter and CSV export otherwise stay client-side — they operate on the loaded page. - If the API reports pages instead of totals, pass
pageCountinstead ofrowCount.
Tier 2 — slots and classNames
<DataTable
columns={columns}
data={data}
slots={{
toolbar: ({ table }) => <MyToolbar count={table.getFilteredRowModel().rows.length} />,
empty: "No people match your filters.",
}}
classNames={{ root: "…", header: "…", row: "…", cell: "…" }}
/>
Tier 3 — useDataTable()
The headless hook: a fully wired TanStack instance (sorting, filtering, pagination, selection) with zero JSX.
const table = useDataTable<Person>({ columns, data, selectable: true });
// render anything: cards, a list, your own <table>…
table.getRowModel().rows.map((row) => …);
Accessibility
- Sort buttons expose
aria-sort(ascending/descending); screen-reader labels cover the select-all and per-row checkboxes. - The global search input is labelled (
Search all columns), and the row count is anaria-liveregion so filtering announcements happen without focus moves. The rows-per-page trigger is labelled for screen readers. - Virtualized rows keep native table semantics (
table/thead/tbody).