Data Table
Feature-composed TanStack Table V9 data grids with sorting, filtering, pagination, selection, and external state ownership.
Powered by
Installation
npx shadcn@latest add @redpanda/data-tablepnpm dlx shadcn@latest add @redpanda/data-tableyarn dlx shadcn@latest add @redpanda/data-tablebunx shadcn@latest add @redpanda/data-tablenubx shadcn@latest add @redpanda/data-tableaube dlx shadcn@latest add @redpanda/data-tableWhen to use
Use DataTable for interactive record collections that need sorting, filtering, pagination, selection, or reusable row actions. Use the base Table for small static content where stateful table behavior would add unnecessary complexity.
Usage
The registry binds one explicit TanStack Table V9 feature set to DataTable. Columns, table instances, atoms, and helpers therefore share the same feature-safe types.
import { createDataTableColumnHelper, DataTable } from '@/components/redpanda-ui/data-table';
type User = { id: string; name: string; role: string;};
const columnHelper = createDataTableColumnHelper<User>();const columns = columnHelper.columns([ columnHelper.accessor('name', { header: 'Name' }), columnHelper.accessor('role', { header: 'Role' }),]);
export function UsersTable({ users }: { users: User[] }) { return <DataTable columns={columns} data={users} selectable />;}Architecture
<DataTable>├── useDataTable() feature-bound V9 hook├── DataTableView shared standard renderer├── table.AppTable context for table renderers├── table.FlexRender typed headers and cells├── table.Subscribe narrow reactive boundaries├── DataTablePagination├── DataTableFacetedFilter└── DataTableViewOptionsdataTableFeatures composes filtering, faceting, visibility, ordering, pinning, sizing, resizing, sorting, pagination, row pinning, selection, and expansion. Row-model factories and built-in filter/sort functions live in the feature definition, not at each call site.
An externally owned controller
Use DataTableView when filters, URL state, or other controls already own a useDataTable controller. It renders the same standard headers, cells, loading and empty states, row actions, and optional pagination as DataTable, without constructing another controller.
import { DataTableView, useDataTable } from '@/components/redpanda-ui/data-table';
function UsersTable({ users }: { users: User[] }) { const table = useDataTable({ data: users, columns, getRowId: (user) => user.id, initialState: { pagination: { pageIndex: 0, pageSize: 20 } }, });
return <DataTableView showPagination table={table} />;}- The controller owns columns, filtering, sorting, pagination, selection, expansion, and all controlled or atom-backed state. Pass those options to
useDataTable, not to the view. showPaginationdefaults tofalseand only hides or shows the controls. It does not disable the controller’s pagination model. For example, a page size of 20 still renders one page when the footer is hidden. SetmanualPaginationon the controller only when the data source actually owns pagination.- Selection columns remain caller-owned. The view reflects actual selection and expansion; it does not insert a checkbox column or change eligibility.
- Pass existing filter controls the original controller. Its selected state keeps its real type; the view subscribes to its rendering models independently, including when the controller uses a narrow selector.
- The view does not clamp page indexes or change responsive visibility. External owners retain those decisions. The convenience
DataTablekeeps its existing automatic clamping andresponsiveColumnsbehavior. - The view requires the bound controller from
useDataTable, including its table context. An arbitrary core TanStack table is not interchangeable.
The existing DataTable.pagination option is unchanged: it controls its footer and the convenience wrapper’s default manualPagination setting. Use DataTableView.showPagination when you only want to control footer visibility on an existing controller.
State ownership
Internal state
The table owns state by default. Use initialState for initial pagination or visibility.
<DataTable columns={columns} data={data} tableOptions={{ initialState: { pagination: { pageIndex: 0, pageSize: 25 }, columnVisibility: { internalId: false }, }, }}/>Controlled state
Put controlled slices and their handlers in tableOptions. Do not pass V8-style state props directly to DataTable.
import { useState } from 'react';import type { SortingState } from '@tanstack/react-table';
function ControlledTable() { const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 20 }); const [sorting, setSorting] = useState<SortingState>([]);
return ( <DataTable columns={columns} data={data} tableOptions={{ state: { pagination, sorting }, onPaginationChange: setPagination, onSortingChange: setSorting, }} /> );}External atoms
TanStack Table V9 can write directly to TanStack Store atoms. Use this when table state must be shared without lifting every change through React props.
Install @tanstack/react-store as a direct dependency when using external atoms.
import { useCreateAtom } from '@tanstack/react-store';import type { PaginationState } from '@tanstack/react-table';
function AtomOwnedTable() { const pagination = useCreateAtom<PaginationState>({ pageIndex: 0, pageSize: 20, });
return <DataTable columns={columns} data={data} tableOptions={{ atoms: { pagination } }} />;}Custom table composition
The basic, full-featured, cluster, and list-layout examples keep their own controllers, toolbars, and pagination controls. Their standard header, row, selection, and empty-state rendering delegates to DataTableView; the empty cell always spans the currently visible columns, even after a column is hidden.
Use lower-level table anatomy only when the presentation genuinely differs from that standard renderer.
Use useDataTable when the registry wrapper is not enough. Core and optional row models are already installed by the shared feature definition.
import { Subscribe } from '@tanstack/react-table';import { useDataTable } from '@/components/redpanda-ui/data-table';
function CustomTable({ data }: { data: User[] }) { const table = useDataTable({ data, columns });
return ( <table.AppTable> <Subscribe source={table.atoms.pagination}> {() => ( <> {table .getHeaderGroups() .map((group) => group.headers.map((header) => <table.FlexRender header={header} key={header.id} />))} {table .getRowModel() .rows.map((row) => row.getVisibleCells().map((cell) => <table.FlexRender cell={cell} key={cell.id} />))} </> )} </Subscribe> </table.AppTable> );}Read reactive state with table.state inside React rendering. Use table.Subscribe or Subscribe around stable children that only depend on one atom. Reserve table.store.state and atom.get() for event-time snapshots.
Resource-list patterns
Automatic selection anatomy
selectable adds native leading selection cells outside the caller’s TanStack data-column schema. columns is passed through unchanged; data column instances remain stable across parent renders. The page checkbox spans grouped header rows, and empty, loading, and expanded cells span visible leaf data columns plus the selection cell.
Automatic selection does not inject or reserve a column ID named select. Column enumeration, sizing, ordering, visibility, and pinning metadata describe data columns only. Automatic controls remain leading and unpinned. Replace old getColumn('select') or synthetic-column metadata access with selectable and row-selection APIs such as row.toggleSelected() and table.toggleAllPageRowsSelected(). For a custom reorderable or pinned selection column, keep an explicit stable schema and controller with DataTableView; it never adds automatic controls.
Global search and selection toolbars
Subscribe toolbar controls to the smallest V9 atom they render. Keep search visible while bulk actions are active so an applied filter never becomes hidden or impossible to clear.
import { Subscribe } from '@tanstack/react-table';
<DataTable columns={columns} data={data} selectable toolbar={(table) => ( <div className="flex items-center gap-2"> <Subscribe source={table.atoms.globalFilter}> {(value) => ( <Input aria-label="Search resources" onChange={(event) => table.setGlobalFilter(event.target.value)} type="search" value={typeof value === 'string' ? value : ''} /> )} </Subscribe> <Subscribe source={table.atoms.rowSelection}> {(selection) => Object.values(selection).some(Boolean) ? ( <Button onClick={() => table.setRowSelection({})}>Clear selection</Button> ) : null } </Subscribe> </div> )}/>;Conditional selection and row activation
Keep domain eligibility in V9 table options, then use the matching row predicate for row actions. Ineligible rows omit both selection and activation affordances.
<DataTable columns={columns} data={data} selectable tableOptions={{ enableRowSelection: (row) => !row.original.protected, }} isRowClickable={(row) => !row.original.protected} getRowAriaLabel={(row) => `Open ${row.original.name}`} onRow={(row) => navigate({ to: '/resources/$id', params: { id: row.original.id } })}/>Container-responsive columns
Use responsiveColumns when a table lives inside resizable panels or split views. Rules use the measured table container—not the browser viewport—and own visibility for the listed columns.
const responsiveColumns = [ { columnId: "owner", hideBelowPx: 760 }, { columnId: "region", hideBelowPx: 520 },] as const
<DataTable columns={columns} data={data} responsiveColumns={responsiveColumns}/>Keep responsiveColumns at module scope or otherwise preserve its semantic values across renders. The hook reuses its ResizeObserver when an inline array contains the same rules, but a stable constant is clearer and avoids repeated rule comparison.
Faceted scalar columns
DataTableFacetedFilter writes an array of selected option values. Set filterFn: "arrHas" on scalar enum columns so a row matches when its value equals any selected option. Do not rely on V9 inference: it infers a string filter from the row value, not the array-shaped filter value.
const columns = columnHelper.columns([ columnHelper.accessor('status', { header: 'Status', filterFn: 'arrHas', }),]);For array-valued cells, use filterFn: "arrIncludesSome" instead.
Server-side data
Keep server state in TanStack Query or Connect Query. Pass only the current page to data and set dataMode="server". Server mode atomically bypasses client filtering, sorting, and pagination so one operation cannot accidentally run against only the loaded page. Provide the logical count through rowCount or pageCount.
<DataTable columns={columns} data={query.data?.rows ?? []} dataMode="server" isLoading={query.isLoading} tableOptions={{ state: { globalFilter, pagination, sorting }, rowCount: query.data?.totalRows, onGlobalFilterChange: setGlobalFilter, onPaginationChange: setPagination, onSortingChange: setSorting, }}/>With a fixed page size, browser memory, row-model work, and rendered DOM stay bounded by that page even when rowCount is in the millions. dataMode="server" does not make passing millions of rows to data safe. Virtualization can reduce DOM nodes, but the full client-side array still consumes memory and must still be processed.
Use useDataTableSearchParams to keep server pagination, sorting, and filters in TanStack Router search state. It resets the page atomically when those query inputs change. For cursor-based APIs, use useTokenPagination and custom pagination controls.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
data |
readonly TData[] |
Required | Rows to display. |
columns |
readonly DataTableColumnDef<TData>[] |
Required | V9 feature-bound column definitions. |
pagination |
boolean |
true |
Enable local pagination and its controls. |
sorting |
boolean |
true |
Enable local sorting. |
selectable |
boolean |
false |
Add page and row selection controls. |
dataMode |
"client" | "server" |
"client" |
Run filtering, sorting, and pagination locally or delegate all three to the external data source. |
responsiveColumns |
readonly { columnId: string; hideBelowPx: number }[] |
[] |
Own listed-column visibility from the measured table container width. |
tableOptions |
DataTableOptions<TData> |
— | V9 state, atoms, handlers, and manual-mode options. |
toolbar |
ReactNode | (table) => ReactNode |
— | Content rendered above the table. |
subComponent |
({ row }) => ReactNode |
— | Expanded-row content. |
expandRowByClick |
boolean |
false |
Toggle expandable rows from non-interactive row content. |
onRow |
(row) => void |
— | Activate a row by pointer, Enter, or Space. |
isRowClickable |
(row) => boolean |
— | Restrict onRow activation and focusability to eligible rows. |
getRowAriaLabel |
(row) => string | undefined |
— | Accessible action name for activatable rows. |
pageSizeOptions |
number[] |
[10, 20, 25, 30, 40, 50] |
Pagination size choices. |
isLoading |
boolean |
false |
Show loading state when no stale rows exist. |
loadingText |
string |
"Loading..." |
Text shown by the loading state. |
emptyText |
string |
"No results." |
Text shown when no rows remain. |
emptyAction |
ReactNode |
— | Optional action rendered with the empty state. |
getRowCanExpand |
(row) => boolean |
— | Restrict which rows can expose subComponent. |
rowClassName |
(row) => string |
— | Add classes from row data or state. |
classNames |
DataTableClassNames |
— | Override structural slot classes. |
testId |
string |
— | Add data-testid to the root table wrapper. |
variant |
"standard" | "simple" | "bordered" | "card" |
— | Table visual treatment. |
size |
"sm" | "md" | "lg" |
— | Table density. |
Migrating from TanStack Table V8
V9 requires explicit features and binds the registry’s column, row, and table types to that feature set. Replace ColumnDef imports with DataTableColumnDef or createDataTableColumnHelper, and move controlled state into tableOptions.
V8 DataTable prop |
V9 replacement |
|---|---|
pagination={{ pageIndex, pageSize }} |
pagination plus tableOptions.state.pagination |
onPaginationChange |
tableOptions.onPaginationChange |
defaultPageSize |
tableOptions.initialState.pagination.pageSize |
pageCount |
tableOptions.pageCount or tableOptions.rowCount |
sorting={sortingState} |
sorting plus tableOptions.state.sorting |
onSortingChange |
tableOptions.onSortingChange |
rowSelection |
tableOptions.state.rowSelection |
onRowSelectionChange |
tableOptions.onRowSelectionChange |
ColumnDef<TData> |
DataTableColumnDef<TData> or createDataTableColumnHelper<TData>() |
table.getState() |
table.state for render reads, or table.store.state for event-time snapshots |
useReactTable |
useDataTable for the registry feature set |
// V8<DataTable columns={columns} data={data} pagination={pagination} onPaginationChange={setPagination} rowSelection={rowSelection} onRowSelectionChange={setRowSelection}/>
// V9<DataTable columns={columns} data={data} selectable tableOptions={{ state: { pagination, rowSelection }, onPaginationChange: setPagination, onRowSelectionChange: setRowSelection, }}/>V9 instance methods must remain attached to their row, column, header, or table object. Call row.getValue("name"); do not destructure getValue first. For custom compositions, register only the filter and sort functions you use rather than importing full registries.
URL state with TanStack Router
Use useDataTableSearchParams with a route-owned validateSearch function and DataTableSearchParamsProvider. It can synchronize pagination, sorting, column filters, and global search. Sorting, filter, and page-size changes reset to the first page in the same Router navigation by default.
Examples
Full-featured table
Task row menus can copy the actual task ID and display clipboard errors. Edit, Make a copy, Favorite, Labels, and Delete are disabled because this example has no backend for those operations; it does not advertise an unimplemented Delete shortcut. Menu triggers and sortable column headers use the standard ghost Button open state.
Cluster table
The cluster and list-layout examples share a demo-only row-action menu. Copy cluster ID writes the actual row ID and reports clipboard failures through a visible notification. View details, Edit cluster, and Delete cluster are disabled because these examples have no product backend.
This composition includes named cluster search and page-size controls, keyboard sorting, faceted filters, column visibility, row actions, and pagination. Filter summaries use the standard small Badge recipe, and the toolbar wraps when space is limited.
Faceted filters
Wrapper composition
Controlled state
Selection toolbar and resource-list behavior
URL state
Ten-million-row server dataset
Without pagination
Custom page sizes
Remote infinite scrolling
The demo uses one height-bounded Table stickyHeader viewport. Its onViewportScroll callback drives a bounded rendered row window and incremental loading; the existing single-flight request guard and abort cleanup prevent overlapping or stale page updates. Loaded counts, loading feedback, and visible fetch errors stay outside the scrolling table.
Local infinite scrolling
Row pinning
Column pinning
Use TableHead pinned="start" and TableCell pinned="start" for the first boundary column, or pinned="end" for the last. Only one column per edge is supported; this static composition does not implement dynamic multi-column offsets. See Table for the shared surface and row-state contract.
Column visibility
Compact header
Inline CRUD
Draggable columns
Empty state
Expandable rows
Fixed layout and explicit sizes
Footer aggregates
Loading skeleton
Resizable columns
Sticky column
Sticky header
Use Table stickyHeader inside an explicitly height-bounded parent, rather than adding sticky paint and a second overflow wrapper at the call site. The shared table owns the opaque header, scroll viewport, and keyboard focus treatment.
Striped rows
Utilities
DataTable uses these exports to decide whether a row click should activate it. Reuse them in custom table compositions instead of duplicating the interactive-target guard.
| Export | Signature | Description |
|---|---|---|
isRowActivationClick |
(target: EventTarget | null, row: Element) => boolean |
Returns true when the target is inside the row and is not an interactive control. |
isInteractiveTarget |
(target: EventTarget | null, boundary?: Element | null) => boolean |
Returns true for links, buttons, inputs, menu items, and equivalent ARIA controls. |
import { isRowActivationClick } from '@/components/redpanda-ui/data-table';
<tr onClick={(event) => isRowActivationClick(event.target, event.currentTarget) && onOpen(item)}>{/* cells */}</tr>;Related
- Data Table Filter for faceted filtering and operator controls.
- Filtered Table Pattern for toolbar composition.
useTokenPaginationfor token-based APIs.