Skip to content

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 selectedId instead)
  • 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 useState in multiple places (you’ll re-implement selectAll, 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 selectedIds to domain entities in the page/action layer)


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.
  • useBulkSelection does not implement keyboard interactions by itself; if you need keyboard-driven bulk workflows, compose it with useKeyboardShortcut and keep the selection logic centralized.