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.
Quick start
Section titled “Quick start”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} /> );}Column configuration
Section titled “Column configuration”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)}Column filters
Section titled “Column filters”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 type | Control rendered |
|---|---|
text | Text input with debounce |
select | Select dropdown |
date | Date picker input |
All 10 states
Section titled “All 10 states”1. Populated (default)
Section titled “1. Populated (default)”Pass data with rows. Standard rendering with alternating row backgrounds.
<DataTableV2 columns={columns} data={rules} getRowId={(r) => r.id} />2. Loading
Section titled “2. Loading”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}/>3. Empty
Section titled “3. Empty”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" />}/>4. Filtered
Section titled “4. Filtered”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.
5. Selected (bulk actions)
Section titled “5. Selected (bulk actions)”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)}/>6. Sorted
Section titled “6. Sorted”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 }); }}/>7. Error
Section titled “7. Error”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.
8. Row actions
Section titled “8. Row actions”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: () => {},}9. Inline controls
Section titled “9. Inline controls”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> ),}10. Clickable rows
Section titled “10. Clickable rows”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}`)}/>Server-side pagination
Section titled “Server-side pagination”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.
Styling customization
Section titled “Styling customization”Width classes
Section titled “Width classes”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.
Sticky columns
Section titled “Sticky columns”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).
Migration from DataTable v1
Section titled “Migration from DataTable v1”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.
Import change
Section titled “Import change”// Beforeimport { DataTable } from '@/components/DataTable';
// Afterimport { DataTableV2 } from '@/components/DataTableV2';Props migration checklist
Section titled “Props migration checklist”| v1 prop | v2 equivalent | Notes |
|---|---|---|
getRowKey | getRowId | Renamed — same signature (row: T) => string |
isLoading | loading | Renamed |
emptyMessage | emptyMessage | Unchanged |
defaultPageSize | pagination.pageSize (server-side) or remove (client-side default: 10) | v2 always shows pagination when data exists |
searchPlaceholder | columns[n].filter.placeholder | Global search removed in v2; use per-column filter: { type: 'text' } |
onRowClick | onRowClick | Unchanged |
renderExpanded | Not available in v2 | Use a detail panel route or a dialog instead |
rowActions: (row) => ReactNode | rowActions: (row) => RowAction[] | Changed from render prop to declarative RowAction[] array |
selectable | selectable | Unchanged |
onSelectionChange | onSelectionChange | Unchanged |
selectedKeys (Set<string>) | selectedRows (Set<string>) | Renamed |
onRefresh | Not in v2 | Manage refresh externally and pass new data |
tableId | Not in v2 | Remove |
Removed features
Section titled “Removed features”- 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 usinguseEffectorreact-query/swr.
Minimal migration example
Section titled “Minimal migration example”// 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}/>