Hooks

useSelection

A list multi-selection state machine — Shift range, ⌘/Ctrl toggle, tri-state select-all, and automatic pruning when items change.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

/** Identity of a selectable item: it has to compare by value inside a `Set`, so strings and numbers only. */
export type SelectionId = string | number

export type SelectionMode = "single" | "multiple"

/**
 * `handleItemClick` cares about three modifier keys and nothing else. Deliberately a
 * structural type rather than `React.MouseEvent`: React's synthetic mouse event, the
 * native `MouseEvent` and `KeyboardEvent` all satisfy it, so one handler serves both
 * `onClick` and `onKeyDown` (Shift+Space range selection).

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/use-selection.json

Prompt

Build a React + TypeScript "useSelection" hook (React only — no other
dependencies; it renders nothing and owns no DOM).

Contract
- `useSelection<T, Id extends string | number>(items: readonly T[], options)`
  where options = `{ getId, mode = "multiple", defaultSelected, selected,
  onSelectionChange }`:
  - `getId: (item: T) => Id` — required, pure, stable per item.
  - `mode: "single" | "multiple"` — in single mode Shift and ⌘/Ctrl degrade to
    a plain click and `selectAll()` is a no-op.
  - `defaultSelected?: Iterable<Id>` — uncontrolled initial value, read once on
    mount; ids absent from `items` are dropped (single mode keeps the first).
  - `selected?: Iterable<Id>` — passing it (even an empty array) switches to
    controlled: the hook never mutates its own store, it only reports upward.
  - `onSelectionChange?: (ids: Id[]) => void` — fired only when the selection
    content actually changes, with ids ordered by `items` order.
- Returns `{ selected: Set<Id>, selectedIds: Id[], isSelected(id), toggle(id),
  select(id), deselect(id), selectAll(), clear(), selectRange(fromId, toId),
  handleItemClick(event, id), allSelected, someSelected, count }`.
- `someSelected` means "a non-empty strict subset" — i.e. exactly the condition
  for a header checkbox's `indeterminate` flag, so it wires straight through.
  Use `count > 0` when you need plain "anything selected".
- `selectedIds` is ordered by `items`, not by click order, and `count ===
  selectedIds.length`. Duplicate ids collapse (Set semantics).
- `handleItemClick(event, id)` takes only `{ shiftKey, metaKey, ctrlKey }`
  structurally, so a React synthetic mouse event, a native MouseEvent and a
  KeyboardEvent all satisfy it — the same handler serves `onClick` and
  `onKeyDown` (Shift+Space extends a range from the keyboard).

Behavior
- Anchor state machine (multiple mode):
  - plain click → selection becomes exactly `{id}`, anchor moves to `id`;
  - ⌘ (Apple platforms) / Ctrl (everything else) click → toggle `id`, anchor
    moves to `id`;
  - Shift click → re-draw the closed range anchor→id (order-independent) and
    **leave the anchor where it is**, so holding Shift and clicking around
    repeatedly re-slices from the same start and can shrink the range again;
  - Shift + ⌘/Ctrl click → union the range into the existing selection instead
    of replacing it (anchor still unchanged);
  - Shift with no live anchor (first click, or the anchor's row disappeared)
    degrades to a plain click.
- Platform modifier: ⌘ on Apple platforms, Ctrl elsewhere — never both. On
  macOS a Ctrl+click also emits a click event with `ctrlKey: true` while
  opening the context menu, so accepting Ctrl there would toggle a row on every
  right-click. Detect the platform inside the event handler, never during
  render (keeps the hook SSR-safe).
- `items` changes prune the selection: compare the previous id sequence with
  the current one during **render** (adjust-state), and drop selected ids that
  are gone plus an anchor pointing at a vanished row. Do NOT do this in an
  effect body — a synchronous `setState` inside an effect is both a lint error
  (react-hooks/set-state-in-effect) and one extra frame of stale counts.
  Pruning also means a removed-then-re-added row does not resurrect selected.
- Reference stability: return the *same* `Set` / array instances while the
  content is unchanged (compare element-wise, then cache via adjust-state), and
  keep every returned function identity-stable by reading the latest items /
  selection / anchor from a ref that is refreshed in an effect. Consumers put
  `selected`, `selectedIds` and the callbacks into dependency arrays; churning
  identities there is how you get "Maximum update depth exceeded".
- Every write funnels through one internal `commit(nextSet, nextAnchor)` that
  normalizes to `items`-ordered ids and bails out (no setState, no callback)
  when the content is identical to the current selection.
- Unknown ids are no-ops everywhere: `select`, `toggle`, `handleItemClick`
  ignore an id that is not in `items`, and `selectRange` ignores a pair whose
  endpoints are not both present.
- Imperative `select` / `deselect` / `toggle` move the anchor to that id;
  `selectRange(from, to)` unions the range in and moves the anchor to `from`;
  `selectAll()` leaves the anchor alone; `clear()` drops it.

Rendering & styling
- The hook renders nothing — consumers own the markup. For a list, keep the
  ARIA ownership chain unbroken: `role="listbox"` (plus
  `aria-multiselectable`) directly containing `role="option"` rows with
  `aria-selected`, no role-less wrapper div in between, and no nested
  focusable widget inside an option.
- Use semantic tokens only: selected rows `bg-primary/10`, the check mark
  `bg-primary text-primary-foreground`, hover `hover:bg-muted/60`, focus
  `focus-visible:ring-2 focus-visible:ring-ring`. Wrap colour transitions in
  `motion-reduce:transition-none` so reduced-motion users still see state.
- Add `select-none` to the rows: Shift+click otherwise paints a native text
  selection across the list on the way down.
- A header select-all checkbox is tri-state: `checked={allSelected}` plus
  `indeterminate` written to the DOM node (it is not a React prop) from a
  callback ref or an effect — `ref={el => { if (el) el.indeterminate =
  someSelected }}`.
- The hook owns selection, not focus. For arrow-key traversal pair it with a
  roving-tabindex list; `handleItemClick` accepts the keyboard event so
  Space / Shift+Space reuse the same selection logic.

Customization levers
- Additive-vs-replace Shift: this recipe replaces the selection with the range
  (Finder-style, so the range can shrink) and reserves Shift+⌘/Ctrl for the
  additive union (Gmail-style). Flip the default if your surface is an inbox
  where every Shift click should only ever add.
- Anchor persistence — keep a snapshot of the selection taken when the anchor
  last moved and union it into every Shift range if you want Explorer's
  "earlier ⌘-picked rows survive a later Shift range" behaviour.
- Modifier map — take the additive/range modifiers as options
  (`additiveKey`, `rangeKey`) when embedding in an app with its own shortcut
  language, or force `metaKey || ctrlKey` if you never render on macOS.
- Selection cap — clamp inside `commit` (`if (next.size > max) return`) for
  "select up to 5" pickers, or expose `maxSelected` and surface the rejection.
- Pagination — the hook treats `items` as the complete selectable universe, so
  server-paginated pages must lift the selection into a controlled parent
  (`selected` + `onSelectionChange`) that spans pages; otherwise pruning drops
  every id that is not on the current page.
- Persistence — feed `defaultSelected` from `useLocalStorage` / a URL search
  param and push `onSelectionChange` back into it to make a selection
  survivable across reloads.

Concepts

  • Selection anchor — the row a range is measured from. Plain and ⌘/Ctrl clicks move it; a Shift click deliberately does not, which is what lets you hold Shift and re-slice the same range wider or narrower instead of walking the range end one row at a time.
  • Modifier-aware click — one entry point resolves「replace / toggle / range / additive range」from shiftKey + the platform's additive key (⌘ on Apple, Ctrl elsewhere; accepting both would make every macOS right-click toggle a row). Because the handler only reads three boolean fields, keyboard events satisfy it too.
  • Derived indeterminateallSelected and someSelected (a non-empty strict subset) are computed from the current items, not stored, so a header checkbox can never drift out of sync with the rows; indeterminate is a DOM property, written from a callback ref rather than passed as a prop.
  • Prune on items change — the id sequence is diffed during render and vanished ids leave the selection in the same commit as the new list, so counts never include rows that are no longer on screen and a removed-then-restored row does not come back pre-selected. Doing this in an effect instead would trip react-hooks/set-state-in-effect and flash a stale count for a frame.
  • Controlled / uncontrolled parity — with selected provided the hook stops writing its own store and only reports through onSelectionChange; without it the store is internal. Both paths return identically shaped, items-ordered values, so a component can be lifted to controlled later without touching its render code.
  • Reference-stable snapshotselected / selectedIds keep their previous identity whenever the content compares equal, and the returned callbacks are created once (latest state is read through a ref), so putting them in dependency arrays or React.memo props does not cause re-render loops.

On This Page