Skip to content

refactor(react-virtual): subscribe to virtualizer updates with useSyncExternalStore - #1259

Open
kklem0 wants to merge 1 commit into
TanStack:mainfrom
kklem0:refactor/use-sync-external-store
Open

refactor(react-virtual): subscribe to virtualizer updates with useSyncExternalStore#1259
kklem0 wants to merge 1 commit into
TanStack:mainfrom
kklem0:refactor/use-sync-external-store

Conversation

@kklem0

@kklem0 kklem0 commented Aug 24, 2026

Copy link
Copy Markdown

🎯 Changes

Drives useVirtualizer / useWindowVirtualizer re-renders through useSyncExternalStore instead of a useReducer bump. The public API, the returned instance and its identity semantics are unchanged — consumers keep calling getVirtualItems() / getTotalSize() on the instance.

This is the direction suggested in #851 ("useSyncExternalStore feels like a more natural fit compared to useMemo"), applied to the existing hooks rather than as a separate hook: no new hook names, no API change, and nothing that tries to work around React Compiler's knownIncompatible entry for useVirtualizer.

It is the minimal alternative to #1241 (which adds separate useVirtualizerSnapshot hooks). Both are open on purpose so you can pick the shape you prefer — they don't have to be exclusive, see the React Compiler note below.

Design

  • Store: a tiny external store whose snapshot is a version counter, bumped by every notification the adapter decides to render (directDomUpdates gating is untouched). The counter tells React that the instance moved; consumers still read render-facing values from the instance.
  • useFlushSync preserved: the synchronous scroll path notifies inside flushSync, so the DOM still updates before the scroll handler returns.
  • Before-paint catch-up: useSyncExternalStore subscribes in a passive effect, so notifications raised while React is committing — the initial rect/offset measurement in _willUpdate, scroll-element swaps, measureElement refs for freshly mounted items — have no listener yet and would only surface via the store's post-commit check, i.e. after paint (a visible flash of the unmeasured range on heavy initial renders). A final layout effect compares the store version with the rendered one and dispatches a reducer so those still re-render synchronously before paint, exactly as before. Mount / measure render counts in the existing tests are unchanged (2 and 3).
  • Shim: use-sync-external-store/shim (React's official package) keeps the ^16.8 || ^17 || ^18 || ^19 peer range; on React 18+ it is React.useSyncExternalStore.
  • SSR: getServerSnapshot provided; renderToString covered by a test.

Why

  • Under concurrent rendering (transitions, Suspense, useDeferredValue) a reducer bump scheduled while a render is in progress lets React finish and commit that render with the stale range it already read, then re-render. With useSyncExternalStore React checks the store at the end of a non-blocking render and re-renders synchronously if it moved, so a torn frame is never committed.
  • The virtualizer is an external mutable store read during render; useSyncExternalStore is the primitive React provides for that.

Tests

  • Existing suite unchanged and passing.
  • New: scroll notifications through the store (range + isScrolling flip), useFlushSync sync commit vs. React-scheduled commit outside act, SSR via getServerSnapshot, StrictMode, no updates after unmount.
  • pnpm run test:pr locally: eslint, types, lib, build/publint, sherif, knip, and the full react-virtual Playwright suite (33 tests, incl. react-compiler and direct-dom-updates).

Note on React Compiler / knownIncompatible

React Compiler ships a hardcoded knownIncompatible entry for @tanstack/react-virtual's useVirtualizer and skips every component that calls it (#1119). This PR deliberately does not change that, and doesn't try to work around it:

Possible follow-ups, as separate PRs, whichever you prefer:

  1. Alias hook — a companion built on the same store that returns the render-facing values as immutable data (virtualItems, totalSize) next to the stable instance for imperative calls. feat(react-virtual): add useVirtualizerSnapshot for React Compiler compatibility via useSyncExternalStore #1241 is one concrete shape of that; it can be rebased onto this store.
  2. Upstream delisting — once a compiler-safe contract exists, work with the React team to drop (or narrow) the knownIncompatible entry, so consumers compile without renaming imports.

Refs: #851, #736, #743, #1119, #1241

✅ Checklist

  • I have followed the steps in the Contributing guide.
  • I have tested this code locally with pnpm run test:pr.

🚀 Release Impact

  • This change affects published code, and I have generated a changeset.
  • This change is docs/CI/dev-only (no release).

Summary by CodeRabbit

  • Bug Fixes
    • Improved rendering consistency for virtualized content during concurrent rendering.
    • Ensured updates are applied synchronously when needed, reducing visual inconsistencies before paint.
    • Improved reliability across server-rendered, Strict Mode, and unmounted component scenarios.
  • Compatibility
    • Preserved existing virtualizer APIs and React 16.8+ support.

…cExternalStore

Replace the reducer bump in useVirtualizerBase with a small external store
consumed through useSyncExternalStore (official use-sync-external-store shim,
so the >=16.8 peer range is unchanged). useVirtualizer / useWindowVirtualizer
keep the same API and identity semantics; consumers still read
getVirtualItems() / getTotalSize() from the instance.

The store snapshot is a version counter bumped by every notification the
adapter decides to render, so under concurrent rendering React can detect a
mid-render store change and re-render synchronously instead of committing a
torn range. useFlushSync keeps its meaning: the sync scroll path notifies
inside flushSync.

useSyncExternalStore subscribes in a passive effect, so notifications raised
while React is committing (initial rect/offset measurement in _willUpdate,
scroll-element swaps, measureElement refs) would only surface after paint. A
final layout effect compares the store version with the rendered one and
dispatches a reducer so those still re-render before paint, exactly as the
reducer-only implementation did; mount/measure render counts are unchanged.

Tests cover scroll notifications through the store, useFlushSync sync vs
scheduled commits outside act, SSR via getServerSnapshot, StrictMode, and
post-unmount notifications.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 24, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 807f919d-2dfe-4818-826f-d14a2a8c5e32

📥 Commits

Reviewing files that changed from the base of the PR and between e9874f0 and 83cdb85.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (4)
  • .changeset/quiet-stores-listen.md
  • packages/react-virtual/package.json
  • packages/react-virtual/src/index.tsx
  • packages/react-virtual/tests/index.test.tsx

Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review.


📝 Walkthrough

Walkthrough

useVirtualizerBase now uses useSyncExternalStore with a versioned listener store. Tests cover notification timing, SSR, StrictMode, and unmount behavior. The React peer range and virtualizer APIs remain unchanged.

Changes

React virtualizer store

Layer / File(s) Summary
External store rerender flow
packages/react-virtual/src/index.tsx, packages/react-virtual/package.json, .changeset/quiet-stores-listen.md
The virtualizer uses a versioned external store with useSyncExternalStore. Notifications support flushSync, and a final layout effect reconciles updates before paint. The required runtime and type packages are declared.
Rendering behavior validation
packages/react-virtual/tests/index.test.tsx
Tests cover offset-driven updates, synchronous and deferred commits, server rendering, StrictMode, and notifications after unmount.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: ⚪ Minimal · up to 83cdb

This refactors virtualizer update subscriptions without changing the public API or instance identity semantics; the reported validation passes, and no actionable merge-blocking risk remains.

Sequence Diagram(s)

sequenceDiagram
  participant useVirtualizerBase
  participant store
  participant React
  useVirtualizerBase->>store: notify version onChange
  store->>React: notify subscribed snapshot
  React->>useVirtualizerBase: render current version
  useVirtualizerBase->>React: reconcile version in layout effect
Loading

Suggested reviewers: piecyk

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main refactor to use useSyncExternalStore for virtualizer updates.
Description check ✅ Passed The description is complete, follows the template, explains the design and motivation, documents testing, and confirms the changeset.
Docstring Coverage ✅ Passed Docstring check was indeterminate for this PR — some files could not be analyzed in time. Not blocking.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

Some tools did not complete. Review the errors below.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

packages/react-virtual/tests/index.test.tsx

Parsing error: "parserOptions.project" has been provided for @typescript-eslint/parser.
The file was not found in any of the provided project(s): packages/react-virtual/tests/index.test.tsx


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Added@​types/​use-sync-external-store@​1.5.01001006280100

View full report

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant