useSelection
A list multi-selection state machine — Shift range, ⌘/Ctrl toggle, tri-state select-all, and automatic pruning when items change.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-selection.jsonPrompt
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 indeterminate —
allSelectedandsomeSelected(a non-empty strict subset) are computed from the currentitems, not stored, so a header checkbox can never drift out of sync with the rows;indeterminateis 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-effectand flash a stale count for a frame. - Controlled / uncontrolled parity — with
selectedprovided the hook stops writing its own store and only reports throughonSelectionChange; 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 snapshot —
selected/selectedIdskeep 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 orReact.memoprops does not cause re-render loops.
useControllableState
A useState-shaped hook that lets one component serve both controlled (value/onChange) and uncontrolled (defaultValue) callers, with a stable setter and prop-accurate updaters.
usePagination
Headless pagination state — clamped page, page count, item range, a client-side slice() and the ellipsis page sequence, controlled or uncontrolled.