Add bulk actions
Use this guide when selected rows should trigger real work.
Start from a clear selection model
Bulk actions should sit on top of a selection model users already understand.
Add them after you have:
- a table with
selection.enabled - clear distinction between selected rows, active row, and preview state
- at least one real multi-row task to support
If the product still cannot answer "what should happen after these rows are selected?", do not add a bulk action menu yet.
Add the bulk actions config beside selection
Bulk actions are enabled with the bulkActions prop.
<Datatable
tableKey="customers"
data={rows}
columns={columns}
getRowId={(row) => row.id}
selection={{
enabled: true,
allowSelectAllMatching: true,
}}
bulkActions={{
triggerLabel: "Actions",
actions,
serverExecutor: async (request) => {
await fetch("/api/customers/bulk", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(request),
})
},
}}
/>The action island only appears when there is at least one selected row.
[!NOTE] Screenshot placeholder: bulk action island visible above the table with selected rows, a clear selected count, and action wording that makes scope obvious.
Action labels
Users should be able to predict the outcome from the action title alone.
Good labels include:
Archive selected rowsAssign selected customersExport all matching accounts
Avoid vague labels like Run, Apply, or Continue.
That matters even more when selection might represent the whole filtered result instead of only visible rows.
Use client actions for lightweight local work
Client actions are a good fit when the selected rows already exist in memory, the action only needs the loaded row data, and the work stays inside the current session.
const actions = [
{
id: "copy-emails",
title: "Copy emails",
onSelect: async ({ selectedRows, selectedCount, clearSelection, closeDialog }) => {
await navigator.clipboard.writeText(selectedRows.map((row) => row.email).join("\n"))
toast.success(`Copied ${selectedCount} email${selectedCount === 1 ? "" : "s"}.`)
clearSelection()
closeDialog()
},
},
]Use this path for things like:
- copying data to the clipboard
- opening a local confirm step
- updating app state that does not require a backend round trip
If the action mutates protected data, sends notifications, exports large datasets, or needs audit logs, move it to the server path instead.
Use server actions when the backend owns the outcome
Server actions are the safer default for durable work.
const actions = [
{
id: "export",
title: "Export customers CSV",
execution: "server",
serverActionId: "export-customers-csv",
buildServerPayload: ({ selectedCount }) => ({
requestedCount: selectedCount,
requestedBy: currentUser.id,
}),
},
]The table passes a DataTableBulkServerActionRequest to serverExecutor.
type DataTableBulkServerActionRequest = {
actionId: string
selection: DataTableSelectionDescriptor
payload?: unknown
}That request shape is what lets your backend distinguish explicit row IDs from an all-matching selection built from the current query.
Add confirmation steps for destructive or expensive actions
If an action deletes, archives, reassigns, bills, or launches a long-running job, give users a deliberate confirm step.
{
id: "archive",
title: "Archive customers",
getInitialStep: () => ({
kind: "confirm",
title: "Archive selected customers?",
description: "Archived customers leave the active workspace view.",
confirmLabel: "Archive",
onConfirm: async ({ selectedRows, clearSelection, closeDialog }) => {
await archiveCustomers(selectedRows)
clearSelection()
closeDialog()
},
}),
}A strong confirmation step restates:
- what action will happen
- how many rows are affected
- whether the scope is selected rows or all matching rows
- any permission or irreversibility warning
[!NOTE] Screenshot placeholder: destructive bulk-action confirm dialog showing exact row count, action wording, and a clear warning about irreversible scope.
Make all-matching scope explicit
When allowSelectAllMatching is enabled, the selected set may include rows that are not currently loaded in the browser.
That means your action labels, confirm step, and backend contract all need to say what will actually happen.
type DataTableSelectionDescriptor =
| {
kind: "explicit"
ids: string[]
}
| {
kind: "allMatching"
query: OnlineQueryStateInput
includedIds: string[]
excludedIds: string[]
totalMatchingRows: number
}For allMatching, the backend should recompute the target rows from the query and then apply any exclusions.
export async function runBulkAction(request: DataTableBulkServerActionRequest) {
const targetIds =
request.selection.kind === "explicit"
? request.selection.ids
: await resolveMatchingIds(db, request.selection.query, request.selection.excludedIds)
if (request.actionId === "customers.archive") {
await db.update(customers).set({ archivedAt: new Date() }).where(inArray(customers.id, targetIds))
}
}Do not assume the browser will send every matching row ID for large online tables.
Nested steps
Bulk actions can open an items, confirm, or custom step.
That is useful when one entry point should fan out into several related operations.
const actions = [
{
id: "assign",
title: "Assign…",
getInitialStep: () => ({
kind: "items",
title: "Assign selected accounts",
searchPlaceholder: "Find owner…",
items: owners.map((owner) => ({
id: owner.id,
title: owner.name,
execution: "server",
serverActionId: "accounts.assign-owner",
buildServerPayload: () => ({ ownerId: owner.id }),
})),
}),
},
{
id: "archive",
title: "Archive…",
getInitialStep: () => ({
kind: "confirm",
title: "Archive selected accounts?",
description: "Archived accounts leave the active pipeline.",
confirmLabel: "Archive",
execution: "server",
serverActionId: "accounts.archive",
buildServerPayload: () => ({ archiveReason: "user_requested" }),
onConfirm: async () => {},
}),
},
{
id: "export",
title: "Export…",
getInitialStep: () => ({
kind: "custom",
title: "Choose export format",
render: ({ executeServerAction, closeDialog }) => (
<div className="space-y-2">
<button onClick={async () => {
await executeServerAction({ actionId: "accounts.export", payload: { format: "csv" } })
closeDialog()
}}>Export CSV</button>
<button onClick={async () => {
await executeServerAction({ actionId: "accounts.export", payload: { format: "xlsx" } })
closeDialog()
}}>Export XLSX</button>
</div>
),
}),
},
]Use:
itemsfor a short searchable list of choicesconfirmfor one deliberate yes/no stepcustomwhen you need a tiny piece of app-owned UI such as format buttons or a date picker
Keep the step flow short. If the action becomes a full workflow with complex validation, it probably belongs in app-owned UI instead.
[!NOTE] Screenshot placeholder: bulk-action menu branching into a short secondary choice list such as “Assign…” or “Move to status…”, showing auxiliary step UI beyond the first action island.
Clear selection after completion
The built-in dialog clears selection and closes automatically after successful server execution.
For client actions, you control that behavior yourself with clearSelection() and closeDialog().
Clear selection when completion means the working set is done.
Keep selection intact when users are likely to retry, compare outcomes, or run a second action immediately after an error.
Errors and auditability
For server-backed actions, your app should own:
- permission checks
- retry behavior
- long-running job status
- audit logs
- success and failure messaging near the table or in surrounding product UI
The table gives you the selection contract and command surface. Your app still owns operational safety.
Verify bulk actions before you move on
Before you continue, confirm that:
- each action corresponds to a real multi-row task
- labels and confirmation text make scope obvious
- destructive or expensive actions require deliberate confirmation
- client actions only handle work that is safe to do locally
- server actions receive enough payload to audit the request
- all-matching selections are resolved server-side from query plus exclusions
- success and error behavior leave users confident about what happened