Capítulo 29 de 54

Chapter 29: Cell Spanning (React) Guide

Core Idea

cellSpanningFeature merges adjacent body cells (rowspan/colspan style) by recomputing spans from whatever rows are actually rendered — it's fully stateless, so sorting/filtering/pagination/pinning just change adjacency and spans follow automatically, with nothing to persist or reset.

Key Concepts

  • Row spanning (vertical merge) is opt-in per column via spanRows on the column def: spanRows: true merges adjacent rows with an equal value (compared with Object.is; nullish values never merge, since a merged blank block reads as a bug). Pass a predicate instead — spanRows: ({ anchorValue, value }) => ... — for custom run logic; every candidate is compared against the run's anchor (first) row, keeping runs transitive by construction.
  • Column spanning (horizontal merge, e.g. a full-width summary row) uses spanColumns on the column that should carry the merged content: spanColumns: ({ row }) => row.original.isSummary ? Infinity : 1. The count is measured in actual render order (hidden columns don't count, reordering is handled), values beyond available room clamp to the end of that cell's pinned region, and a span can never cross a start/center/end-pinned boundary.
  • Rendering convention: a covered cell reports span 0 on the covered axis — check cell.getRowSpan()/cell.getColSpan() (or the shortcut cell.getIsCovered()) and skip rendering it entirely. Never render rowSpan={0} — in HTML that means "span to the end of the row group," merging the cell down the whole <tbody>, not what you want.
  • When a cell spans both axes, only the anchor cell reports both spans; every other covered cell in the rectangle reports 0 on at least one axis. Cells only join a vertical run when their column spans also match — a full-width summary row never accidentally merges into the data rows above it.
  • Interaction with row model changes: sorting by the spanned column clusters equal values into the largest runs (sorting by something else usually shatters them); filtering that removes the middle of a run makes the remaining neighbors adjacent and merges them; pagination clips runs at page boundaries (a run never crosses a page); pinned rows render in separate sections, so a run never crosses a pinning boundary either.
  • Disabling: enableCellSpanning: false on the table (global kill switch) or per-column-def.
  • Composes with cell selection (cellSelectionFeature): a selection rectangle auto-expands to fully enclose any merged cell it touches — always entirely selected or entirely unselected, including for exclusions. Arrow-key navigation treats a merge as one stop; getSelectedCellCount() counts a merge once; getSelectedCellRangesData() still returns the full underlying grid since covered cells carry real values even though they don't render.
  • Known limitations: with row virtualization, if a run's anchor row scrolls out of the rendered window, covered rows render nothing — read table.getCellSpanIndex() to find the anchor and render a clamped span at the window's top. Grouped columns ignore spanRows (grouping already collapses repeats into group rows). Footer/<tfoot> rendering is unaffected.

Code Examples

const features = tableFeatures({ cellSpanningFeature })

columnHelper.accessor('region', { spanRows: true })                              // vertical merge
columnHelper.accessor('label', { spanColumns: ({ row }) => row.original.isSummary ? Infinity : 1 }) // horizontal merge

{row.getVisibleCells().map((cell) => {
  const rowSpan = cell.getRowSpan(), colSpan = cell.getColSpan()
  if (rowSpan === 0 || colSpan === 0) return null   // covered — skip, never rowSpan={0}
  return <td key={cell.id} rowSpan={rowSpan} colSpan={colSpan}><table.FlexRender cell={cell} /></td>
})}
  • What it demonstrates: the mandatory "skip covered cells" render pattern — the single most common bug source with this feature is rendering rowSpan={0} instead of skipping the cell.

Key Takeaways

  1. Never render a covered cell with rowSpan={0} — always skip it (if (rowSpan === 0 || colSpan === 0) return null).
  2. This feature is stateless by design — don't try to persist span state; it's always derived fresh from the current row model.
  3. Under virtualization, use table.getCellSpanIndex() to handle a run whose anchor scrolled out of view.

Connects To

  • Cell Selection (React) Guide: how merged cells interact with selection rectangles.
  • Headers Guide: header.rowSpan — the header-row equivalent convention this feature mirrors for body cells.
  • Grouping (React) Guide: why grouped columns don't participate in row spanning.