Skip to content

DataTable v2 component guide

DataTableV2 is the current standard data table component for Arbitex admin UI pages. It implements all 10 interaction states from the Arbitex DataTable v2 design spec using CSS Grid (not a <table> element), supports server-side pagination, sorting, and filtering, and exposes a declarative bulk-action API.

Source: frontend/src/components/DataTableV2.tsx

If you are migrating an existing component from DataTable (v1), see the migration checklist at the end of this guide.


import { DataTableV2, type ColumnDef } from '@/components/DataTableV2';
interface DLPRule {
id: string;
detector_name: string;
entity_type: string;
action_tier: string;
enabled: boolean;
}
const columns: ColumnDef<DLPRule>[] = [
{ key: 'detector_name', header: 'Detector' },
{ key: 'entity_type', header: 'Entity Type' },
{
key: 'action_tier',
header: 'Action',
render: (row) => <ActionBadge tier={row.action_tier} />,
sortable: false,
},
{
key: 'enabled',
header: 'Status',
render: (row) => (row.enabled ? 'Active' : 'Disabled'),
},
];
function DLPRulesTable({ rules }: { rules: DLPRule[] }) {
return (
<DataTableV2
columns={columns}
data={rules}
getRowId={(r) => r.id}
/>
);
}

Each column is defined with a ColumnDef<T> object:

export interface ColumnDef<T> {
key: string; // Row property key; used for default rendering and sorting
header: string; // Column header text
render?: (row: T) => React.ReactNode; // Custom cell renderer
sortable?: boolean; // Default: true
filter?: FilterDef; // Optional inline column filter
width?: string; // Optional Tailwind width class (e.g. "w-32")
sticky?: boolean; // Pins column as sticky-left (CSS Grid sticky)
}

Add filter to a column to render a filter control below the header:

const columns: ColumnDef<DLPRule>[] = [
{
key: 'action_tier',
header: 'Action',
filter: {
type: 'select',
options: [
{ value: 'block', label: 'Block' },
{ value: 'redact', label: 'Redact' },
{ value: 'log_only', label: 'Log Only' },
{ value: 'prompt', label: 'Prompt' },
],
},
},
{
key: 'detector_name',
header: 'Detector',
filter: { type: 'text', placeholder: 'Filter detectors...' },
},
];
Filter typeControl rendered
textText input with debounce
selectSelect dropdown
dateDate picker input

Pass data with rows. Standard rendering with alternating row backgrounds.

<DataTableV2 columns={columns} data={rules} getRowId={(r) => r.id} />

Pass loading={true}. The table renders shimmer skeleton rows. Existing data (if any) is replaced by skeleton rows — the column headers remain visible.

<DataTableV2
columns={columns}
data={[]}
getRowId={(r) => r.id}
loading={isLoading}
/>

When data is an empty array and loading is false, the empty state renders. Customize the message and optional icon:

<DataTableV2
columns={columns}
data={[]}
getRowId={(r) => r.id}
emptyMessage="No DLP rules configured."
emptyIcon={<ShieldIcon className="h-8 w-8 text-content-muted" />}
/>

Column filters appear when a column has a filter definition. Active filter values are shown as removable filter pills above the table. Filter state can be managed internally (uncontrolled) or externally (controlled):

Controlled filtering (for server-side filtering):

<DataTableV2
columns={columns}
data={data}
getRowId={(r) => r.id}
activeFilters={filters} // Record<string, string>
onFilterChange={(next) => {
setFilters(next);
fetchData(next); // re-fetch with new filter params
}}
/>

Uncontrolled filtering: omit activeFilters and onFilterChange. The component filters data client-side.

Enable checkboxes and define bulk actions:

<DataTableV2
columns={columns}
data={rules}
getRowId={(r) => r.id}
selectable
bulkActions={[
{
label: 'Enable',
onClick: (selectedIds) => handleBulkEnable(selectedIds),
},
{
label: 'Delete',
onClick: (selectedIds) => handleBulkDelete(selectedIds),
variant: 'danger',
},
]}
/>

When rows are selected, a sticky bulk-action bar appears above the table with the action buttons and a count of selected rows. The onClick handler receives a Set<string> of row IDs (from getRowId).

Controlled selection:

<DataTableV2
...
selectedRows={selectedIds} // Set<string>
onSelectionChange={(next) => setSelectedIds(next)}
/>

Sortable columns show tri-state sort indicators (ascending ▲ / descending ▼ / none). By default, sorting is managed internally (client-side sort on data).

Controlled sorting (for server-side sorting):

<DataTableV2
columns={columns}
data={data}
getRowId={(r) => r.id}
sortColumn={sortCol}
sortDirection={sortDir}
onSort={(column, direction) => {
setSortCol(column);
setSortDir(direction);
fetchData({ sort: column, direction });
}}
/>

Pass an error string to show an error banner above the table. Provide onRetry to show a retry button:

<DataTableV2
columns={columns}
data={staleData} // stale data remains visible behind an overlay
getRowId={(r) => r.id}
error={fetchError?.message}
onRetry={refetch}
/>

When error is set, the table renders a stale-data overlay and the error banner at the top. The data rows remain visible (dimmed) to give context.

Provide a rowActions function that returns an array of RowAction objects. The table renders a per-row action menu in a dedicated column:

<DataTableV2
columns={columns}
data={rules}
getRowId={(r) => r.id}
rowActions={(row) => [
{
label: 'Edit',
icon: <PencilIcon className="h-4 w-4" />,
onClick: () => openEditDialog(row),
},
{
label: 'Delete',
icon: <TrashIcon className="h-4 w-4" />,
onClick: () => handleDelete(row.id),
variant: 'danger',
},
]}
/>

The pending flag on a RowAction replaces the icon with a spinner:

{
label: 'Deleting...',
pending: true,
onClick: () => {},
}

Inline controls (edit inputs within cells) are rendered by the consumer via the column’s render function. DataTableV2 does not manage inline edit state — the parent component controls it:

{
key: 'threshold',
header: 'Threshold',
render: (row) =>
editingId === row.id ? (
<input
type="number"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={() => saveRow(row.id, editValue)}
className="w-20 rounded border border-border-token px-2 py-1 text-sm"
autoFocus
/>
) : (
<span onClick={() => startEdit(row.id, row.threshold)}>{row.threshold}</span>
),
}

Pass onRowClick to make rows clickable. The cursor changes to pointer and a chevron indicator appears on the right:

<DataTableV2
columns={columns}
data={rules}
getRowId={(r) => r.id}
onRowClick={(row) => navigate(`/admin/dlp-rules/${row.id}`)}
/>

For large datasets, use server-side pagination. Pass the pagination object and respond to page/size changes:

<DataTableV2
columns={columns}
data={pageData} // current page only
getRowId={(r) => r.id}
pagination={{
page: currentPage,
pageSize: pageSize,
total: totalCount, // total row count across all pages
}}
onPageChange={(page) => {
setCurrentPage(page);
fetchPage(page, pageSize);
}}
onPageSizeChange={(size) => {
setPageSize(size);
setCurrentPage(1);
fetchPage(1, size);
}}
/>

When pagination is omitted, the component paginates data client-side.


Set width on any column to control column sizing:

{ key: 'id', header: 'ID', width: 'w-32', sortable: false }

DataTableV2 uses CSS Grid, so column widths are set as minmax(width, 1fr) in the grid template. Unspecified columns share remaining space equally.

Mark a column sticky: true to pin it to the left during horizontal scroll:

{ key: 'detector_name', header: 'Detector', sticky: true }

Sticky columns get position: sticky; left: 0; z-index: 1; background: var(--surface-primary).


DataTable (v1) is in frontend/src/components/DataTable.tsx. It uses a <table> element, client-side only pagination, and a functional rowActions render prop. Replace it with DataTableV2 using the checklist below.

// Before
import { DataTable } from '@/components/DataTable';
// After
import { DataTableV2 } from '@/components/DataTableV2';
v1 propv2 equivalentNotes
getRowKeygetRowIdRenamed — same signature (row: T) => string
isLoadingloadingRenamed
emptyMessageemptyMessageUnchanged
defaultPageSizepagination.pageSize (server-side) or remove (client-side default: 10)v2 always shows pagination when data exists
searchPlaceholdercolumns[n].filter.placeholderGlobal search removed in v2; use per-column filter: { type: 'text' }
onRowClickonRowClickUnchanged
renderExpandedNot available in v2Use a detail panel route or a dialog instead
rowActions: (row) => ReactNoderowActions: (row) => RowAction[]Changed from render prop to declarative RowAction[] array
selectableselectableUnchanged
onSelectionChangeonSelectionChangeUnchanged
selectedKeys (Set<string>)selectedRows (Set<string>)Renamed
onRefreshNot in v2Manage refresh externally and pass new data
tableIdNot in v2Remove
  • Global search input: v2 uses per-column filters. Add filter: { type: 'text' } to each column you want to be searchable.
  • Expandable rows (renderExpanded): v2 does not support accordion expansion. Refactor to a navigation pattern (row click → detail page) or a modal.
  • Auto-refresh (onRefresh, tableId): v2 does not include a built-in refresh timer. Wire refresh into the parent component using useEffect or react-query/swr.
// Before (v1)
<DataTable
columns={columns}
data={rules}
isLoading={loading}
getRowKey={(r) => r.id}
searchPlaceholder="Search rules..."
rowActions={(row) => <DeleteButton onClick={() => del(row.id)} />}
selectable
onSelectionChange={setSelected}
selectedKeys={selected}
/>
// After (v2)
<DataTableV2
columns={[
...columns,
{ key: 'detector_name', header: 'Detector', filter: { type: 'text', placeholder: 'Search rules...' } },
]}
data={rules}
loading={loading}
getRowId={(r) => r.id}
rowActions={(row) => [
{ label: 'Delete', icon: <TrashIcon />, onClick: () => del(row.id), variant: 'danger' },
]}
selectable
onSelectionChange={setSelected}
selectedRows={selected}
/>