Skip to content

Bulk Actions

Problem

Bulk actions are a recurring admin UI workflow:

  • Select many items (table rows, cards, list entries)
  • Show a toolbar only when something is selected
  • Perform actions (delete, export, status change)
  • Keep selection state consistent across child components

Common failure modes:

  • Selection state lives in multiple places (useState in the table, another in the toolbar)
  • "Select all" and indeterminate state drift out of sync
  • Destructive actions run without consistent confirmation
  • Selection isn’t cleared after the operation, leading to accidental repeated actions

Solution

Compose two shared primitives:

Drive UI off a few derived values:

  • selectedCount controls toolbar visibility
  • isAllSelected(ids) / isSomeSelected(ids) control header checkbox state

Implementation

Page-level selection + conditional bulk toolbar

tsx
import React from 'react'
import { useBulkSelection, useConfirmation } from '@/shared/hooks'
import { ConfirmDialog } from '@/shared/ui/components/ConfirmDialog'

type Row = { id: number; name: string }

export function UsersList({ rows }: { rows: Row[] }) {
  const selection = useBulkSelection<number>()
  const { state, confirm, handleConfirm, handleCancel } = useConfirmation()

  const ids = rows.map((r) => r.id)
  const allSelected = selection.isAllSelected(ids)
  const someSelected = selection.isSomeSelected(ids)

  const toggleAll = () => {
    if (allSelected) selection.clear()
    else selection.selectAll(ids)
  }

  const requestDeleteSelected = async () => {
    if (selection.selectedCount === 0) return

    const ok = await confirm({
      title: `Delete ${selection.selectedCount} item(s)?`,
      description: 'This action cannot be undone.',
      confirmLabel: 'Delete',
      cancelLabel: 'Cancel',
      variant: 'destructive',
    })

    if (!ok) return

    // Perform your domain action using the selected ids.
    // Example: await repository.deleteMany(Array.from(selection.selectedIds))

    selection.clear()
  }

  return (
    <>
      {selection.selectedCount > 0 && (
        <div>
          <strong>{selection.selectedCount} selected</strong>
          <button type="button" onClick={selection.clear}>Clear</button>
          <button type="button" onClick={requestDeleteSelected}>Delete</button>
        </div>
      )}

      <table>
        <thead>
          <tr>
            <th>
              <input
                type="checkbox"
                checked={allSelected}
                aria-checked={someSelected ? 'mixed' : allSelected}
                onChange={toggleAll}
                aria-label="Select all"
              />
            </th>
            <th>Name</th>
          </tr>
        </thead>
        <tbody>
          {rows.map((row) => (
            <tr key={row.id}>
              <td>
                <input
                  type="checkbox"
                  checked={selection.isSelected(row.id)}
                  onChange={() => selection.toggle(row.id)}
                  aria-label={`Select ${row.name}`}
                />
              </td>
              <td>{row.name}</td>
            </tr>
          ))}
        </tbody>
      </table>

      <ConfirmDialog
        open={state.isOpen}
        options={state.options}
        onConfirm={handleConfirm}
        onCancel={handleCancel}
      />
    </>
  )
}

Variations

  • Bulk actions for card/list layouts
    • Keep the same page-level selection controller.
    • Cards toggle selection on click, but stop propagation on the checkbox to avoid double toggles.
  • Undoable deletes
    • After deletion, show a toast with an Undo action.
    • Keep the selection cleared; undo restores items, not selection.
  • Two-tier confirmations
    • Use different ConfirmOptions depending on action severity.
    • Example: standard delete vs “permanent delete” copy/labels.

Accessibility considerations

  • Use explicit aria-labels for row checkboxes and the “select all” checkbox.
  • Represent indeterminate selection with aria-checked="mixed".
  • Ensure the bulk toolbar is reachable via keyboard and doesn’t steal focus unexpectedly when it appears.