Data Table
Feature-composed TanStack Table V9 data grids with sorting, filtering, pagination, selection, and external state ownership.
Installation
When 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
├── 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.
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
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
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
Cluster table
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
Local infinite scrolling
Row pinning
Column pinning
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
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.
Recent changes
- v1.2.0Pin shipped dependency floors to the version we develop against. Registry items now declare ranges like `^5.1.9` (the actual installed version) instead of collapsing to `^5.0.0`, so consumers start on the known-tested baseline while caret semantics still allow any compatible release within the same major.#133
- v1.1.0Theme docs refresh, readability pass on semantic foregrounds, and consumer-facing Base UI regression fixes.#121
- v1.0.0Post-Base-UI polish. Public API unchanged.#116
- v1.0.0Migrate every Radix-based primitive to `@base-ui/react@^1.4.0` (Base UI).#114
- v0.3.0Add theme-provider component to the registry with documentation and tests. Includes playground type improvements (export RegistryItem, remove as-const boilerplate) and docs site dark mode border color fix.#109