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

8 rows
Ada Lovelace[email protected]Adminactive
Grace Hopper[email protected]Editoractive
Alan Turing[email protected]Viewerinvited
Katherine Johnson[email protected]Editoractive
Margaret Hamilton[email protected]Admininactive
Rows per page
Page 1 of 2
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

PropTypeDefaultDescription
columnsColumnDef<T>[]—TanStack column definitions
dataT[]—Row data
sortablebooleantrueHeader-click sorting (aria-sort included)
selectablebooleanfalseCheckbox column (select-all-page included)
pageSizenumber—Enables the paginated footer: rows-per-page selector plus Prev/Next Buttons
manualPaginationbooleanfalseServer-side pagination: data is just the current page; fetch in onPaginationChange
rowCountnumber—Total rows per the server response; drives "Page X of Y" when manual
pageCountnumber—Total pages, for APIs that can't report a row count (alternative to rowCount)
paginationPaginationState—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
manualSortingbooleanfalseServer-side sorting: header clicks emit onSortingChange instead of sorting client-side
sorting / onSortingChangeSortingState / (s: SortingState) => void—Controlled sorting state ([{ id, desc }]) / the refetch trigger
manualFilteringbooleanfalseServer-side filtering: the search box emits onGlobalFilterChange instead of filtering client-side
globalFilter / onGlobalFilterChangestring / (q: string) => void—Controlled search string / fires as the user types
onRowSelectionChange(rows: T[]) => void—Called with selected row objects
filterablebooleanfalseGlobal search input above the columns (case-insensitive, matches any column)
pinColumnsbooleanfalseSticky first/last columns (position: sticky)
csvExportbooleantrueClient-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

257 rows
Person 1[email protected]Admininactive
Person 2[email protected]Editoractive
Person 3[email protected]Vieweractive
Person 4[email protected]Adminactive
Person 5[email protected]Editoractive
Person 6[email protected]Viewerinactive
Person 7[email protected]Adminactive
Person 8[email protected]Editoractive
Person 9[email protected]Vieweractive
Person 10[email protected]Adminactive
Rows per page
Page 1 of 26
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}
/>
  • onPaginationChange fires with the resolved { pageIndex, pageSize } on Prev/Next and rows-per-page changes — that's your refetch trigger. pagination is 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) and manualFiltering + 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 pageCount instead of rowCount.

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 an aria-live region 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).