Acronis UIKit
Components

DataTable

A data grid on TanStack react-table — sorting, filtering, selection, pagination, row expansion, column resizing, sticky columns, and server-driven data.

Usage

import {
  DataTable,
  DataTableColumnHeader,
  DataTableToolbar,
  DataTablePagination,
  DataTableViewOptions,
  DataTableExpandTrigger,
} from '@acronis-platform/ui-react';
import type { ColumnDef } from '@tanstack/react-table';

DataTable is a data grid built on TanStack react-table v8, composed over the Table primitives. Given columns and data it builds and owns its own table instance — sorting, filtering, column visibility, row selection, pagination, row expansion, column resizing and column pinning all live in the component. Alternatively pass a table instance you built with useReactTable and DataTable renders from that one instead, owning no state of its own (see Server-driven usage). The companion parts — DataTableColumnHeader, DataTableToolbar, DataTablePagination, DataTableViewOptions, DataTableExpandTrigger — always operate on a table instance (or a row of one). This is a design-pending v1; it reuses the Table component's --ui-table-* tokens (the wrapper border matches the cell borders).

A DataTableColumnHeader sorts in a single click: the trailing arrow toggles ascending → descending and shows the direction with an up/down arrow in the brand blue, or a muted up/down arrow when the column is unsorted. Column hiding lives behind the cog in the trailing settings column (DataTableViewOptions with iconOnly), not in the toolbar, keeping sorting to one click. That settings column is only rendered for DataTable's own instance — with an external table, render DataTableViewOptions yourself next to the toolbar (see Toolbar and pagination).

Parts

ExportPurpose
DataTableThe grid. Owns its state from columns/data, or renders a caller-built table.
DataTableColumnHeaderSingle-click sortable header (↑ / ↓ / ↕). Use in a column's header.
DataTableToolbarSearch box, per-column filter popover, applied-filter chips. Takes a table.
DataTablePaginationSelection count, rows-per-page select, page controls. Takes a table.
DataTableViewOptionsColumn-visibility menu (TanStack adapter over the TableViewOptions primitive).
DataTableExpandTriggerChevron toggle for a row's expansion, placed inside a column's cell.

Examples

The simplest form — DataTable builds its own instance from columns and data:

const columns: ColumnDef<Payment>[] = [
  {
    accessorKey: 'email',
    header: ({ column }) => <DataTableColumnHeader column={column} title="Email" />,
  },
  { accessorKey: 'amount', header: 'Amount' },
];

<DataTable columns={columns} data={payments} />

That internal instance includes the paginated row model, so it shows TanStack's default 10 rows per page. Since the page controls live in DataTablePagination — which needs a table instance — a grid with more than a page of rows should either share one instance (below) or switch to infinite scroll.

Toolbar and pagination

Build the instance yourself and pass the same one to the toolbar, the grid and the pagination, so search, sorting, selection and paging all agree:

const table = useReactTable({
  data,
  columns,
  getCoreRowModel: getCoreRowModel(),
  getSortedRowModel: getSortedRowModel(),
  getFilteredRowModel: getFilteredRowModel(),
  getPaginationRowModel: getPaginationRowModel(),
  onSortingChange: setSorting,
  onColumnFiltersChange: setColumnFilters,
  onRowSelectionChange: setRowSelection,
  state: { sorting, columnFilters, rowSelection },
});

<div className="flex items-start gap-2">
  <div className="flex-1">
    <DataTableToolbar table={table} searchKey="email" />
  </div>
  {/* External `table` ⇒ no built-in settings column, so render the cog here */}
  <DataTableViewOptions table={table} iconOnly />
</div>
<DataTable table={table} />
<DataTablePagination table={table} pageSizeOptions={[5, 10, 20]} />

With table passed, DataTable configures nothing on that instance — the props it would otherwise use to configure its own become no-ops (enableColumnResizing, getRowCanExpand, manualSorting, sorting, onSortingChange, columnVisibility, onColumnVisibilityChange, onColumnSizingChange, paginationMode, onLoadMore, loadMoreRootMargin, hasNextPage, isLoadingMore), and meta.pin pinning is skipped too — set the equivalents on your own instance (pin via TanStack's column.pin()). The trailing settings column is dropped as well, which is why the cog above is rendered next to the toolbar instead.

If you'd rather keep DataTable's own state and only share a slice of it with a toolbar, pass columns/data plus the controlled state you need in both places:

<DataTable
  columns={columns}
  data={pageRows}
  columnVisibility={columnVisibility}
  onColumnVisibilityChange={setColumnVisibility}
/>

Presentational flags

Borrowed from the Vue AvTable, these are pure visual props on DataTable:

<DataTable columns={columns} data={rows} striped />
<DataTable columns={columns} data={rows} bordered />        {/* vertical column borders */}
<DataTable columns={columns} data={rows} highlightCurrentRow /> {/* click to highlight */}
<DataTable columns={columns} data={[]} skeleton skeletonRows={5} /> {/* loading */}

Sorting

Sorting is client-side by default. For a non-alphabetic order, give the column a TanStack sortingFn:

const SEVERITY_RANK = { Critical: 0, Error: 1, Warning: 2, Running: 3 };

{
  accessorKey: 'severity',
  sortingFn: (rowA, rowB) =>
    SEVERITY_RANK[rowA.original.severity] - SEVERITY_RANK[rowB.original.severity],
  header: ({ column }) => <DataTableColumnHeader column={column} title="Severity" />,
}

For server-side sorting set manualSorting and drive the state yourself — DataTable reports what the user asked for and skips its own comparator, so already-sorted data isn't re-sorted:

<DataTable
  columns={columns}
  data={serverRows}
  manualSorting
  sorting={sorting}
  onSortingChange={(updater) => {
    const next = typeof updater === 'function' ? updater(sorting) : updater;
    setSorting(next);
    refetch(next); // mapping sort state to a query stays your job
  }}
/>

Filtering

DataTableToolbar wires a plain text search to one column via searchKey. Without filter fields it also renders a Reset button whenever a filter is applied:

<DataTableToolbar
  table={table}
  leading={<TenantSwitcher />}        {/* optional scope switcher, before search */}
  searchKey="email"
  searchPlaceholder="Filter emails…"
/>

Pass filter fields as children for per-column filtering. They render inside a FilterSearchFilters popover, and an applied-filter chip row appears below the toolbar. Each field reads and writes the popover's draft through useFilterSearchFilters(), keyed by column id — Apply commits the draft to the table's column filters (leaving the searchKey text filter untouched), Cancel reverts it, and removing a chip clears that one column:

function SeverityFilterField() {
  const { filters, setFilter } = useFilterSearchFilters();
  return (
    <InputSelect
      items={options}
      value={(filters.severity as string) ?? 'all'}
      onValueChange={(next) => setFilter('severity', next === 'all' ? undefined : next)}
    >
      {/* … */}
    </InputSelect>
  );
}

<DataTableToolbar
  table={table}
  filtersLabel="Filters"
  getFilterChipLabel={(key, value) => `${key}: ${String(value)}`}
>
  <SeverityFilterField />
</DataTableToolbar>

The column the field targets needs a matching filterFn (e.g. filterFn: 'equals') on its ColumnDef.

Pagination

DataTablePagination renders the selected-row count, a rows-per-page Select (pageSizeOptions, default [10, 20, 30, 40, 50]), the current page indicator, and first / previous / next / last icon buttons (first and last appear from the lg breakpoint up). It needs an instance with getPaginationRowModel().

<DataTablePagination table={table} pageSizeOptions={[5, 10, 20]} />

Column resizing

enableColumnResizing renders a drag handle at each resizable header's trailing edge (TanStack's columnResizeMode: 'onChange', so widths track the drag live). Pass onColumnSizingChange to persist widths, and opt individual columns out with enableResizing: false:

<DataTable
  columns={columns}
  data={rows}
  enableColumnResizing
  onColumnSizingChange={(updater) => persistWidths(updater)}
/>

Resizing makes every column's width deterministic, so give each column an explicit size (otherwise TanStack's 150px default applies to columns you never touched — noticeable on narrow chevron columns). Without enableColumnResizing, a column only gets a fixed width if its ColumnDef sets size; the rest stay in native table auto-layout.

id: 'select' is a reserved column id: a selection ColumnDef with that id always renders at a fixed 48px and never gets a resize handle, the same way the built-in trailing __actions column is always 48px — no size or enableResizing: false needed, and neither can override it. This is a different column from Table's hand-authored TableSelectCell, which is a fixed 32px — the two share a role but not a width.

The handle is keyboard-operable: focus it and use / to step 10px, or Shift + arrow for 50px, clamped to the column's minSize/maxSize.

Column reordering

enableColumnReordering makes every non-pinned header cell draggable: grab one and drop it on another header to move that column into its place (native HTML5 drag-and-drop over TanStack's columnOrder; the grab/grabbing cursors use the literal cursor-grab/cursor-grabbing utilities — there's no dedicated Figma "Draggable" token yet, unlike the resize handle's generated --ui-resizable-cursor). Pinned columns are excluded — they're anchored to a table edge by definition.

<DataTable
  columns={columns}
  data={rows}
  enableColumnReordering
  columnOrder={columnOrder}
  onColumnOrderChange={setColumnOrder}
/>

columnOrder/onColumnOrderChange are optional — omit both and DataTable keeps the order internally; pass them to persist the user's order. The gesture is pointer-only for now: there is no keyboard equivalent yet.

Row actions vs. bulk actions

Per-row actions and bulk actions are mutually exclusive, and the switch point is simply "anything selected": isBulkSelectionActive(table) is true as soon as one or more rows are selected — a single selected row already counts.

DataTableBulkActionsBar derives that itself (it stays mounted and switches between its idle and active states), and TableActionsCell takes the same flag as bulkSelectionActive — it then keeps its 48px column but renders no trigger and no hover tint. See the data table bulk actions pattern.

Sticky (pinned) and wrapping columns

ColumnDef.meta is augmented by ui-react with two flags. pin drives TanStack's native column pinning and renders the column as position: sticky cells (with an opaque row background so scrolled cells don't show through); wrap lets a column's header and cells wrap onto multiple lines instead of truncating, mirroring the Table primitives' wrap prop.

const columns: ColumnDef<Device>[] = [
  { accessorKey: 'name', header: 'Name', meta: { pin: 'left' }, size: 150 },
  { accessorKey: 'note', header: 'Note', meta: { wrap: true }, size: 280 },
  { accessorKey: 'status', header: 'Status', meta: { pin: 'right' }, size: 120 },
];

Removing a meta.pin un-pins the column on the next render. Pinning is driven only for DataTable's own instance — with an external table, pin columns yourself via column.pin().

Row expansion

getRowCanExpand marks which rows can expand, renderExpandedRow renders the detail row beneath them (spanning every visible column). Put a DataTableExpandTrigger in a column's cell so the affordance sits in a real column instead of relying on a whole-row click:

const columns: ColumnDef<Payment>[] = [
  {
    id: 'expand',
    header: () => null,
    cell: ({ row }) => <DataTableExpandTrigger row={row} />,
    size: 44,
    enableSorting: false,
    enableHiding: false,
  },
  // …
];

<DataTable
  columns={columns}
  data={payments}
  getRowCanExpand={() => true}
  renderExpandedRow={(row) => <PaymentDetails payment={row.original} />}
/>

The trigger renders nothing for a row that can't expand. With an external table, configure getRowCanExpand + getExpandedRowModel() on that instance — renderExpandedRow is still honored.

Custom rows and empty state

renderRow replaces DataTable's per-cell render path for a row entirely, which is the escape hatch for a memoized row that must survive frequent parent re-renders (e.g. a polling screen). The exported getCellStyle, getPinnedStyle and getColumnWidth helpers let a custom row keep the default cell styling:

const PolicyRowView = memo(
  ({ row }: { row: Row<Policy> }) => (
    <TableRow>
      <TableCell>{row.original.name}</TableCell>
      <TableCell>{row.original.status}</TableCell>
    </TableRow>
  ),
  (prev, next) => prev.row.original === next.row.original
);

<DataTable
  columns={columns}
  data={rows}
  renderRow={(row) => <PolicyRowView key={row.id} row={row} />}
/>

A renderRow row never gets an expanded-content row appended — read row.getIsExpanded() and render it yourself if you need both.

If you only need to change the empty-state text, emptyLabel overrides the default "No results." copy without a render prop. renderEmptyState replaces the whole row and receives hasFilters, so you can tell "no data at all" from "no matches" (the copy and its localization stay yours):

<DataTable
  columns={columns}
  data={rows}
  renderEmptyState={({ hasFilters }) => (
    <EmptyScreen title={hasFilters ? t('noMatches') : t('noDevices')} />
  )}
/>

Infinite scroll

paginationMode="infinite" drops the paginated row model — data is the full accumulated array you append to — and renders a sentinel row that calls onLoadMore as it scrolls into view. While isLoadingMore is true, further calls are suppressed and a trailing loading row renders (role="status" + aria-live="polite", with screen-reader-only "Loading more rows…" text):

<DataTable
  columns={columns}
  data={loadedRows}
  paginationMode="infinite"
  onLoadMore={loadNextPage}
  loadMoreRootMargin="200px"
  hasNextPage={hasNextPage}
  isLoadingMore={isLoadingMore}
/>
  • Fetching, cursor/offset tracking, dedup and accumulating data stay yours.
  • The sentinel needs at least one rendered row, so it cannot drive the first fetch — seed page one yourself (e.g. on mount).
  • loadMoreRootMargin (CSS margin syntax) fires onLoadMore before the sentinel is literally visible. Tune it against your page size: a large margin with small pages can legitimately trigger several calls back to back.
  • It does not compose with virtualization — for a very large accumulated list, virtualize the raw Table primitives yourself (see Advanced compositions below).

Server-driven usage

The three server-facing pieces compose: render from your own instance (table), or keep DataTable's and hand it manualSorting + paginationMode="infinite"

  • renderRow — the second shape (manual sorting, infinite scroll and memoized rows) needs no external instance at all.

Bookmarkable URL state

useTableUrlState mirrors pagination, sorting and column filters into the query string so a view is bookmarkable and restores on load. It is router-agnostic — it only uses window.location + history.pushState/popstate — and its keys are namespaced (tbl_page, tbl_size, tbl_sort, tbl_filter) so they can't collide with the rest of the URL. Wire its setters into a useReactTable instance's controlled state:

import { useTableUrlState } from '@acronis-platform/ui-react';

const { state, setPagination, setSorting, setColumnFilters } = useTableUrlState({
  defaultPageSize: 5,
});

const table = useReactTable({
  data,
  columns,
  getCoreRowModel: getCoreRowModel(),
  getFilteredRowModel: getFilteredRowModel(),
  getPaginationRowModel: getPaginationRowModel(),
  onPaginationChange: setPagination,
  onSortingChange: setSorting,
  onColumnFiltersChange: setColumnFilters,
  state: { pagination: state.pagination, sorting: state.sorting, columnFilters: state.columnFilters },
});

Encoding: tbl_page is 1-based, tbl_sort is id:asc|desc pairs joined by ,, tbl_filter is id:<encoded-value> pairs joined by ,. Defaults are omitted (page 1 and the default page size never appear), and setters called together in one handler collapse into a single history entry. Override the keys with paramKeys, and reuse the pure parseTableUrlState / serializeTableUrlState helpers to seed state on the server or in a test.

Advanced compositions

The heavier behaviors stay out of DataTable (to keep it lean) — compose the Table primitives + TanStack directly instead:

  • Tree mode — pass getSubRows: (row) => row.children + getExpandedRowModel() and indent the name cell by row.depth.
  • Row groupsstate.grouping + getGroupedRowModel() + getExpandedRowModel(); render a header row for row.getIsGrouped().
  • Virtual scrollinguseVirtualizer from @tanstack/react-virtual over a fixed-height scroll container, with spacer rows above/below the visible window (keeps the column layout intact). Add @tanstack/react-virtual yourself.
  • Toolbar + date range — a DateRangePicker period field living inside the toolbar's filters popover (popover over popover).

Accessibility

  • Renders a real table through the Table primitives, so headers and cells keep native table semantics.
  • The sort control is a real button (Enter/Space) labelled "Sort by title" (override via sortLabel to localize); direction is conveyed by the arrow icon next to the always-visible title.
  • The resize handle is a role="separator" with aria-orientation="vertical", aria-valuenow/min/max and a "Resize column" label (override via resizeColumnLabel to localize); arrow keys resize it.
  • DataTableExpandTrigger is a button carrying aria-expanded and an "Expand row" / "Collapse row" label (override via expandLabel / collapseLabel to localize).
  • Pagination controls are icon buttons with explicit labels; the rows-per-page select is labelled "Rows per page".
  • Selection checkboxes need your own aria-label ("Select row" / "Select all") — they have no visible label in the cell.
  • The infinite-scroll loading row announces itself politely, so a fetch in flight isn't conveyed by the animated placeholder alone.

API Reference

DataTable

Prop

Type

Column meta

Set on a ColumnDef, not on DataTable (ui-react augments TanStack's ColumnMeta).

KeyTypePurpose
pin'left' | 'right'Pin the column to that edge (sticky on horizontal scroll).
wrapbooleanLet the column's header + cells wrap onto multiple lines.

Companion parts

ComponentProps
DataTableColumnHeadercolumn (required), title (required), plus any button attribute.
DataTableToolbartable (required), leading, searchKey, searchPlaceholder (default 'Filter…'), children, filtersLabel (default 'Filters'), getFilterChipLabel.
DataTablePaginationtable (required), pageSizeOptions (default [10, 20, 30, 40, 50]).
DataTableViewOptionstable (required), iconOnly, triggerLabel (default 'View'), triggerAriaLabel (default 'Column settings').
DataTableExpandTriggerrow (required), plus any button attribute; forwards a ref.

useTableUrlState

Prop

Type

Prop

Type

Style helpers

getPinnedStyle(column), getColumnWidth(column, enableColumnResizing) and getCellStyle(cell, enableColumnResizing) are exported so a renderRow row can reproduce DataTable's own cell styling.

Edit on GitHub

On this page