Skip to content

Avoid reattaching VirtualizedList cell refs on every render - #59841

Closed
dheerajkoppu wants to merge 2 commits into
react:mainfrom
dheerajkoppu:virtualized-list-stable-cell-ref
Closed

dheerajkoppu wants to merge 2 commits into
react:mainfrom
dheerajkoppu:virtualized-list-stable-cell-ref

Conversation

@dheerajkoppu

@dheerajkoppu dheerajkoppu commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

Summary:

VirtualizedList._pushCells gives each CellRenderer an inline ref callback:

ref={ref => {
  this._cellRefs[key] = ref;
}}

It's a new function on every render, so on every commit React calls the old callback with null and the new one with the instance, for every mounted cell. This happens even when CellRenderer (a PureComponent) bails out, and the list re-renders often, for example each time the render window moves during a scroll.

This replaces it with one _captureCellRef class field that finds the slot from the instance's cellKey. cellKey and the React key are the same value, so an instance's cellKey never changes. The null call is ignored: a stable ref is only detached when the cell unmounts or Suspense hides it, and in both cases componentWillUnmount already deletes the entry through _onCellUnmount. When Suspense shows the cell again, React calls the ref with the instance and the entry comes back.

No behavior change.

Changelog:

[INTERNAL] [CHANGED] - Avoid reattaching VirtualizedList cell refs on every render

Test Plan:

  • Added does not reattach cell refs when the list re-renders. It fails on main, where one re-render of a 5-cell list writes to _cellRefs 10 times.
  • Added removes cell refs when cells unmount.
  • The existing renders all cells after being hidden and revealed by Suspense with a pending batch update test covers a cell being hidden and shown again.
  • yarn test packages/virtualized-lists packages/react-native/Libraries/Lists: 11 suites, 206 passed, 1 skipped.
  • yarn flow-check and yarn lint: no errors in source files.

Performance:

Measured in Jest on an M4 Pro (Node 26, React development build). The list re-renders with only extraData changed, so every cell bails out and the time is the list's own render and commit. Each number is the median of 3 runs, and each run is the median of 7 samples of 200 re-renders.

Mounted cells Ref calls per re-render (main → PR) Time per re-render (main → PR)
50 100 → 0 0.464 → 0.378 ms (−19%)
300 600 → 0 2.459 → 1.960 ms (−20%)

Run-to-run ranges don't overlap (main 0.438–0.468 and 2.337–2.507 ms, PR 0.377–0.380 and 1.953–1.969 ms). This is React's development build, so the gain in a release build on a device is likely smaller. I haven't measured that.

Benchmark
import VirtualizedList from "../VirtualizedList";
import * as React from "react";
import { act, create } from "react-test-renderer";

jest.useFakeTimers({ doNotFake: ["hrtime", "performance"] });

const RERENDERS_PER_SAMPLE = 200;
const SAMPLES = 7;

for (const cellCount of [50, 300]) {
  it(`${cellCount} mounted cells`, async () => {
    const items = Array.from({ length: cellCount }, (_, index) => ({
      key: `item-${index}`,
    }));
    const listProps = {
      data: items,
      renderItem: ({ item }) => <item value={item.key} />,
      getItem: (data, index) => data[index],
      getItemCount: (data) => data.length,
      getItemLayout: (_, index) => ({ length: 10, offset: 10 * index, index }),
      initialNumToRender: cellCount,
    };
    const listRef = React.createRef();
    let component;
    let extraData = 0;
    const rerender = () =>
      act(() => {
        component.update(
          <VirtualizedList
            ref={listRef}
            {...listProps}
            extraData={++extraData}
          />,
        );
      });
    await act(() => {
      component = create(<VirtualizedList ref={listRef} {...listProps} />);
    });

    let refWrites = 0;
    listRef.current._cellRefs = new Proxy(listRef.current._cellRefs, {
      set(target, cellKey, cellRenderer) {
        refWrites++;
        target[cellKey] = cellRenderer;
        return true;
      },
    });

    for (let iteration = 0; iteration < 50; iteration++) {
      await rerender();
    }
    refWrites = 0;

    const samples = [];
    for (let sample = 0; sample < SAMPLES; sample++) {
      const start = process.hrtime.bigint();
      for (let iteration = 0; iteration < RERENDERS_PER_SAMPLE; iteration++) {
        await rerender();
      }
      samples.push(
        Number(process.hrtime.bigint() - start) / 1e6 / RERENDERS_PER_SAMPLE,
      );
    }
    samples.sort((first, second) => first - second);

    console.log(
      `cells=${cellCount} ` +
        `refCallsPerRender=${refWrites / (SAMPLES * RERENDERS_PER_SAMPLE)} ` +
        `medianMs=${samples[Math.floor(SAMPLES / 2)].toFixed(3)}`,
    );
  });
}

VirtualizedList gave every cell a new ref callback on each render, so
React detached and reattached every mounted cell's ref even when the
cells bailed out of rendering. Use one stable callback that reads the
key from the cell's cellKey prop, and rely on _onCellUnmount to remove
entries for unmounted cells.
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Nov 7, 2026
@dheerajkoppu
dheerajkoppu deleted the virtualized-list-stable-cell-ref branch November 7, 2026 01:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant