TanStack logo

Skill

cell-selection

select rectangular cell ranges in tables

Covers Data Analysis UI Components TanStack

Description

Select rectangular cell ranges with cellSelectionFeature: two-corner range state keyed by row and column id, mousedown/mouseenter handlers, selection edges, render-order resolution under pinning, and autoResetCellSelection. Load when ranges widen unexpectedly after sorting or column reordering, when a drag re-renders the whole table, or when building copy-to-clipboard from a selection.

SKILL.md

This skill builds on core and table-features. cellSelection is an array of rectangles, each stored as two corner cells identified by row and column id. It is not a per-cell map, and it is not positional.

Setup

import { cellSelectionFeature, tableFeatures } from '@tanstack/table-core'

type Person = { id: string; name: string }
export const features = tableFeatures({ cellSelectionFeature })
export const options = {
  getRowId: (row: Person) => row.id,
}

State shape:

type CellSelectionRange = {
  anchorRowId: string
  anchorColumnId: string
  focusRowId: string
  focusColumnId: string
}
type CellSelectionState = Array<CellSelectionRange>

The anchor corner stays put; the focus corner moves during a drag or Shift-extend. Two corners are what make Shift-extend possible, and they keep a drag across thousands of cells to a two-field write.

Core Patterns

Bind both mouse handlers

const onMouseDown = cell.getSelectionStartHandler()
const onMouseEnter = cell.getSelectionExtendHandler()

The start handler attaches its own document-level mouseup listener and removes it when the drag ends, so a pointer released outside the table still finishes correctly. Pass a document explicitly (cell.getSelectionStartHandler(iframeDocument)) only when the table renders into another document.

Read the selection

const count = table.getSelectedCellCount()
const grids = table.getSelectedCellRangesData() // [range][row][column]

Expansion APIs are memoized and pull-based, so a table that only highlights cells never pays to enumerate a large selection.

Draw the outline from edges

cell.getSelectionEdges() marks a side true when the neighbouring cell in that direction is not selected, which yields one continuous outline around a union of rectangles. All sides are false when the cell is not selected.

Drive keyboard navigation externally

The feature ships no keydown handling. Call table.moveCellSelection(direction), table.extendCellSelection(direction), table.setFocusedCell(rowId, columnId), table.selectAllCells(), and table.resetCellSelection(true) from a hotkey library such as @tanstack/react-hotkeys, scoped to the grid element rather than the document.

Common Mistakes

HIGH Expecting a per-cell selection map

Wrong: const isSelected = table.state.cellSelection[cell.id]

Correct: const isSelected = cell.getIsSelected()

cellSelection holds rectangles, not cell keys. Membership is resolved by comparing the cell's row and column index against the memoized bounds.

Source: packages/table-core/src/features/cell-selection/cellSelectionFeature.types.ts

HIGH Assuming a range is frozen to the cells it originally covered

Ranges are anchored to corner ids, so sorting, filtering, and column reordering keep the corners and recompute what sits between them. A range can therefore widen onto columns or rows the user never selected. Reset in userland when the product needs stricter behavior:

// after a column reorder or pin
table.resetCellSelection(true)

Hiding a column that a corner sits on makes the range inert rather than deleting it; it returns when the column is shown again.

Source: docs/framework/react/guide/cell-selection.md#how-ranges-survive-table-changes

HIGH Binding only mousedown and expecting drag

Wrong:

const props = { onMouseDown: cell.getSelectionStartHandler() }

Correct:

const props = {
  onMouseDown: cell.getSelectionStartHandler(),
  onMouseEnter: cell.getSelectionExtendHandler(),
}

Without the extend handler a drag selects only the origin cell. Do not add a mouseup binding; the start handler already owns one.

Source: examples/react/cell-selection

HIGH Deriving column position from column definition order

Wrong: const index = column.getIndex()

Correct: const isSelected = cell.getIsSelected()

Cells render start-pinned first, then center, then end. getVisibleLeafColumns() and column.getIndex() are not pinning-reordered, so indexing a selection against them makes a rectangle visually scattered as soon as a column is pinned. The feature resolves its own render-order index map; use the cell APIs rather than recomputing membership.

Source: packages/table-core/src/features/cell-selection/cellSelectionFeature.utils.ts

HIGH Re-rendering every cell on each drag update

Wrong: one subscription wrapping the whole <tbody>.

Correct: one subscription per row, with a selector returning only what changes that row's appearance.

A drag writes state on every cell boundary crossed. A table-wide subscription reconciles every cell each time. Subscribe per row against table.atoms.cellSelection and derive a key from table.getCellSelectionBounds() (memoized, so it computes once per change) covering the row itself plus the rows above and below, which decide its top and bottom edges.

Source: docs/framework/react/guide/cell-selection.md#performance-with-tablesubscribe

MEDIUM Drawing selection borders on a border-collapse table

Wrong: .cell-selected { border: 2px solid blue }

Correct: .cell-selected { box-shadow: inset 0 0 0 2px blue }

On a border-collapse table a thicker border widens the shared grid line, so rows change height as cells become selected. Box-shadow never affects layout.

Source: examples/react/cell-selection/src/index.css

MEDIUM Expecting a clipboard string from the table

Wrong: navigator.clipboard.writeText(table.getSelectedCellsAsTsv())

Correct: navigator.clipboard.writeText(toTsv(table.getSelectedCellRangesData()))

The table returns raw values only. The delimiter, the representation of null, and quoting rules are application decisions, so serialization is userland. Quote any field containing a tab, newline, or quote, or a pasted spreadsheet gains phantom columns.

Source: docs/framework/react/guide/cell-selection.md#copying-a-selection

MEDIUM Persisting a selection and expecting drag state with it

cellSelection is safe to persist because drag session state is deliberately non-reactive instance data, not part of the slice. Do not add an isSelecting field to the persisted state; a stored true would rehydrate into a drag that hovering extends and nothing ever ends.

Source: packages/table-core/src/features/cell-selection/cellSelectionFeature.types.ts

MEDIUM Fighting the automatic reset on data change

Selection resets to initialState.cellSelection whenever data changes, because new data can invalidate the row ids a range points at or silently re-select cells when ids are reused. Opt out deliberately:

export const keepAcrossDataChanges = { autoResetCellSelection: false }

autoResetAll overrides this option.

Source: packages/table-core/src/features/cell-selection/cellSelectionFeature.ts

API Discovery

Inspect node_modules/@tanstack/table-core/dist/features/cell-selection/ for CellSelectionRange, CellSelectionState, CellSelectionBounds, CellSelectionEdges, the enablement and is*Event options, and the cell and table instance APIs.

© 2026 YourAI.tools. Every skill from an identity-verified publisher.

Independent catalog. Not affiliated with, endorsed by, or sponsored by Anthropic or any listed publisher. All trademarks belong to their respective owners.