Appearance
useBulkSelection
What this solves
Bulk selection shows up everywhere in admin UI:
- Selecting rows in a table to run actions (delete, export, status changes)
- Multi-select lists (messages, users, invoices)
- Checkbox-driven flows (select some, select all, clear)
Ad-hoc useState + arrays/sets typically fail at scale because:
- Selection logic gets duplicated across pages/components
- "Select all" + indeterminate state becomes inconsistent
- Clearing selection after data changes or bulk actions is easy to forget
- Child components end up owning shared state, making bulk action toolbars brittle
useBulkSelection centralizes selection state and provides a small, consistent API that works across tables, lists, and grids.
When to use it
Use useBulkSelection when you need a reusable, page-level selection controller for:
- Tables with bulk actions
- Multi-select lists
- Checkbox-driven flows where selection affects other UI (toolbars, counters, action menus)
When NOT to use it
Do not use useBulkSelection when:
- The UI is single-select (use a single
selectedIdinstead) - The selection is purely local to a leaf component and has no shared effects
- The selection must be persisted remotely or synchronized across routes (this hook is in-memory UI state)
Basic usage
Provide the hook at the level that owns the list/table and any bulk actions.
tsx
import { useBulkSelection } from '@/shared/hooks'
type Id = string
export function Example({ ids }: { ids: Id[] }) {
const {
isSelected,
toggle,
clear,
selectAll,
selectedCount,
isAllSelected,
} = useBulkSelection<Id>()
const allSelected = isAllSelected(ids)
const toggleSelectAll = () => {
if (allSelected) clear()
else selectAll(ids)
}
return (
<div>
<button type="button" onClick={toggleSelectAll}>
{allSelected ? 'Clear all' : 'Select all'}
</button>
<button type="button" onClick={clear} disabled={selectedCount === 0}>
Clear selection
</button>
{ids.map((id) => (
<label key={id}>
<input
type="checkbox"
checked={isSelected(id)}
onChange={() => toggle(id)}
/>
{id}
</label>
))}
</div>
)
}Common patterns
Pattern 1 — Page-level selection with a stateless table
Keep selection state in the page/container and pass simple booleans + callbacks into a table/list component. This keeps the table stateless and reusable.
tsx
import { useBulkSelection } from '@/shared/hooks'
type Row = { id: number; name: string }
type TableProps = {
rows: Row[]
isSelected: (id: number) => boolean
onToggle: (id: number) => void
allSelected: boolean
someSelected: boolean
onToggleAll: () => void
}
function UsersTable({ rows, isSelected, onToggle, allSelected, someSelected, onToggleAll }: TableProps) {
return (
<table>
<thead>
<tr>
<th>
<input
type="checkbox"
checked={allSelected}
aria-checked={someSelected ? 'mixed' : allSelected}
onChange={onToggleAll}
aria-label="Select all"
/>
</th>
<th>Name</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id}>
<td>
<input
type="checkbox"
checked={isSelected(row.id)}
onChange={() => onToggle(row.id)}
aria-label={`Select ${row.name}`}
/>
</td>
<td>{row.name}</td>
</tr>
))}
</tbody>
</table>
)
}
export function UsersPage({ rows }: { rows: Row[] }) {
const {
isSelected,
toggle,
selectAll,
clear,
isAllSelected,
isSomeSelected,
} = useBulkSelection<number>()
const ids = rows.map((r) => r.id)
const allSelected = isAllSelected(ids)
const someSelected = isSomeSelected(ids)
const toggleAll = () => {
if (allSelected) clear()
else selectAll(ids)
}
return (
<UsersTable
rows={rows}
isSelected={isSelected}
onToggle={toggle}
allSelected={allSelected}
someSelected={someSelected}
onToggleAll={toggleAll}
/>
)
}Why this is recommended:
- Selection remains a single source of truth
- The table stays reusable and doesn’t need to know about bulk actions
- It’s easy to clear selection after data refreshes or bulk operations
Pattern 2 — Bulk actions toolbar visibility
Use selectedCount to drive conditional UI (toolbars, counters) and clear selection after the action.
tsx
import { useBulkSelection } from '@/shared/hooks'
type Row = { id: number; name: string }
export function BulkActionsExample({ rows }: { rows: Row[] }) {
const { selectedCount, isSelected, toggle, clear } = useBulkSelection<number>()
const handleDelete = async () => {
const selected = rows.filter((r) => isSelected(r.id))
// perform domain action using selected
// await deleteUsers(selected.map((u) => u.id))
clear()
}
return (
<div>
{selectedCount > 0 && (
<div>
<span>{selectedCount} selected</span>
<button type="button" onClick={clear}>Clear</button>
<button type="button" onClick={handleDelete}>Delete</button>
</div>
)}
{rows.map((r) => (
<label key={r.id}>
<input
type="checkbox"
checked={isSelected(r.id)}
onChange={() => toggle(r.id)}
/>
{r.name}
</label>
))}
</div>
)
}Things to avoid
- Managing selection with
useStatein multiple places (you’ll re-implementselectAll, indeterminate logic, and clear semantics repeatedly) - Letting child components own selection state (bulk toolbars and page-level actions become hard to coordinate)
- Mixing domain logic into selection (keep selection generic; map
selectedIdsto domain entities in the page/action layer)
Related primitives
API reference (concise)
ts
export type SelectableId = string | number
export interface UseBulkSelectionOptions<T extends SelectableId = SelectableId> {
initialSelected?: T[]
}
export interface UseBulkSelectionReturn<T extends SelectableId = SelectableId> {
selectedIds: Set<T>
selectedCount: number
isSelected: (id: T) => boolean
select: (id: T) => void
deselect: (id: T) => void
toggle: (id: T) => void
selectAll: (ids: T[]) => void
clear: () => void
isAllSelected: (ids: T[]) => boolean
isSomeSelected: (ids: T[]) => boolean
}
export function useBulkSelection<T extends SelectableId = SelectableId>(
options?: UseBulkSelectionOptions<T>
): UseBulkSelectionReturn<T>Final notes
- For accessibility, prefer checkbox controls with clear
aria-labels and an explicit indeterminate/mixed state when partially selected. useBulkSelectiondoes not implement keyboard interactions by itself; if you need keyboard-driven bulk workflows, compose it with useKeyboardShortcut and keep the selection logic centralized.