UILab

Data Table

Paginated data table with sorting, column filters and view options, with its state kept in TanStack Router search params.

TASK-1048Review accessibility reportin progressmedium125/22/2026
TASK-1047Refactor the payment formdonehigh235/19/2026
TASK-1046Speed up the search indexcanceledlow105/16/2026
TASK-1045Add dark mode to settingstodomedium215/13/2026
TASK-1044Audit dependency licensesin progresshigh85/10/2026
TASK-1043Write onboarding docsdonelow195/7/2026
TASK-1042Fix flaky login testcanceledmedium65/4/2026
TASK-1041Migrate billing to the new APItodohigh175/1/2026
TASK-1040Set up error alertsin progresslow44/28/2026
TASK-1039Clean up feature flagsdonemedium154/25/2026
0 rows selected.

Rows per page

Page 1 of 5

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-table

Usage

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

OptionDescription
labelName shown in the toolbar, sort menu and view options
placeholderPlaceholder of the text and number filter inputs
variantFilter 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
unitUnit shown next to number and range values
iconIcon component shown next to the label

Client and server mode

In both modes, useDataTable keeps pagination, sorting and filters in the URL.

ModeDescription
serverDefault. Pass the current page of data and the total pageCount (required). The route loader reads the search params and queries your API.
clientPass 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:

routes/tasks/-search.ts
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:

routes/tasks/index.tsx
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=20

API reference

useDataTable

Accepts every TanStack Table option except state, features and the manual* flags, plus:

OptionDefaultDescription
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
debounceMs300Delay before filter changes are written to the URL
clearOnDefaulttrueLeave search params out of the URL when they equal their default
scrollfalseScroll to the top when the search params change

DataTable

PropDescription
tableTable instance from useDataTable
actionBarRendered under the table while any visible row is selected
childrenRendered 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]).

On this page