Skip to content
Redpanda UI
Esc
↑↓navigate↵open⌘Jpreview
Edit on GitHub

Data Table

Feature-composed TanStack Table V9 data grids with sorting, filtering, pagination, selection, and external state ownership.

Installation

bash
npx shadcn@latest add @redpanda/data-table
bash
pnpm dlx shadcn@latest add @redpanda/data-table
bash
yarn dlx shadcn@latest add @redpanda/data-table
bash
bunx shadcn@latest add @redpanda/data-table
bash
nubx shadcn@latest add @redpanda/data-table
bash
aube dlx shadcn@latest add @redpanda/data-table

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.

tsx
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

text
<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└── DataTableViewOptions

dataTableFeatures 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.

tsx
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.
  • showPagination defaults to false and 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. Set manualPagination on 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 DataTable keeps its existing automatic clamping and responsiveColumns behavior.
  • 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.

tsx
<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.

tsx
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.

tsx
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.

tsx
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.

tsx
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.

tsx
<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.

tsx
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.

tsx
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.

tsx
<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
tsx
// 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

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

Loading skeleton

Resizable columns

Sticky column

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.
tsx
import { isRowActivationClick } from '@/components/redpanda-ui/data-table';
<tr onClick={(event) => isRowActivationClick(event.target, event.currentTarget) && onOpen(item)}>{/* cells */}</tr>;

Recent changes

  • patchv1.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
  • minorv1.1.0Theme docs refresh, readability pass on semantic foregrounds, and consumer-facing Base UI regression fixes.#121
  • minorv1.0.0Post-Base-UI polish. Public API unchanged.#116
  • majorv1.0.0Migrate every Radix-based primitive to `@base-ui/react@^1.4.0` (Base UI).#114
  • minorv0.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
See full history →

Last updated on

Was this page helpful?