Data table bulk actions
Select rows in a data table and act on the selection via a contextual bulk-action bar.
The approved way to offer row selection with bulk actions. A checkbox selection
column drives DataTableBulkActionsBar — a persistent bar above the grid
whose children are your bulk actions (Export, Delete, …), with a
trailing selection summary and a Deselect control.
The activation threshold belongs to the components, not to you:
isBulkSelectionActive(table) — one or more rows selected, including
exactly one. The bar is always mounted; rather than appearing and
disappearing, it switches between two states on that predicate:
- Idle (nothing selected) — the actions are disabled through a native
<fieldset disabled>, and the trailing side shows yourloadedLabel(e.g. "25 of 1250 items loaded") instead of a selection summary. - Active (one or more rows selected) — the actions enable and the trailing side shows the selection summary plus Deselect.
TableActionsCell takes the same flag as bulkSelectionActive to blank out each
row's trigger while the bulk scope is active.
Because the bulk bar and the row checkboxes must read the same selection
state, that state needs one source of truth. This page composes the
Table primitives + DataTablePagination over a single useReactTable
instance. The self-contained DataTable is equally valid: it keeps its own
internal instance, but its controlled rowSelection / onRowSelectionChange
props let you lift the selection into your own state and drive a minimal second
instance for the bar off the same state — the shape of the CoreCapabilities /
CoreCapabilitiesWithPagination stories.
When to use
- A table where users pick several rows and apply one action to all of them.
- Destructive or batch operations (delete, export, assign) across rows.
When not to use
- A read-only or single-select table — use
DataTabledirectly. - One action per row — put a row action (menu / button) in a cell instead.
Anti-patterns
- Leaving
DataTable's selection uncontrolled while a separateuseReactTabledrives the bar — two independentrowSelectionstates that diverge. Either build one shared instance, or lift a singlerowSelectionstate and pass it to both (DataTable'srowSelection/onRowSelectionChangeplus the bar's instance). - Mounting the bar conditionally so it appears and disappears — it is built to stay put and toggle its own state. A mount/unmount reflows the page on every first and last selection.
- Omitting
loadedLabelwhen the idle state has something useful to say (e.g. "25 of 1250 items loaded") — the trailing area then sits empty until the first selection. - Re-deriving the condition in your own code instead of calling
isBulkSelectionActive(table)— the predicate is exported so the bar and everyTableActionsCellcan't drift apart. - Hiding the selection count — always show "N items selected" so the scope is clear.
How it works
const [rowSelection, setRowSelection] = useState({});
const table = useReactTable({
data,
columns, // includes a checkbox 'select' column
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
state: { rowSelection },
});
const bulkSelectionActive = isBulkSelectionActive(table);
<DataTableBulkActionsBar
table={table}
loadedLabel={`${data.length} items total`}
>
<Button variant="secondary">Export</Button>
<Button variant="destructive">Delete</Button>
</DataTableBulkActionsBar>Mount the bar unconditionally, then render the grid from
table.getHeaderGroups() / getRowModel() — passing bulkSelectionActive to
each row's TableActionsCell — and pass the same table to
DataTablePagination. The bar reads the count off table.getSelectedRowModel(),
disables its own actions while nothing is selected, and wires Deselect to
table.resetRowSelection() itself — it owns no state.
Its strings are localizable: selectedLabel (a (count: number) => string,
default (count) => `${count} item${count === 1 ? '' : 's'} selected:` ),
clearLabel (default "Deselect"), and loadedLabel (no default — omit it and
the idle state's trailing area stays empty).