Appearance
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 (
useStatein 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:
useBulkSelectionfor a single source of truth for selection stateuseConfirmationto gate risky bulk operations behind an async confirm
Drive UI off a few derived values:
selectedCountcontrols toolbar visibilityisAllSelected(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
ConfirmOptionsdepending on action severity. - Example: standard delete vs “permanent delete” copy/labels.
- Use different
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.