Capítulo 44 de 54

Chapter 44: Row Selection (React) Guide

Core Idea

rowSelectionFeature tracks a { [rowId]: boolean } map with built-in Shift-range selection and sub-row cascade behavior — the two things most worth getting right up front are a meaningful getRowId (selection is keyed by id, not index) and deciding how selecting/deselecting a parent should affect its children.

Key Concepts

  • State: RowSelectionState = Record<string, boolean>. Reads: table.state.rowSelection (reactive), table.atoms.rowSelection.get() (snapshot, no subscription). Row-model-aware selected-rows views: table.getSelectedRowModel(), getFilteredSelectedRowModel(), getGroupedSelectedRowModel().
  • getRowId matters more here than almost anywhere else — the default row id is row.index, which is meaningless once rows re-sort/re-filter/re-fetch. Set getRowId: (row) => row.uuid (or similar) so selection survives those changes. Because selection state is just an id map, it can validly hold ids not present in the current data — useful under manualPagination, where getSelectedRowModel() only ever reflects the current page's rows even though selection itself can span pages.
  • Ownership: external atom via atoms: { rowSelection: atom } (v9-recommended, e.g. for reading selected ids elsewhere to drive bulk-action API calls) or classic state.rowSelection + onRowSelectionChange.
  • Scope controls: enableRowSelection (boolean or per-row predicate, e.g. adults-only); enableMultiRowSelection: false for radio-button-style single selection (or a predicate for conditional single-select); enableSubRowSelection (boolean or predicate) — false stops a parent-row toggle from cascading to children.
  • Sub-row cascade specifics: selecting a parent writes the parent id and every selectable descendant id into state. Deselecting a child afterward does not automatically remove the parent id (some tables intentionally treat selection ids as literal, independent of the tree) — pass { deselectParents: true } to row.toggleSelected(false, opts) / row.getToggleSelectedHandler(opts) to prune stale ancestor ids on deselect. When sub-row selection is blocked for a parent, both select-all APIs (toggleAllRowsSelected/toggleAllPageRowsSelected) skip its descendants, and both "is everything selected" checks (getIsAllRowsSelected/getIsAllPageRowsSelected) ignore them too.
  • Shift range selection is built in, no opt-in needed: after an ordinary click establishes an anchor row, Shift-clicking another row selects/deselects the inclusive interval between them (following the table's current logical display order — filtering/sorting/grouping/expansion all apply; with client-side pagination a range can cross pages since it uses the full pre-pagination order; with manual pagination only currently-loaded rows can participate). The clicked checkbox's resulting checked value applies to the whole range, and that endpoint becomes the new anchor.
    • enableRowRangeSelection: false disables it; isRowRangeSelectionEvent swaps the modifier check (defaults to event.shiftKey/event.nativeEvent.shiftKey).
    • selectChildren: false (option on the toggle handler) restricts a range toggle to rows literally in the display-order interval, instead of recursively cascading into a parent's descendants.
    • The anchor persists across sorting/filtering/grouping/expansion/pagination/data updates as long as its row id stays in the display order; if it's removed (filtered out, data replaced), the next Shift click falls back to an ordinary toggle and starts a fresh anchor. resetRowSelection(), either select-all API, and table.reset() clear the anchor explicitly; direct row.toggleSelected()/table.setRowSelection() calls (including externally-controlled state changes) do not move or establish it.
  • Rendering, two common patterns: (1) checkbox column — row.getToggleSelectedHandler() on each row checkbox, table.getToggleAllRowsSelectedHandler()/getToggleAllPageRowsSelectedHandler() on a header "select all" checkbox, row.getIsSomeSelected()/table.getIsSomeRowsSelected() driving an indeterminate visual state; (2) whole-row click — bind row.getToggleSelectedHandler() directly to the <tr>'s onClick instead of a dedicated checkbox column.

Code Examples

const features = tableFeatures({ rowSelectionFeature })
const table = useTable({ features, columns, data, getRowId: (row) => row.uuid })

// select-all header + per-row checkbox, sub-row aware
header: ({ table }) => <Checkbox checked={table.getIsAllRowsSelected()}
  indeterminate={table.getIsSomeRowsSelected()} onChange={table.getToggleAllRowsSelectedHandler()} />
cell: ({ row }) => <Checkbox
  checked={row.getIsSelected() || (row.getCanSelectSubRows() && row.getIsAllSubRowsSelected())}
  disabled={!row.getCanSelect()} indeterminate={row.getIsSomeSelected()}
  onChange={row.getToggleSelectedHandler()} />

// pruning stale parent ids on child deselect
row.getToggleSelectedHandler({ deselectParents: true })
  • What it demonstrates: the sub-row-aware checkbox column pattern (getCanSelectSubRows()/getIsAllSubRowsSelected() only matter with hierarchical data — flat tables just need row.getIsSelected()), and the deselectParents option for keeping selection state tidy.

Key Takeaways

  1. Set getRowId before anything else — selection keyed by array index breaks the moment rows re-sort or re-fetch.
  2. Shift-range selection needs no setup, but its anchor logic has real edge cases (removed rows, external state changes) worth testing if the UI depends on it.
  3. Decide deselectParents deliberately — the default (parent id sticks around after a child is deselected) surprises people who expect selection to always mirror what's visually checked.
  4. Under manualPagination, remember getSelectedRowModel() only reflects the current page — selection itself can (and often should) span pages via ids not present in data.

Connects To

  • Rows Guide: getRowId in full detail.
  • Row Pinning (React) Guide: a common pairing (pin selected rows to the top).
  • Expanding (React) Guide: sub-row selection's dependency on the row tree.