---
title: Data Table
description: Feature-composed TanStack Table V9 data grids with sorting, filtering, pagination, selection, and external state ownership.
icon: table
sidebar:
  badge: Updated
search:
  keywords: [grid, datagrid, table]
related:
  - /table
  - /data-table-filter
  - /pagination
mode: wide
---

<ComponentPreview name="data-table-demo" className="[&_.preview]:items-start" />

<PoweredBy
  packages={[
    { name: 'TanStack Table V9', url: 'https://tanstack.com/table/latest' },
    { name: 'Lucide', url: 'https://lucide.dev/' },
  ]}
/>

## Installation

```package-install
npx shadcn@latest add @redpanda/data-table
```

## When to use

```mermaid
graph TD;
    A[Need to present structured records?] --> B{Where do data operations run?};
    B --> C[Complete dataset is in the browser];
    B --> D[API owns filtering, sorting, or pagination];
    C --> E[Use DataTable in client mode];
    D --> F[Use DataTable in server mode];
    E --> G{Many rows must remain visible?};
    G --> H[Use pagination or an infinite-scroll table];
    G --> I[Use a basic Table for small static content];
    F --> J[Pass one page plus rowCount or pageCount];
```

Use `DataTable` for interactive record collections that need sorting, filtering, pagination, selection, or reusable row actions. Use the base [`Table`](/docs/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`](/docs/use-data-table-search-params) 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`](/docs/use-token-pagination) 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`](/docs/use-data-table-search-params) 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.

<ComponentPreview
  name="data-table-improved-demo"
  bigScreen
  title="Data Table"
  type="block"
  description="Filtering, sorting, selection, visibility, and pagination."
  className="w-full"
/>

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

<ComponentPreview name="data-table-cluster-demo" className="[&_.preview]:items-start" />

### Faceted filters

<ComponentPreview
  name="data-table-faceted-filter-demo"
  description="Combine status and priority facets while retaining live result counts."
/>

### Wrapper composition

<ComponentPreview
  name="data-table-wrapper-demo"
  description="Compose a reusable table wrapper around V9 columns, data, and pagination."
/>

### Controlled state

<ComponentPreview
  name="data-table-controlled-demo"
  description="React-owned pagination and sorting through V9 tableOptions."
/>

### Selection toolbar and resource-list behavior

<ComponentPreview
  name="data-table-selection-toolbar-demo"
  bigScreen
  description="Atom-subscribed global search and bulk actions with conditional selection, accessible row activation, and container-responsive columns."
/>

### URL state

<ComponentPreview
  name="data-table-search-params-demo"
  description="TanStack Router-owned pagination, sorting, and global search."
/>

### Ten-million-row server dataset

<ComponentPreview
  name="data-table-server-scale-demo"
  bigScreen
  description="Keep browser work bounded to one 100-row page while TanStack Router owns server query state."
/>

### Without pagination

<ComponentPreview name="data-table-no-pagination-demo" />

### Custom page sizes

<ComponentPreview name="data-table-page-size-options-demo" />

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

<ComponentPreview
  name="data-table-infinite-scroll-remote-demo"
  description="Load remote pages as the user reaches the end of the table."
/>

### Local infinite scrolling

<ComponentPreview
  name="data-table-infinite-scroll-local-demo"
  description="Reveal an in-memory dataset incrementally without pagination controls."
/>

### Row pinning

<ComponentPreview
  name="data-table-row-pinning-demo"
  description="Keep selected rows pinned to the top or bottom of the table."
/>

### Column pinning

<ComponentPreview
  name="data-table-column-pinning-demo"
  description="Pin the first identifier and last cost columns while the middle columns scroll."
/>

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](/docs/table#pinned-boundary-columns) for the shared surface and row-state contract.

### Column visibility

<ComponentPreview name="data-table-column-visibility-demo" description="Let users choose which columns are visible." />

### Compact header

<ComponentPreview
  name="data-table-compact-header-demo"
  description="Use a compact header for operator-console density."
/>

### Inline CRUD

<ComponentPreview name="data-table-crud-demo" description="Add, edit, and delete rows through inline CRUD flows." />

### Draggable columns

<ComponentPreview name="data-table-draggable-columns-demo" description="Reorder columns with drag and drop." />

### Empty state

<ComponentPreview name="data-table-empty-state-demo" description="Render an empty state when no rows match." />

### Expandable rows

<ComponentPreview name="data-table-expandable-row-demo" description="Reveal nested row details inline." />

### Fixed layout and explicit sizes

<ComponentPreview
  name="data-table-fixed-layout-sizes-demo"
  description="Keep streaming updates stable with a fixed layout and explicit sizes."
/>

### Footer aggregates

<ComponentPreview
  name="data-table-footer-aggregates-demo"
  description="Summarize totals and averages in a footer row."
/>

### Loading skeleton

<ComponentPreview name="data-table-loading-skeleton-demo" description="Use skeleton rows while table data loads." />

### Resizable columns

<ComponentPreview name="data-table-resizable-columns-demo" description="Resize columns with drag handles." />

### Sticky column

<ComponentPreview name="data-table-sticky-column-demo" description="Keep a boundary column visible in wide tables." />

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

<ComponentPreview
  name="data-table-sticky-header-demo"
  description="Keep column labels visible during vertical scroll."
/>

### Striped rows

<ComponentPreview name="data-table-striped-demo" description="Add row striping for dense datasets." />

## 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>;
```

## Related

- [Data Table Filter](/docs/data-table-filter) for faceted filtering and operator controls.
- [Filtered Table Pattern](/docs/patterns/filtered-table) for toolbar composition.
- [`useTokenPagination`](/docs/use-token-pagination) for token-based APIs.
