Skip to content

fix: avoid re-rendering unchanged Table rows and cells - #10816

Open
pete-woods wants to merge 1 commit into
adobe:mainfrom
pete-woods:table-tuning
Open

pete-woods wants to merge 1 commit into
adobe:mainfrom
pete-woods:table-tuning

Conversation

@pete-woods

@pete-woods pete-woods commented Oct 20, 2026 •

Copy link
Copy Markdown

Closes #10477, closes #10759

Intent

When one row of a large RAC Table changes, for example a tree table gaining child rows or a row being expanded, the update should cost roughly that row's subtree, not the whole table. Today every Row and Cell re-runs useTableRow/useTableCell, focus rings, hover, selection checkbox and so on for every update. On a tree table of about 300 rows that blocks the main thread for 150–500ms in a dev build, even when only one row changed.

The same cause is behind two open issues, which this fixes. These counts come from a 20-row table in a ResizableTableContainer, measured in Jest on main and on this branch:

Issue Rows / cells rendered on main With this PR
#10477: clicking a cell 40 / 81 1 / 2
#10477: arrow key to the next cell 23 / 45 4 / 8
#10759: parent re-renders with a new width 40 / 80 0 / 0

It also helps with #8617, but doesn't fully fix it: sorting and filtering replace most rows, so those rows still render.

Why it happens

useCachedChildren already returns the same element for unchanged collection nodes, but Row and Cell consume TableStateContext. Its value is a new object whenever the collection, selection or expanded keys change, so React can't bail out. There are usually two passes per update: the parent render, then the useSyncExternalStore render once the hidden collection tree commits. Inline <Column> elements get new props on every parent render, which rebuilds collection.columns, so even a parent re-render that changes nothing triggers a full second pass.

How this works

  • Row and Cell are created with small table-local wrappers (createTableBranchComponent/createTableLeafComponent). These render the existing body, unchanged, inside a memo'd component. The wrapper still reads TableStateContext and computes a render key from exactly what that row or cell displays:
    • Row: node identity, selected/disabled/can-select, focused (and isFocused/childFocusStrategy only when focused), expanded, last-child type, its cell nodes (updating a cell doesn't clone the row node), and the last sibling's index (for aria-setsize).
    • Cell: cell and row nodes, the row's selected/disabled/expanded state, and its own focus.
  • Values that affect every row go through a new internal TableRowRenderContext, which changes only when one of them does: selection mode and behaviour, isEmpty, empty collection, column keys (not the array identity), row header keys, header row count, treeColumn, and whether onRowAction/onCellAction are set.
  • The memoised bodies use useStableTableState (new, in react-aria/private/table). It returns a stable proxy that forwards to the latest table state, including the SelectionManager, keyboard delegate and grid actions, and it is registered with gridMap/gridIds. Without it, a row that skipped rendering would keep stale closures and act on an outdated selection, collection or onRowAction callback.
  • DragAndDropContext and RowFocusContext values are now memoised.

I picked this over a selector-style subscription per row because it doesn't change the hooks or the collection internals. Everything stays inside RAC Table, and TableStateContext behaves the same for anything else that consumes it.

Benchmark

packages/react-aria-components/test/Treeble.perf.browser.test.tsx: 50 parents × 5 children, 3 columns, selectionMode="multiple", dev build, Chromium, timed with flushSync. Medians over 3 runs:

Scenario Before After Rows rendered (before → after)
Add 1 child row ~495ms ~33ms 613 → 13
Parent re-renders, nothing changed ~152ms ~14ms 614 → 0
Add 60 child rows ~255ms ~85–110ms 1154 → 306
Expand one row ~148ms ~15ms 600 → 6
Collapse one row ~145ms ~13ms 590 → 1

Adding 60 rows still re-renders every row in that group, because each one's aria-setsize changes.

Behaviour changes and risks

  • Render props: RowRenderProps.state is now the stable proxy. A className or style function that reads table-wide state not covered by the render keys won't update until its own row re-renders. Components that read TableStateContext directly still update as before.
  • Maintenance: the render keys mirror what useTableRow, useGridRow, useSelectableItem and useGridCell read during render. If those hooks start depending on new state, the keys need updating too. TableRenderKeys.test.js (below) catches most omissions.
  • Concurrent rendering: the proxy's ref is updated during render and restored in a layout effect. While a transition render is in progress and not yet committed, an event on a skipped row could briefly see the uncommitted state.
  • Drag and drop: dragState and dropState are new objects on every render, so tables with drag and drop still re-render all rows. No regression, but no gain either.
  • Not covered: GridList, Tree and ListBox follow the same pattern and aren't changed here.

✅ Pull Request Checklist:

  • Included link to corresponding React Spectrum GitHub Issue.
  • Added/updated unit tests and storybook for this change (for new code or code which already has tests).
  • Filled out test instructions.
  • Updated documentation (if it already exists for this component).
  • Looked at the Accessibility Practices for this feature - Aria Practices
  • I understand every change in this PR and can explain why it's there.
  • If AI-assisted, I followed our AI contribution guidance and pointed my assistant at AGENTS.md.

AI disclosure: the investigation, implementation, tests and benchmark were done with Claude Code (Claude Opus 5.5).

Adds a "Tree Table render counts" story. No docs changes. Rendered output is unchanged and the only API change is private (react-aria/private/table/useStableTableState). ARIA output is unchanged and covered by the existing Treeble/Table tests.

📝 Test Instructions:

  • Automated:
    • New tests in Treeble.test.js under "Treeble row memoization":

      • unchanged rows don't re-render;
      • a row that skipped rendering uses the latest selection, both toggling and a shift-range covering rows added later;
      • a skipped row calls the latest onRowAction closure;
      • a cell updates when only that cell changes.

      Each of these fails if the memoised rows are given non-stable state.

    • TableRenderKeys.test.js runs about 60 interactions against Table and against a copy loaded with memo disabled (every row and cell always re-renders), with and without drag and drop. After each step it checks that the two DOMs match, including render props and the focused element. Interactions include selection, focus, expansion, row and column changes, disabled rows, actions and keyboard navigation modes. Removing render-key entries one at a time, and in groups that duplicate each other, makes it fail. The exceptions are node-identity entries: they are currently redundant because Document.setProps gives every sibling of a changed row new props. Also uncaught is keyboardNavigationBehavior, which can't be reached from jsdom.

    • The last Treeble drag-and-drop test now presses Escape at the end. It left a keyboard drag running, which hid the document from any test that ran after it.

  • Ran: yarn test (8140 passed, 16 skipped), yarn test:ssr (74 passed), yarn test-loose on RAC/S2/RSP table (1976 passed), and Chromium browser tests for RAC and S2 (147 passed). yarn lint is clean. The Firefox and WebKit browser projects were not run.
  • Benchmark: npx vitest run --config=vitest.browser.config.ts --project chromium-desktop packages/react-aria-components/test/Treeble.perf.browser.test.tsx.
  • Manual: "React Aria Components/Table/Tree Table render counts" flashes each row and cell as it renders and reports how many rendered. Compare with main, and toggle withDragAndDrop to see the drag-and-drop gap. Also try the Table tree stories in Storybook. Expand and collapse rows with mouse and keyboard (ArrowLeft/ArrowRight, including RTL), select with click and shift-click, and check focus restoration after expanding.
  • Storybook There is also a new storybook that demonstrates the improvement at http://localhost:9003/?path=/story/react-aria-components-table--tree-table-render-counts&providerSwitcher-express=false&strict=true -- it highlights in yellow when cells re-render.

🧢 Your Project:

CircleCI's web based UI.

@pete-woods
pete-woods force-pushed the table-tuning branch 3 times, most recently from 56133fd to ebcd3b2 Compare October 21, 2026 10:57
@pete-woods
pete-woods marked this pull request as ready for review October 21, 2026 11:08
Row and Cell read TableStateContext, whose value changes whenever the
collection, selection or expanded keys change, so every row and cell
re-ran its hooks on any table update, even when the rendered elements
were cached.

Row and Cell now render their existing bodies inside a memoised
component that only re-renders when a render key, computed from the
parts of the table state the row or cell displays, changes. The bodies
read a stable proxy of the table state (useStableTableState) so event
handlers in rows that skipped rendering still act on the latest
collection, selection and callbacks. Table-wide values that affect every
row are provided through a context that changes only when one of them
does.

Adds a test that runs the same interactions against Table and against a
copy with memoisation disabled and checks the DOM always matches, and a
"Tree Table render counts" story that flashes rows and cells as they
render.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nwidynski

nwidynski commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

In your own words, how is this different from #10768?

@pete-woods

Copy link
Copy Markdown
Author

In your own words, how is this different from #10768?

To be completely honest I didn't spot that PR. I set "the machine" on this performance tuning task before I realised others had the same issue.

I'll have a look through to see how the implementations compare.

@pete-woods

pete-woods commented Oct 21, 2026 •

Copy link
Copy Markdown
Author

So the fix here is more general. But both changes I think make sense combined:

  • The other PR handles table resizing only - avoiding rebuilding when nothing changed at all. It doesn't help when e.g. the parent re-renders, or other state changes are made.
  • This PR additionally tries to avoid re-rendering as much as possible when the table state changes, e.g. trees expanded, by memoising rows and cells.

With the two PRs combined I think we'd see even fewer re-renders.

@pete-woods

pete-woods commented Oct 21, 2026 •

Copy link
Copy Markdown
Author

Getting opus to compare the performance of each. I'm also gonna look how the numbers compare if we combined both.

Edit: Below is Opus's analysis, which seems to confirm what I expected (they combine well).

Table render cost: main vs #10768 vs #10816 vs both combined

"Combined" is #10816 (e9ad1b54f) with #10768's branch merged in. The two PRs conflict in one place: both memoise the DragAndDropContext value in TableInner. I resolved it by keeping one copy, plus #10768's memoised DropIndicatorContext. On the combined code, formatting, lint and type-checks pass, and 62 suites pass: 1,771 tests passed, 6 skipped. Those suites are RAC, react-stately table, S2 TableView and v3 table, and they include both PRs' new tests.

Render counts (Jest)

Rows and cells rendered for each update, shown as rows / cells.

Scenario main #10768 #10816 Combined
Container resized (its own width state, #10759) 20 / 40 0 / 0 0 / 0 0 / 0
Parent re-renders with a new width 40 / 80 40 / 80 0 / 0 0 / 0
Parent re-renders, nothing changed 40 / 80 40 / 80 0 / 0 0 / 0
Click a cell (#10477) 42 / 84 42 / 84 23 / 41 23 / 41
Arrow key to next row (#10477) 23 / 45 23 / 45 4 / 8 4 / 8
Change one row's data 40 / 80 40 / 80 1 / 2 1 / 2
Tree: expand one row 100 / 100 100 / 100 5 / 5 5 / 5
Tree: add a child row 101 / 101 101 / 101 6 / 6 6 / 6

Setup:

  • Flat table: 20 rows, 2 columns, selectionMode="multiple", inside a ResizableTableContainer.
  • Tree table: 10 parents with 4 children each, 9 of the parents expanded.
  • Counting: each Row and Cell counts a render whenever its className render prop runs.
  • Environment: Jest with jsdom and the repo's test setup. That setup uses StrictMode, so some counts include double renders.
  • Container resize: clientWidth is mocked and a window resize event is fired, the same way Fix/table state identity #10768's tests do it.
  • "Click a cell" on fix: avoid re-rendering unchanged Table rows and cells #10816: the first selection flips isEmpty, which re-renders every row. A later click renders about 2 rows.

Source: /tmp/CompareRenders.test.js.

Tree table timings (Chromium)

From Treeble.perf.browser.test.tsx: 50 parents with 5 children each, 3 columns, selectionMode="multiple", dev build, timed with flushSync. Each cell gives the median over 5–7 rounds in two separate runs, as "run 1 / run 2".

Scenario main #10768 #10816 Combined Rows rendered (main → #10816)
Add 1 child row 473 / 501 ms 465 / 488 ms 32 / 30 ms 29 / 29 ms 613 → 13
Parent re-renders, nothing changed 146 / 159 ms 126 / 145 ms 30 / 12 ms 12 / 13 ms 614 → 0
Add 60 child rows 294 / 247 ms 262 / 243 ms 82 / 95 ms 98 / 100 ms 1154 → 306
Expand one row 144 / 142 ms 138 / 149 ms 15 / 13 ms 13 / 12 ms 600 → 6
Collapse one row 226 / 143 ms 138 / 141 ms 13 / 12 ms 11 / 12 ms 590 → 1

#10768 renders the same rows as main in this benchmark, and combined renders the same rows as #10816.

Container resize timings (Chromium)

A ResizableTableContainer with 300 rows and 4 columns. Each round changes the container's width through the DOM, so the real ResizeObserver fires. The figures are total React render time per resize from <Profiler> actualDuration, as the median of 9 resizes in each of two runs.

Version Render time per resize Cells rendered per resize
main 47.9 / 49.5 ms 1,200
#10768 0.7 / 1.1 ms 0
#10816 4.5 / 4.9 ms 0
Combined 0.9 / 0.7 ms 0

#10816 renders no cells here, but it still runs each row and cell's wrapper to compare render keys, which costs about 4 ms for 300 rows. #10768 avoids that because TableStateContext keeps its identity.

Source: /tmp/ResizeBench.browser.test.tsx.

Takeaways

@snowystinger

Copy link
Copy Markdown
Member

Were you running your own perf? It'd be good if we could run those combinations with our new PR @reidbarber has been working on to do benchmarking #10787

@pete-woods

Copy link
Copy Markdown
Author

Were you running your own perf? It'd be good if we could run those combinations with our new PR @reidbarber has been working on to do benchmarking #10787

Yes, I got Opus to make a performance harness for this. Let me look into using the one from your other PR.

@pete-woods

pete-woods commented Oct 22, 2026 •

Copy link
Copy Markdown
Author

Okay, I got it to performance profile the various combinations with the harness you mentioned. I had to get opus to add some more scenarios (which I can add in a PR if we move forward with this). It seems to back up my above findings.


Table performance with the #10787 harness: summary

I compared main, #10768, #10816 and the two combined, using the benchmark harness from #10787.

  • Builds: production builds in headless Chromium, with the harness defaults of 8 browser launches, 5 trials and 2 warmups.
  • Scenarios: existing chore: Performance benchmark harness #10787 scenarios, plus a new tree-table fixture (see below).

Scenarios added to the harness

The harness had no tree table. Its tree fixture uses the Tree component, not Table with treeColumn. Its flat-table fixtures don't cover an unrelated parent re-render or a change to one row's data either.

So I added a tree-table fixture. It has 50 workflows with 5 jobs each, a selection checkbox column and treeColumn="name". It isn't virtualised. Every workflow except the last starts expanded, so it has about 300 rows. Its eight actions:

Scenario Action
tree-table.mount Mount the table
tree-table.expand ArrowRight on the collapsed workflow
tree-table.collapse ArrowLeft on the first workflow
tree-table.focus ArrowDown from a workflow to its first job, with no selection
tree-table.select Click a job's checkbox
tree-table.parent Update state in the table's parent that the table doesn't use
tree-table.add Add a job to one workflow
tree-table.update Change one job's status

Each action waits for the expected end state before it counts as complete: the full row order, aria-expanded, focus, selection or text, as appropriate. Afterwards it checks every row's label and aria-level. The fixture passes yarn perf smoke, yarn test:perf and the type-check. It adds about 200 lines across four files: a new perf/app/tree-table.tsx, plus app.tsx, scenarios.mjs and drivers.mjs. I'm happy to contribute it to #10787.

All other rows below come from #10787's existing scenarios:

Row Scenario
Resize the container resize.container
Flat table: sort table-data.sort
Flat table: filter table-data.filter
Flat table: move cell focus table-data.focus
Flat table: close a cell menu table-cells.menu-close
Select a row (flat) table-cells.select

Results

Times are medians of the time from input to two frames after the expected DOM and focus state.

Scenario main #10768 #10816 Combined
Tree table: expand a row 361 ms 245 ms 21 ms 19 ms
Tree table: collapse a row 347 ms 241 ms 21 ms 17 ms
Tree table: move row focus 244 ms 121 ms 16 ms 15 ms
Tree table: unrelated parent update 228 ms 230 ms 15 ms 15 ms
Tree table: add a child row 234 ms 231 ms 21 ms 22 ms
Tree table: change one row's data 226 ms 234 ms 18 ms 18 ms
Resize the container (500 rows) 206 ms 31 ms 36 ms 32 ms
Flat table: sort 123 ms 120 ms 55 ms 55 ms
Flat table: filter 90 ms 53 ms 19 ms 18 ms
Flat table: move cell focus 81 ms 42 ms 15 ms 16 ms
Flat table: close a cell menu 130 ms 72 ms 24 ms 24 ms
Select a row (flat / tree) 66 / 116 ms 65 / 117 ms 71 / 128 ms 72 / 131 ms
Mount the tree table 158 ms 161 ms 171 ms 168 ms

Findings

  • fix: avoid re-rendering unchanged Table rows and cells #10816 makes every tree table interaction that changes state 92–96% faster. That covers expansion, focus, data changes and parent re-renders. It also makes flat-table sort, filter, focus and menu-close 57–82% faster, and resizing 83% faster.
  • Fix/table state identity #10768 is mainly a resize fix. It also makes focus, expand/collapse, filter and menu-close 30–50% faster, but doesn't help with parent re-renders or data changes.
  • Combined performs the same as fix: avoid re-rendering unchanged Table rows and cells #10816. Comparing the two directly, every scenario is within the harness's noise threshold. The biggest differences are on resizing (−6 ms) and expand/collapse (−3 to −4 ms).
  • Selection and mount are 5–8% (4–9 ms) slower with fix: avoid re-rendering unchanged Table rows and cells #10816, but the harness classes these as within threshold or inconclusive, not regressions. The first selection re-renders every row, and each row also pays the memo comparison. This needs confirming with yarn perf confirm.
  • No scenario showed a regression in any comparison.

let stateRef = useRef(state);
// Rows and cells render after this in the same pass and must see the new state, so the ref is
// updated during render. The layout effect restores the committed state if a render is discarded.
// oxlint-disable-next-line react/react-compiler, rsp-rules/pure-render

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this one we can't disable unfortunately, it's caused some nasty bugs before, no reading or writing refs in render

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

3 participants