Redpanda UIRedpanda UI
Components

Data Table

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

Loading component…

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
└── 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.

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

PropTypeDefaultDescription
datareadonly TData[]RequiredRows to display.
columnsreadonly DataTableColumnDef<TData>[]RequiredV9 feature-bound column definitions.
paginationbooleantrueEnable local pagination and its controls.
sortingbooleantrueEnable local sorting.
selectablebooleanfalseAdd page and row selection controls.
dataMode"client" | "server""client"Run filtering, sorting, and pagination locally or delegate all three to the external data source.
responsiveColumnsreadonly { columnId: string; hideBelowPx: number }[][]Own listed-column visibility from the measured table container width.
tableOptionsDataTableOptions<TData>V9 state, atoms, handlers, and manual-mode options.
toolbarReactNode | (table) => ReactNodeContent rendered above the table.
subComponent({ row }) => ReactNodeExpanded-row content.
expandRowByClickbooleanfalseToggle expandable rows from non-interactive row content.
onRow(row) => voidActivate a row by pointer, Enter, or Space.
isRowClickable(row) => booleanRestrict onRow activation and focusability to eligible rows.
getRowAriaLabel(row) => string | undefinedAccessible action name for activatable rows.
pageSizeOptionsnumber[][10, 20, 25, 30, 40, 50]Pagination size choices.
isLoadingbooleanfalseShow loading state when no stale rows exist.
loadingTextstring"Loading..."Text shown by the loading state.
emptyTextstring"No results."Text shown when no rows remain.
emptyActionReactNodeOptional action rendered with the empty state.
getRowCanExpand(row) => booleanRestrict which rows can expose subComponent.
rowClassName(row) => stringAdd classes from row data or state.
classNamesDataTableClassNamesOverride structural slot classes.
testIdstringAdd 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 propV9 replacement
pagination={{ pageIndex, pageSize }}pagination plus tableOptions.state.pagination
onPaginationChangetableOptions.onPaginationChange
defaultPageSizetableOptions.initialState.pagination.pageSize
pageCounttableOptions.pageCount or tableOptions.rowCount
sorting={sortingState}sorting plus tableOptions.state.sorting
onSortingChangetableOptions.onSortingChange
rowSelectiontableOptions.state.rowSelection
onRowSelectionChangetableOptions.onRowSelectionChange
ColumnDef<TData>DataTableColumnDef<TData> or createDataTableColumnHelper<TData>()
table.getState()table.state for render reads, or table.store.state for event-time snapshots
useReactTableuseDataTable 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

Loading component…

Cluster table

Loading component…

Faceted filters

Loading component…

Wrapper composition

Loading component…

Controlled state

Loading component…

Selection toolbar and resource-list behavior

Loading component…

URL state

Loading component…

Ten-million-row server dataset

Loading component…

Without pagination

Loading component…

Custom page sizes

Loading component…

Remote infinite scrolling

Loading component…

Local infinite scrolling

Loading component…

Row pinning

Loading component…

Column pinning

Loading component…

Column visibility

Loading component…

Compact header

Loading component…

Inline CRUD

Loading component…

Draggable columns

Loading component…

Empty state

Loading component…

Expandable rows

Loading component…

Fixed layout and explicit sizes

Loading component…
Loading component…

Loading skeleton

Loading component…

Resizable columns

Loading component…

Sticky column

Loading component…
Loading component…

Striped rows

Loading component…

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.

ExportSignatureDescription
isRowActivationClick(target: EventTarget | null, row: Element) => booleanReturns true when the target is inside the row and is not an interactive control.
isInteractiveTarget(target: EventTarget | null, boundary?: Element | null) => booleanReturns 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>

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 →
Built by malinskibeniamin. The source code is available on GitHub.

On this page