react-datatable
Guides

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 rows
  • Assign selected customers
  • Export 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:

  • items for a short searchable list of choices
  • confirm for one deliberate yes/no step
  • custom when 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

On this page