Capítulo 28 de 54

Chapter 28: Cell Selection (React) Guide

Core Idea

cellSelectionFeature tracks spreadsheet-style rectangular cell ranges as a compact ordered log of include/exclude operations (not a per-cell map), letting click, Shift-extend, drag, and Ctrl/Cmd multi-range selection all compose cheaply even over large tables.

Key Concepts

  • State shape is an operation log, not a selection set: CellSelectionState is Array<{ anchorRowId, anchorColumnId, focusRowId, focusColumnId, operation?: 'include' | 'exclude' }>. anchor is where a range started (fixed), focus is the corner that moves during drag/Shift-extend. Applied in order; an omitted operation means include. This is what makes "select all except these three cells" cheap instead of enumerating every included cell.
  • Reading state: table.state.cellSelection (reactive), table.getSelectedCellCount(), table.getSelectedCellIds(), getCellSelectionRowIds()/getCellSelectionColumnIds(), table.getSelectedCellRangesData() (each disjoint positive region as a row-major value grid). The expansion APIs are memoized and pull-based — a table that only highlights selected cells (via cell.getIsSelected()) never pays to actually enumerate a large selection.
  • Controlling it externally: same two options as any state slice — an external atom via atoms: { cellSelection: atom } (recommended in v9) or classic state.cellSelection + onCellSelectionChange. Note: a drag fires one change per cell boundary crossed, so debounce/commit-on-mouseup before syncing to a server or URL.
  • Requires a meaningful getRowId, same reasoning as row selection — selection is keyed by row id + column id.
  • Enabling/disabling: enableCellSelection on the table (boolean or per-cell function) or per-column-def enableCellSelection: false (column-level false always wins over the table option). A non-selectable cell is skipped even by a rectangle drawn straight through it; check cell.getCanSelect() before attaching handlers.
  • Mouse wiring is exactly two handlers: cell.getSelectionStartHandler() on onMouseDown, cell.getSelectionExtendHandler() on onMouseEnter — no manual mouseup handling needed (the start handler self-attaches/detaches a document-level listener, even across iframe/popout boundaries via an optional target-document argument).
  • Interaction modifiers: plain drag = single-range move (disable via enableCellSelectionDrag: false); Shift-click = extend the active range's focus, anchor fixed (enableCellRangeSelection: false to disable, isCellRangeSelectionEvent to swap the modifier); Ctrl/Cmd-click on an unselected cell = new inclusive range, on a selected cell = new exclusion (enableMultiCellRangeSelection: false to disable, isMultiCellRangeSelectionEvent to swap).
  • Programmatic control: table.selectCellRange(range, { mode: 'include' | 'exclude' }) replaces current selection (the older { additive: true } is a deprecated include-mode alias); table.getCellSelectionBounds() resolves the log into deterministic disjoint rectangles.
  • Rendering: cell.getIsSelected(), cell.getIsFocused() (the active cell — can be true even for an excluded anchor), cell.getSelectionEdges(){ top, right, bottom, left } (each true when that neighbor is not selected, which is exactly what's needed to draw one continuous outline around a union of rectangles without per-cell neighbor inspection), cell.getTabIndex() (roving tabindex: 0 for focused, -1 otherwise). Draw the outline with box-shadow: inset, not border — a border on a border-collapse table changes row height as selection changes; a box-shadow never affects layout.
  • No built-in keyboard handling — the feature exposes imperative APIs (table.moveCellSelection(direction), extendCellSelection(direction), setFocusedCell(rowId, colId), selectAllCells(), resetCellSelection(true), direction ∈ 'up'|'down'|'left'|'right') for a dedicated library like TanStack Hotkeys to drive.
  • With cellSpanningFeature also registered, selection rectangles auto-expand to fully enclose any merged cell they touch — a merge is always entirely selected or entirely unselected, never partial.

Code Examples

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

<td onMouseDown={cell.getSelectionStartHandler()} onMouseEnter={cell.getSelectionExtendHandler()}>
  <table.FlexRender cell={cell} />
</td>
  • What it demonstrates: the entire mouse-interaction surface for cell selection is two handlers on one <td>.

Key Takeaways

  1. Selection state is an operation log (anchor/focus + include/exclude), not a per-cell set — reason about it as "ranges applied in order," not "which cells are true."
  2. Two handlers (getSelectionStartHandler/getSelectionExtendHandler) cover mouse drag/Shift/Ctrl entirely; keyboard needs a separate library wired to the imperative move/extend APIs.
  3. Style selection boundaries with box-shadow: inset, never border, to avoid layout shift.

Connects To

  • Cell Spanning (React) Guide: how merged cells interact with selection rectangles.
  • Row Selection (React) Guide: the row-level equivalent, same getRowId dependency.
  • Rows Guide: why getRowId matters for any id-keyed feature.