fix: react adapter sync state - #6458
Conversation
|
View your CI Pipeline Execution ↗ for commit 6e781d1
☁️ Nx Cloud last updated this comment at |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughTable state synchronization now uses render-phase snapshots and commit boundaries across core, React, Preact, and Lit integrations. Controlled-state publication, selector notifications, pagination examples, grouping behavior, tests, and feature lifecycle documentation were updated. ChangesCommit-aligned table reactivity
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant Framework
participant useTable
participant RenderPhaseSource
participant table_setOptions
participant table_publishExternalState
participant Subscribers
Framework->>useTable: render controlled options
useTable->>table_setOptions: stage options without synchronization
useTable->>RenderPhaseSource: read render snapshot
Framework->>useTable: run commit lifecycle
useTable->>RenderPhaseSource: markCommitted(snapshot)
useTable->>table_publishExternalState: publish controlled state
table_publishExternalState->>Subscribers: notify committed selected state
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Warning Review ran into problems🔥 ProblemsGit: Failed to clone repository. Please run the 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. Comment |
| * Isolated `table.Subscribe` consumers still receive the underlying store | ||
| * notification; only this hook's root subscription is filtered. | ||
| */ | ||
| export function useTableSelector<TState, TSelected>( |
There was a problem hiding this comment.
fyi this is just a useSyncWithExternalStoreWithSelector revisited implementation
There was a problem hiding this comment.
🧹 Nitpick comments (3)
packages/react-table/tests/useTable.test.tsx (1)
1-930: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winConsider extracting a shared DOM-query helper.
The
container?.querySelector('[data-testid="..."]')?.textContentpattern is repeated across nearly every test. A small helper (e.g.queryText(testId: string)) would reduce boilerplate and keep future tests consistent.♻️ Example helper
function queryText(testId: string) { return container?.querySelector(`[data-testid="${testId}"]`)?.textContent }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/react-table/tests/useTable.test.tsx` around lines 1 - 930, Extract a shared queryText(testId) helper near render and reuse it throughout the tests in place of repeated container?.querySelector('[data-testid="..."]')?.textContent expressions. Update all data-testid lookups to call the helper while preserving their existing assertions and behavior.packages/react-table/src/reactivity.ts (1)
100-149: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win
compareis frozen at first render whileselectoris kept live via ref.
selectorRefis updated every render so the latestselectoris always used, butcompareis captured directly by theuseStateinitializer closure and never refreshed — if a caller ever passes a non-stablecomparefunction, this hook would silently keep using the very first one forever. Today this is harmless because both call sites (useTable.ts) pass the same stableshallowreference, but this is an exported hook, so the inconsistency is a latent footgun for other/future callers.♻️ Proposed fix: mirror the selector-ref pattern for compare
const selectorRef = useRef(selector) selectorRef.current = selector + const compareRef = useRef(compare) + compareRef.current = compare const [selectedSource] = useState(() => { let hasSnapshot = false let snapshot: TSelected const getSnapshot = () => { const select = selectorRef.current ?? ((state: TState) => state as unknown as TSelected) const nextSnapshot = select(source.get()) - if (!hasSnapshot || !compare(snapshot, nextSnapshot)) { + if (!hasSnapshot || !compareRef.current(snapshot, nextSnapshot)) { snapshot = nextSnapshot hasSnapshot = true } return snapshot }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/react-table/src/reactivity.ts` around lines 100 - 149, Keep the comparison function current in useTableSelector by mirroring the existing selectorRef pattern: store compare in a ref and update it on every render, then have getSnapshot use the ref’s current comparator instead of the initializer-captured compare parameter. Preserve the existing snapshot caching and subscription behavior.packages/table-core/src/core/table/coreTablesFeature.utils.ts (1)
53-66: 🗄️ Data Integrity & Integration | 🔵 Trivial | 💤 Low valueSkip
options.statesync for keys owned by external atoms.When a slice has both
options.atoms[key]andoptions.state[key],table.atoms[key]reads the external atom only, sotable_syncExternalStateToBaseAtoms()leavingbaseAtoms[key]stale is safe unless a setter readstable.baseAtoms[key]as the current value. Current feature code reads throughtable.atoms, so this is only a minor correctness cleanup.🛡️ Proposed fix
+import { hasOwn } from '../../utils' + table._reactivity.batch(() => { for (const key in state) { const baseAtom = (table.baseAtoms as Record<string, any>)[key] if (!baseAtom) { continue } + const externalAtoms = table.options.atoms as + | Record<string, unknown> + | undefined + if (externalAtoms && hasOwn(externalAtoms, key)) { + // An external atom owns this slice; `table.atoms[key]` never reads + // the base atom for it, so writing here would only leave stale data behind. + continue + } const externalState = state[key as keyof typeof state] const currentState = table._reactivity.untrack(() => baseAtom.get()) if (!compare(currentState, externalState)) { baseAtom.set(() => externalState) } } })🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/table-core/src/core/table/coreTablesFeature.utils.ts` around lines 53 - 66, Update table_syncExternalStateToBaseAtoms so it skips state synchronization for keys present in options.atoms, leaving those externally owned atoms untouched. Keep the existing baseAtoms lookup, comparison, and update behavior for keys without an external atom.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@packages/react-table/src/reactivity.ts`:
- Around line 100-149: Keep the comparison function current in useTableSelector
by mirroring the existing selectorRef pattern: store compare in a ref and update
it on every render, then have getSnapshot use the ref’s current comparator
instead of the initializer-captured compare parameter. Preserve the existing
snapshot caching and subscription behavior.
In `@packages/react-table/tests/useTable.test.tsx`:
- Around line 1-930: Extract a shared queryText(testId) helper near render and
reuse it throughout the tests in place of repeated
container?.querySelector('[data-testid="..."]')?.textContent expressions. Update
all data-testid lookups to call the helper while preserving their existing
assertions and behavior.
In `@packages/table-core/src/core/table/coreTablesFeature.utils.ts`:
- Around line 53-66: Update table_syncExternalStateToBaseAtoms so it skips state
synchronization for keys present in options.atoms, leaving those externally
owned atoms untouched. Keep the existing baseAtoms lookup, comparison, and
update behavior for keys without an external atom.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 58ab51b0-2435-4a26-bf73-f174554d6cb5
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (12)
examples/react/basic-external-atoms/src/main.tsxexamples/react/basic-external-atoms/tests/e2e/smoke.spec.tsexamples/react/basic-external-state/tests/e2e/smoke.spec.tspackages/react-table/package.jsonpackages/react-table/src/reactivity.tspackages/react-table/src/useTable.tspackages/react-table/tests/test-setup.tspackages/react-table/tests/useTable.test.tsxpackages/react-table/vite.config.tspackages/table-core/src/core/table/constructTable.tspackages/table-core/src/core/table/coreTablesFeature.utils.tspackages/table-core/tests/unit/core/tableAtoms.test.ts
…gs (#6459) * refactor: move render-phase sync strategy into core reactivity bindings Builds on the deferred controlled-state publication from this branch and consolidates it behind a consistent adapter interface: - TableReactivityBindings gains two optional properties describing the two strategy axes: deferExternalStateSync (write-side: is setOptions a notification-safe moment?) and commit (invalidation hook for readonly atoms whose compute reads non-reactive plain options). constructTable warns in dev when defer is set with plain options but no commit hook. - table_setOptions consults the bindings flag instead of a per-call { syncExternalState } option, so an adapter's strategy is declared once. - table_syncExternalStateToBaseAtoms replaces the arguments.length overloads with an explicit `capturedState | null` sentinel, and now owns the commit bump: it runs inside the write batch and fires even when nothing is published, so ownership releases still invalidate subscribers. - New renderPhaseReactivity preset in @tanstack/table-core/reactivity hosts the live readonly-atom facade + commit atom (moved from the React adapter). Store primitives are injected by the adapter so all atoms share one store instance with user external atoms. Preact can adopt the same preset later. - New createCommitFilteredSource replaces useTableSelector: because facade snapshots are referentially stable, a reference check on the last snapshot read is enough to skip the root hook's redundant post-commit notification. This drops the committed-selection refs and the implicit requirement that the selector's layout effect run before the publish effect. - useTable: plain useSelector over the filtered source; the publish layout effect is a single table_syncExternalStateToBaseAtoms call. All 11 react-table tests from this branch pass unchanged. table-core: 1017 tests, types, lint, size-limit green. Both example e2e smoke suites pass. Verified in basic-external-state (StrictMode + React Compiler + devtools): one render pass and one commit per controlled update, no render-phase warning, correct behavior through pagination/sorting/page-size/1M-row stress. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: adopt render-phase reactivity preset in the preact adapter Preact had the identical sync-options-during-render code as React and paid the same cost silently: the mid-render store notification scheduled an extra deferred render per controlled update (Preact never warns about it). Same shape as the React adapter: preactReactivity collapses onto renderPhaseReactivity with @tanstack/preact-store primitives, useTable defers publication to an isomorphic layout effect with the captured controlled state, and the root useSelector reads through createCommitFilteredSource. New unit tests assert one render pass per controlled update with consistent controlled/selected/atom/store/row-model reads, exactly one post-commit store notification for external subscribers, and uncontrolled updates still re-rendering. New e2e spec exercises controlled pagination in the basic-external-state preact example. Types, lint, and build green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: adopt render-phase reactivity in the lit adapter, un-pin subscribe islands from stale renders The lit basic-subscribe e2e regression had two stacked causes: 1. The subscribe directive returned noChange for host-driven re-renders with an unchanged source + selector, so islands only ever updated from store notifications. They previously received one for ANY setOptions call because the aggregate state snapshot was rebuilt without a comparator; the (correct) shallow compare on table.store stopped notifying for options-only changes, pinning islands to stale row models after Regenerate Data. The directive now re-renders islands on every host update and adopts the latest template closure, while subscriptions still drive updates between host renders. 2. Lit was the off-diagonal reactivity case: a reactive options store written during render(), costing a second update cycle per interaction (requestUpdate mid-update from the optionsStore subscription). litReactivity now uses the renderPhaseReactivity preset with @tanstack/lit-store primitives: plain options synced during render, captured controlled state published from hostUpdated(), and the controller's root subscription reads through createCommitFilteredSource. The optionsStore subscription is gone; options changes flow through host renders (lit reactive properties), which 30 of 31 lit examples already use. basic-subscribe was the lone outlier constructing the table once and pushing data via imperative setOptions in updated() — it now re-syncs options per render pass like the rest. Measured on basic-subscribe: Regenerate Data works again with one host update (previously two, with stale islands), zero idle update churn, and a row-selection click still updates only its island with zero host updates. Unit tests: directive/controller suites updated to the new contract plus a hostUpdated publication test (6/6). All 34 lit example e2e suites pass, including the previously failing basic-subscribe. Types, lint, build green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: drop dev-warning assertion to match removed bindings coherence warning The constructTable dev warning for deferExternalStateSync-without-commit was removed in the previous commit; align the test suite (1010 tests green). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: apply automated fixes * consolidate constructTable features loop again * fix: keep the two-phase feature lifecycle in the consolidated features loop The consolidated loop interleaved constructTableAPIs with initTableInstanceData per feature (and ran APIs first within each feature), breaking the lifecycle contract from #6446 that constructTable.test.ts encodes: every feature's table instance data initializes before ANY table APIs are constructed, so API constructors can read instance data owned by other features and init hooks observe a pre-API table. Keep the consolidation of the init-fn collection with instance-data init (one phase-1 loop writing the precomputed arrays directly), and restore constructTableAPIs as its own second pass. table-core: 1097/1097. React/preact/lit adapter suites green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor!: process feature lifecycle hooks in a single pass Drop the two-phase construction contract (all initTableInstanceData before any constructTableAPIs). Every in-repo hook is pure property assignment with lazy API closures, so the phase barrier bought nothing; features are now processed in one registration-order pass with each feature's instance data initialized just before its own APIs are constructed. New contract, encoded in the lifecycle test and documented in the custom-features guides and TableFeature jsdoc: hooks may rely on data and APIs of features registered earlier; eager cross-feature reads belong in lazy API bodies. table-core 1097/1097; react/preact/lit adapter suites green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor: require precomputed instance-init fn arrays on the table type constructTable always assigns the five _*InstanceInitFns arrays before any constructor can run, so model them as required and drop the non-null assertions at the use sites (including the leftover one in buildHeaderGroups that eslint flagged as unnecessary). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/framework/alpine/guide/custom-features.md`:
- Line 143: Correct the constructTableAPIs lifecycle wording so it guarantees
access only to data initialized by the current and previously registered
features, not all features. Apply this clarification in
docs/framework/alpine/guide/custom-features.md:143-143,
docs/framework/angular/guide/custom-features.md:149-149,
docs/framework/ember/guide/custom-features.md:149-149,
docs/framework/lit/guide/custom-features.md:141-141,
docs/framework/preact/guide/custom-features.md:149-149,
docs/framework/react/guide/custom-features.md:149-149,
docs/framework/solid/guide/custom-features.md:143-143,
docs/framework/svelte/guide/custom-features.md:143-143, and
docs/framework/vue/guide/custom-features.md:143-143; explicitly state that later
feature initialization has not occurred and preserve the single-pass
registration-order execution model.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: ee925b54-1b40-4ab3-a19c-492e46ec1cf7
📒 Files selected for processing (9)
docs/framework/alpine/guide/custom-features.mddocs/framework/angular/guide/custom-features.mddocs/framework/ember/guide/custom-features.mddocs/framework/lit/guide/custom-features.mddocs/framework/preact/guide/custom-features.mddocs/framework/react/guide/custom-features.mddocs/framework/solid/guide/custom-features.mddocs/framework/svelte/guide/custom-features.mddocs/framework/vue/guide/custom-features.md
| #### initTableInstanceData and resetTableInstanceData | ||
|
|
||
| Use `initTableInstanceData` for mutable, non-reactive data that belongs to one table instance, such as an interaction anchor or an imperative cache. It runs once after table options, state atoms, and the store have been created. Every feature's initialization hook runs before any feature's `constructTableAPIs` hook. | ||
| Use `initTableInstanceData` for mutable, non-reactive data that belongs to one table instance, such as an interaction anchor or an imperative cache. It runs once after table options, state atoms, and the store have been created. Features are processed in a single pass in registration order; each feature's initialization hook runs just before that feature's `constructTableAPIs` hook, so hooks may rely on data and APIs from features registered earlier. |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Correct the conflicting lifecycle guarantee across all framework guides.
The new text says initialization and construction are interleaved per feature, but the following constructTableAPIs description still says all feature-owned data has already been initialized. That can mislead feature authors into depending on later registrations.
docs/framework/alpine/guide/custom-features.md#L143-L143: Clarify the following paragraph to guarantee only the current and earlier features’ data.docs/framework/angular/guide/custom-features.md#L149-L149: Replace the global initialization implication with the current-feature guarantee.docs/framework/ember/guide/custom-features.md#L149-L149: State that later feature initialization has not occurred yet.docs/framework/lit/guide/custom-features.md#L141-L141: Align the construct-hook paragraph with single-pass execution.docs/framework/preact/guide/custom-features.md#L149-L149: Remove the obsolete all-features guarantee.docs/framework/react/guide/custom-features.md#L149-L149: Document that only the current feature’s initialization is complete.docs/framework/solid/guide/custom-features.md#L143-L143: Preserve the dependency boundary for later features.docs/framework/svelte/guide/custom-features.md#L143-L143: Update the construct-hook wording to match interleaving.docs/framework/vue/guide/custom-features.md#L143-L143: Replace “all feature-owned” with the precise current-feature guarantee.
📍 Affects 9 files
docs/framework/alpine/guide/custom-features.md#L143-L143(this comment)docs/framework/angular/guide/custom-features.md#L149-L149docs/framework/ember/guide/custom-features.md#L149-L149docs/framework/lit/guide/custom-features.md#L141-L141docs/framework/preact/guide/custom-features.md#L149-L149docs/framework/react/guide/custom-features.md#L149-L149docs/framework/solid/guide/custom-features.md#L143-L143docs/framework/svelte/guide/custom-features.md#L143-L143docs/framework/vue/guide/custom-features.md#L143-L143
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/framework/alpine/guide/custom-features.md` at line 143, Correct the
constructTableAPIs lifecycle wording so it guarantees access only to data
initialized by the current and previously registered features, not all features.
Apply this clarification in
docs/framework/alpine/guide/custom-features.md:143-143,
docs/framework/angular/guide/custom-features.md:149-149,
docs/framework/ember/guide/custom-features.md:149-149,
docs/framework/lit/guide/custom-features.md:141-141,
docs/framework/preact/guide/custom-features.md:149-149,
docs/framework/react/guide/custom-features.md:149-149,
docs/framework/solid/guide/custom-features.md:143-143,
docs/framework/svelte/guide/custom-features.md:143-143, and
docs/framework/vue/guide/custom-features.md:143-143; explicitly state that later
feature initialization has not occurred and preserve the single-pass
registration-order execution model.
There was a problem hiding this comment.
🧹 Nitpick comments (1)
packages/table-core/tests/implementation/core/autoReset.test.ts (1)
199-212: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winAdd coverage for both reset paths.
This test covers only
pageIndexand a reference-only columns update. Add cases verifying that expanded state is also preserved for equivalent columns, while real grouping or pre-grouped-row-model changes still reset both pagination and expansion.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/table-core/tests/implementation/core/autoReset.test.ts` around lines 199 - 212, Extend the auto-reset coverage around the existing column-reference test to assert expanded state remains unchanged for equivalent columns, and add cases for real grouping changes and pre-grouped-row-model changes that verify both pagination.pageIndex and expanded state reset. Reuse the existing table setup and reset-path helpers, keeping the reference-only columns behavior unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@packages/table-core/tests/implementation/core/autoReset.test.ts`:
- Around line 199-212: Extend the auto-reset coverage around the existing
column-reference test to assert expanded state remains unchanged for equivalent
columns, and add cases for real grouping changes and pre-grouped-row-model
changes that verify both pagination.pageIndex and expanded state reset. Reuse
the existing table setup and reset-path helpers, keeping the reference-only
columns behavior unchanged.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 534382ca-36d7-46f7-bedf-6ed3834cf4fe
📒 Files selected for processing (12)
examples/react/mantine-react-table/src/mantine-react-table/components/menus/MRT_ShowHideColumnsMenu.tsxexamples/react/mantine-react-table/src/mantine-react-table/hooks/display-columns/getMRT_RowNumbersColumnDef.tsxexamples/react/mantine-react-table/src/mantine-react-table/hooks/useMRT_Effects.tsexamples/react/mantine-react-table/src/mantine-react-table/hooks/useMRT_TableInstance.tsexamples/react/mantine-react-table/tests/e2e/smoke.spec.tsexamples/react/material-react-table/src/material-react-table/components/menus/MRT_ShowHideColumnsMenu.tsxexamples/react/material-react-table/src/material-react-table/hooks/display-columns/getMRT_RowNumbersColumnDef.tsxexamples/react/material-react-table/src/material-react-table/hooks/useMRT_Effects.tsexamples/react/material-react-table/src/material-react-table/hooks/useMRT_TableInstance.tsexamples/react/material-react-table/tests/e2e/smoke.spec.tspackages/table-core/src/features/column-grouping/createGroupedRowModel.tspackages/table-core/tests/implementation/core/autoReset.test.ts
Summary
This PR fixes React render-phase updates when using controlled table state, as reported in #6450.
The core idea is to separate two operations that previously happened together:
Previously,
useTablecalledsetOptions()during render, andsetOptions()immediately synchronized controlled state into writable base atoms. Updating those atoms synchronously notifiedtable.store, which could call React’s external-store listener while another render was still in progress:The new flow keeps render reads synchronous and current, but defers reactive publication until the React layout/commit phase.
Goals
The implementation is intended to support all of the following cases:
options.state.options.atoms.useSelector.table.Subscribeconsumers.getSnapshot()references.State ownership model
Each state slice now resolves using the documented precedence:
The check against
options.stateis an own-property check rather than a truthiness check. This distinguishes a missing controlled slice from a slice explicitly owned by the controlled state object.flowchart TD A[Read table state slice] --> B{External atom configured?} B -- Yes --> C[Read and return external atom] B -- No --> D[Read base atom to retain reactive dependency] D --> E{options.state owns this key?} E -- Yes --> F[Return controlled render value] E -- No --> G[Return base atom value] C --> H[Stable live slice snapshot] F --> H G --> H H --> I[Shallow-stable aggregate table.store] I --> J[React selector gate]The base or external atom is still read before resolving controlled state. This keeps the readonly atom connected to its reactive owner even while the controlled value currently wins.
Implementation
1. Live readonly atoms in the React adapter
React table options are plain values updated during render. They are not themselves reactive dependencies, so a normal computed atom cannot know that
options.statechanged.The React
createReadonlyAtomimplementation therefore returns a small live facade:get()evaluates the resolver against the latest render-time options.Conceptually:
This gives us two complementary behaviors:
2. Render phase: update options without publishing atoms
useTablenow updates the table options using:This keeps all options current during render but explicitly prevents
setOptions()from synchronizing controlled state into base atoms at that point.As a result, during the render that receives a new controlled value:
table.options.statecontains the new value.table.atoms.<slice>.get()returns the new value.table.store.get()returns the new value.table.statereturns the selected new value.3. Commit phase: publish the captured state
After React commits, an isomorphic layout effect publishes the controlled state captured by that render:
Using the captured state is important. The synchronization must represent the render that actually committed, rather than rereading a potentially newer mutable
table.optionsobject.The operation is batched so that:
The explicit commit invalidation also handles option-only changes where no base atom write occurs, such as releasing ownership of a controlled slice.
4. Root selector filtering
The public
table.storemust continue notifying its subscribers after a commit. This is required for consumers such as:However, the root
useTablecomponent has already rendered the new controlled value. Forwarding the same publication to React’s root external-store listener would be redundant.useTableSelectorwraps the existing TanStack StoreuseSelector; it does not reimplementuseSyncExternalStoreWithSelector.It records the selector and selected snapshot that React actually committed. When the public store later publishes:
Therefore:
table.Subscribecomponents can update.Hook ordering is intentional:
useTableSelectorrecords the committed root selection in its layout effect.useTablepublishes the atom graph in its following layout effect.5. Stable aggregate snapshots
The aggregate
table.storereadonly atom uses shallow comparison.This is necessary because building the full table state creates a new object. Without snapshot caching, React could observe a different object reference on every
getSnapshot()call and report:The store snapshot changes only when at least one resolved slice changes shallowly.
Controlled update sequence
The following sequence uses:
C0: previously committed controlled state.C1: next controlled state received by React.B0: previous base atom value.sequenceDiagram autonumber participant E as Table event participant R as React owner participant U as useTable participant O as table.options participant S as live table.store participant X as root useTableSelector participant B as base atoms participant C as commit atom participant I as isolated Subscribe E->>R: onPaginationChange(updater) R->>U: render with controlled C1 U->>O: table_setOptions(syncExternalState: false) Note over U,O: Options change during render<br/>No writable atom update U->>S: get current snapshot S->>B: read B0 to retain dependency Note over S: Resolve external atom<br/>then controlled C1<br/>then base B0 S-->>X: stable snapshot containing C1 X-->>U: selected C1 U-->>R: table.state, atoms and row models use C1 R->>R: commit UI for C1 Note over X,U: Layout phase after commit X->>X: record committed selection C1 U->>B: mirror captured C1, shallow-gated U->>C: increment commit version Note over U,C: Batched publication B-->>S: invalidate reactive dependencies C-->>S: invalidate option-dependent readonly atoms S->>I: publish committed snapshot C1 S->>X: publish committed snapshot C1 X->>X: compare next C1 with committed C1 Note over X: Equal selection<br/>skip root onStoreChange I->>I: rerender only if its selector changedSupported scenarios
baseAtoms[key]options.state[key]options.atoms[key]useSelectoris required.onStoreChangewhen its selected result is unchanged.API changes
table_setOptionsThe function accepts an optional final configuration argument:
The default remains unchanged:
continues to synchronize external state immediately. This preserves existing behavior for core and other adapters.
table_syncExternalStateToBaseAtomsThe function can now receive:
Passing an explicit snapshot allows React to publish the state associated with a specific committed render.
The implementation distinguishes:
from:
The first form reads
table.options.state. The second intentionally publishes no controlled state. This prevents an explicitly capturedundefinedvalue from accidentally falling back to a newer mutable options object.Usage examples
Controlled React state
The first and subsequent pagination updates now:
External atom without an outer subscription
No additional subscription is required:
The hidden readonly atom tracks the external atom, and
useTablereacts throughtable.store.Why not
queueMicrotask,setTimeout, or debouncing?A timer-based solution is not coupled to the React lifecycle.
Scheduling synchronization during render could cause a callback to run even if that render:
Timers also introduce ordering problems for rapid updates and can leave isolated subscribers stale for an arbitrary amount of time.
A passive effect would be tied to a real commit, but publication could happen after paint. A layout effect provides the required semantics:
Test coverage
The focused regression coverage includes:
useSelector, including row-model changestable.state, slice atom, flat store, subscriber and row model remain alignedstore.get(), fresh selector objects and recreated controlled slicesVerified focused results:
Additional validation performed:
Known limitation / non-goal
This change makes publication into the reactive atom graph commit-only, but it does not make the entire table instance render-local.
table.optionsis still a shared mutable object updated during render. Therefore, while a concurrent render is suspended or later abandoned, imperative reads such as:may observe render-in-progress options.
What is guaranteed by this PR is narrower:
Providing full concurrent isolation for imperative table reads would require a larger architectural change, such as separating render-local options from committed options or returning a render-specific table facade.
External atom identities are also expected to remain construction-stable, consistent with the current options merging behavior.
This pull request introduces significant improvements to the React adapter for TanStack Table, focusing on more robust and predictable synchronization of controlled state, better integration with React's commit phase, and enhanced test coverage. The main changes ensure that externally controlled state is only published to the table's atom graph after React commits, preventing render-phase errors and improving fine-grained reactivity. Additionally, new tests are added to verify correct behavior, and dependency updates ensure compatibility with React 19.
React Adapter and State Synchronization Enhancements:
packages/react-table/src/reactivity.ts,packages/react-table/src/useTable.ts,packages/table-core/src/core/table/coreTablesFeature.utils.ts,packages/table-core/src/core/table/constructTable.ts: Refactored the adapter to synchronize controlled state into the table's atom graph only after React commits, using a newcommitmethod and a customuseTableSelectorhook. This prevents render-phase errors and ensures that derived atoms and table APIs always read a consistent snapshot. The synchronization now supports an explicit comparator and is controlled via a newsyncExternalStateoption. [1] [2] [3] [4] [5] [6] [7] [8] [9] [10] [11] [12] [13] [14]Test Coverage Improvements:
examples/react/basic-external-atoms/tests/e2e/smoke.spec.ts,examples/react/basic-external-state/tests/e2e/smoke.spec.ts: Added new end-to-end tests to verify that table updates correctly reflect changes from external atoms without requiring outer React subscriptions and that controlled pagination updates do not cause render-phase errors. [1] [2]packages/react-table/tests/test-setup.ts,packages/react-table/vite.config.ts: Ensured the test environment is properly set up for React 19 by enabling the act environment and including the setup file in Vite config. [1] [2]Dependency Updates:
packages/react-table/package.json: Addedreact-domand@types/react-domas dev dependencies to support React 19 features and testing.Example and Usage Updates:
examples/react/basic-external-atoms/src/main.tsx: Updated the example to remove unnecessaryuseSelectorcalls, relying on the improved React adapter for atom subscriptions and state access. [1] [2] [3] [4] [5]Summary by CodeRabbit
initTableInstanceDatafeature initialization order across framework guides.