Data Table
Paginated data table with sorting, column filters and view options, with its state kept in TanStack Router search params.
| TASK-1048 | Review accessibility report | in progress | medium | 12 | 5/22/2026 | |
| TASK-1047 | Refactor the payment form | done | high | 23 | 5/19/2026 | |
| TASK-1046 | Speed up the search index | canceled | low | 10 | 5/16/2026 | |
| TASK-1045 | Add dark mode to settings | todo | medium | 21 | 5/13/2026 | |
| TASK-1044 | Audit dependency licenses | in progress | high | 8 | 5/10/2026 | |
| TASK-1043 | Write onboarding docs | done | low | 19 | 5/7/2026 | |
| TASK-1042 | Fix flaky login test | canceled | medium | 6 | 5/4/2026 | |
| TASK-1041 | Migrate billing to the new API | todo | high | 17 | 5/1/2026 | |
| TASK-1040 | Set up error alerts | in progress | low | 4 | 4/28/2026 | |
| TASK-1039 | Clean up feature flags | done | medium | 15 | 4/25/2026 |
Rows per page
Introduction
Data Table is built on TanStack Table and ported from Dice UI. Pagination, sorting and filters live in the URL as TanStack Router search params, so a filtered view can be shared, bookmarked and restored with the back button. All primitives come from uilab-core.
Use Data Table for read-mostly, paginated lists. Use Data Grid when users need to edit data in place.
TanStack Router required
useDataTable reads and writes search params with useSearch and useNavigate, so the table must render inside a TanStack Router RouterProvider. Try the demo above: its pagination, sorting and filters are written to this page's URL.
Installation
Install via following command:
Reminder
Following CLI command will install @tanstack/react-table, @tanstack/react-router, @dnd-kit/*, cmdk and zod to your project.
npx shadcn add @uilab-core/data-tableUsage
import { DataTable } from "@/components/data-table/data-table";
import { DataTableSortMenu } from "@/components/data-table/data-table-sort-menu";
import { DataTableToolbar } from "@/components/data-table/data-table-toolbar";
import { useDataTable } from "@/hooks/use-data-table";
const { table } = useDataTable({
data,
columns,
pageCount,
getRowId: (row) => row.id,
});
<DataTable table={table}>
<DataTableToolbar table={table}>
<DataTableSortMenu table={table} />
</DataTableToolbar>
</DataTable>The toolbar renders one filter for each column with enableColumnFilter: true and picks the control from meta.variant. It also has a reset button and the view options (column visibility). Its children are rendered at the end of the toolbar.
Columns
The column id is also the search param key for that column's filter.
import type { ColumnDef } from "@tanstack/react-table";
import { TextIcon } from "lucide-react";
import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header";
import { getDataTableSelectColumn } from "@/components/data-table/data-table-select-column";
import type { DataTableFeatures } from "@/lib/data-table-features";
const columns = React.useMemo<ColumnDef<DataTableFeatures, Task>[]>(
() => [
// Checkbox column for row selection
getDataTableSelectColumn(),
{
id: "title",
accessorKey: "title",
header: ({ column }) => (
<DataTableColumnHeader column={column} label="Title" />
),
meta: {
label: "Title",
placeholder: "Search titles...",
variant: "text",
icon: TextIcon,
},
// Columns are not filterable unless this is `true`
enableColumnFilter: true,
},
],
[],
);Column meta
| Option | Description |
|---|---|
label | Name shown in the toolbar, sort menu and view options |
placeholder | Placeholder of the text and number filter inputs |
variant | Filter control: text, number, range, date, dateRange, boolean, select or multiSelect |
options | { label, value, count?, icon? }[] for select and multiSelect |
range | [min, max] for the range slider |
unit | Unit shown next to number and range values |
icon | Icon component shown next to the label |
Client and server mode
In both modes, useDataTable keeps pagination, sorting and filters in the URL.
| Mode | Description |
|---|---|
server | Default. Pass the current page of data and the total pageCount (required). The route loader reads the search params and queries your API. |
client | Pass all rows as data and omit pageCount. The table sorts, filters and paginates them in the browser. |
Client mode
const { table } = useDataTable({
mode: "client",
data,
columns,
getRowId: (row) => row.id,
});Server mode
Describe the filterable and sortable columns once, then share that config between the route and the column definitions:
import type { DataTableColumnConfig } from "@/lib/data-table-types";
import type { DataTableSearchOptions } from "@/lib/data-table-parsers";
export const tasksColumnConfigs = {
// Sortable, not filterable
code: { isSortable: false },
title: { variant: "text" },
status: { variant: "multiSelect" },
estimatedHours: { variant: "range" },
createdAt: { variant: "dateRange" },
} as const satisfies Record<string, DataTableColumnConfig>;
export const tasksSearchOptions = {
columnConfigs: tasksColumnConfigs,
defaultSorting: [{ id: "createdAt", desc: true }],
defaultPerPage: 10,
} satisfies DataTableSearchOptions<typeof tasksColumnConfigs>;Validate the search params on the route and turn them into a query in the loader. Because of loaderDeps, the loader re-runs whenever the table changes the URL:
import { createFileRoute } from "@tanstack/react-router";
import {
getDataTableQuery,
validateDataTableSearch,
} from "@/lib/data-table-parsers";
import { tasksSearchOptions } from "./-search";
export const Route = createFileRoute("/tasks/")({
validateSearch: validateDataTableSearch(tasksSearchOptions),
loaderDeps: ({ search }) => search,
loader: ({ deps }) => {
// { page, perPage, sorting, filters, joinOperator }
const query = getDataTableQuery(deps, tasksSearchOptions);
return getTasks(query);
},
component: TasksPage,
});
function TasksPage() {
const { data, pageCount } = Route.useLoaderData();
return <TasksTable data={data} pageCount={pageCount} />;
}Type your query function's input with DataTableColumnConfigsQuery<typeof tasksColumnConfigs>. Each filter has an id, variant, operator and value to map onto your API or database query.
To add your own search params, spread the validator: validateSearch: (search) => ({ ...validateDataTableSearch(tasksSearchOptions)(search), tab: search.tab as string | undefined }).
Spread getColumnOptions into each column so filtering and sorting match the config. Use the same defaults in initialState:
import { getColumnOptions } from "@/lib/data-table-parsers";
{
id: "title",
accessorKey: "title",
meta: { label: "Title", variant: "text" },
...getColumnOptions(tasksColumnConfigs.title),
}
const { table } = useDataTable({
data,
columns,
pageCount,
initialState: {
sorting: tasksSearchOptions.defaultSorting,
pagination: { pageIndex: 0, pageSize: tasksSearchOptions.defaultPerPage },
},
});Keep defaults in sync
Search params that equal their default are left out of the URL. defaultSorting and defaultPerPage in the route options must match the hook's initialState. Otherwise the loader and the table disagree whenever the URL omits them.
URL format
Each filterable column has its own search param, keyed by column id. The value is the operator followed by the filter value. When a filter uses the column's default operator, the operator is left out. Several filters on the same column are stored as an array.
?title=report # text, default operator (contains)
?title=eq.Weekly%20report # text, "is"
?status=todo,done # multi-select, default operator (has any of)
?estimatedHours=2,8 # range, default operator (between)
?estimatedHours=gt.4 # range, "is greater than"
?createdAt=is.empty # no value
?sort=createdAt.desc,title.asc
?page=2&perPage=20API reference
useDataTable
Accepts every TanStack Table option except state, features and the manual* flags, plus:
| Option | Default | Description |
|---|---|---|
mode | "server" | "server" or "client", see above |
pageCount | - | Total page count, required in server mode |
queryKeys | - | Rename the page, perPage, sort and joinOperator search params |
history | "replace" | Replace the current history entry or push a new one |
debounceMs | 300 | Delay before filter changes are written to the URL |
clearOnDefault | true | Leave search params out of the URL when they equal their default |
scroll | false | Scroll to the top when the search params change |
DataTable
| Prop | Description |
|---|---|
table | Table instance from useDataTable |
actionBar | Rendered under the table while any visible row is selected |
children | Rendered above the table, usually DataTableToolbar |
DataTablePagination
DataTable renders it for you. When you use it on its own, it accepts pageSizeOptions (default [10, 20, 30, 40, 50]).