Skip to content

fix: react adapter sync state - #6458

Merged
KevinVandy merged 7 commits into
betafrom
fix/react-sync-state-adapter
Jul 29, 2026
Merged

fix: react adapter sync state#6458
KevinVandy merged 7 commits into
betafrom
fix/react-sync-state-adapter

Conversation

@riccardoperra

@riccardoperrariccardoperra commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

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:

  1. Reading the latest table options and controlled state during React render.
  2. Publishing that state into the reactive atom graph after React commits.

Previously, useTable called setOptions() during render, and setOptions() immediately synchronized controlled state into writable base atoms. Updating those atoms synchronously notified table.store, which could call React’s external-store listener while another render was still in progress:

React render
-> table.setOptions()
-> controlled state copied into base atom
-> table.store synchronously notifies React
-> React schedules an update during render
-> "Cannot update a component while rendering a different component"

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:

  • Controlled state passed through options.state.
  • External atoms passed through options.atoms.
  • Internal/uncontrolled table state.
  • Per-slice ownership changes between controlled and internal state.
  • External atoms without requiring an additional outer useSelector.
  • Root-level state selectors without redundant React renders.
  • Isolated table.Subscribe consumers.
  • Stable getSnapshot() references.
  • React Strict Mode.
  • Rapid consecutive updates.
  • Controlled objects recreated on every render.
  • Suspended renders that must not publish uncommitted state.
  • Backward compatibility for non-React adapters and existing static APIs.

State ownership model

Each state slice now resolves using the documented precedence:

options.atoms[key]
> own options.state[key]
> baseAtoms[key]

The check against options.state is 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]
Loading

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.state changed.

The React createReadonlyAtom implementation therefore returns a small live facade:

  • get() evaluates the resolver against the latest render-time options.
  • The result is cached using the configured comparator.
  • Repeated reads return a stable reference when the result is semantically unchanged.
  • A hidden computed atom owns the real subscription.
  • The hidden computed tracks normal atom dependencies plus a React commit atom.

Conceptually:

constgetSnapshot=()=>{constnext=computeFromCurrentOptions()if(!hasSnapshot||!compare(snapshot,next)){snapshot=next}returnsnapshot}constreactiveAtom=createAtom(()=>{commitAtom.get()returngetSnapshot()})

This gives us two complementary behaviors:

  • Render-time getters always see the current controlled options.
  • Subscribers are invalidated only by real reactive writes or by a React commit.

2. Render phase: update options without publishing atoms

useTable now updates the table options using:

table_setOptions(table,updater,{syncExternalState: false,})

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.state contains the new value.
  • table.atoms.<slice>.get() returns the new value.
  • table.store.get() returns the new value.
  • table.state returns the selected new value.
  • Row models can use the new value.
  • No writable atom is updated.
  • No store subscriber is notified during render.

3. Commit phase: publish the captured state

After React commits, an isomorphic layout effect publishes the controlled state captured by that render:

reactivity.batch(()=>{table_syncExternalStateToBaseAtoms(table,capturedControlledState,shallow,)reactivity.commit()})

Using the captured state is important. The synchronization must represent the render that actually committed, rather than rereading a potentially newer mutable table.options object.

The operation is batched so that:

  • Controlled slices are mirrored into base atoms.
  • Semantically equal slices do not cause redundant writes.
  • The React commit atom invalidates readonly atoms.
  • Subscribers observe one coherent snapshot.
  • Isolated subscribers can update before paint.

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.store must continue notifying its subscribers after a commit. This is required for consumers such as:

<table.Subscribeselector={(state)=>state.pagination.pageIndex}>{(pageIndex)=><span>{pageIndex}</span>}</table.Subscribe>

However, the root useTable component has already rendered the new controlled value. Forwarding the same publication to React’s root external-store listener would be redundant.

useTableSelector wraps the existing TanStack Store useSelector; it does not reimplement useSyncExternalStoreWithSelector.

It records the selector and selected snapshot that React actually committed. When the public store later publishes:

if(!compare(committedSelection,nextSelection)){onStoreChange(nextState)}

Therefore:

  • Public store subscribers still receive the update.
  • Isolated table.Subscribe components can update.
  • The root React listener is skipped when the committed selection already matches.
  • Controlled updates produce only the render caused by the controlling React state.
  • Unrelated store changes do not call the root listener when the selected result is unchanged.

Hook ordering is intentional:

  1. useTableSelector records the committed root selection in its layout effect.
  2. useTable publishes the atom graph in its following layout effect.
  3. The filtered root subscription can compare against the correct committed selection.

5. Stable aggregate snapshots

The aggregate table.store readonly 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 result of getSnapshot should be cached to avoid an infinite loop

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 changed
Loading

Supported scenarios

ScenarioState sourceExpected behavior
Internal/uncontrolled statebaseAtoms[key]Table APIs update the base atom and notify subscribers immediately. The root rerenders only if its selected value changes.
Controlled stateoptions.state[key]The current render reads the controlled value immediately. Base synchronization and subscriber notification happen only after commit.
External atomoptions.atoms[key]The table subscribes internally to the atom. No outer React useSelector is required.
External atom and controlled state for the same sliceExternal atomThe external atom wins. Controlled state does not override the slice resolver.
Controlled → internal ownershipBase atomAfter ownership is released, the current base value becomes visible. Commit invalidation wakes memoized or isolated subscribers even if no new base write occurs.
Internal → controlled ownershipControlled stateThe controlled value is visible in the same React render without waiting for atom synchronization.
Unselected slice changesAny sourceThe public store may update, but the root selector does not rerender or forward onStoreChange when its selected result is unchanged.
Recreated controlled objectsControlled stateShallow comparison suppresses equivalent base writes and prevents maximum-depth loops.
Rapid controlled updatesControlled stateReact resolves the final controlled value; committed publication brings isolated subscribers to the same final snapshot.
Strict ModeControlled stateRepeated render/effect execution remains idempotent and comparator-gated.
Suspended renderControlled stateRender-time reads can evaluate the WIP value, but base atoms are not mutated and store subscribers are not notified because no commit effect runs.

API changes

table_setOptions

The function accepts an optional final configuration argument:

table_setOptions(table,updater,{syncExternalState: false,})

The default remains unchanged:

table_setOptions(table,updater)

continues to synchronize external state immediately. This preserves existing behavior for core and other adapters.

table_syncExternalStateToBaseAtoms

The function can now receive:

  • An explicit state snapshot.
  • An optional comparator.
table_syncExternalStateToBaseAtoms(table,capturedState,shallow,)

Passing an explicit snapshot allows React to publish the state associated with a specific committed render.

The implementation distinguishes:

table_syncExternalStateToBaseAtoms(table)

from:

table_syncExternalStateToBaseAtoms(table,undefined)

The first form reads table.options.state. The second intentionally publishes no controlled state. This prevents an explicitly captured undefined value from accidentally falling back to a newer mutable options object.

Usage examples

Controlled React state

functionApp(){const[pagination,setPagination]=React.useState({pageIndex: 0,pageSize: 10,})consttable=useTable({
data,
columns,
features,state: {
pagination,},onPaginationChange: setPagination,},(state)=>state.pagination,)return(<><div>Page {table.state.pageIndex+1}</div><buttononClick={()=>table.nextPage()}>Next</button></>)}

The first and subsequent pagination updates now:

  • Render the correct controlled state.
  • Produce the correct paginated row model.
  • Do not synchronously update the atom graph during render.
  • Do not produce the React render-phase warning.
  • Do not cause a redundant root render after commit.

External atom without an outer subscription

functionApp(){constpaginationAtom=useCreateAtom({pageIndex: 0,pageSize: 10,})consttable=useTable({
data,
columns,
features,atoms: {pagination: paginationAtom,},},(state)=>state.pagination,)return(<><div>Page {table.state.pageIndex+1}</div><buttononClick={()=>table.nextPage()}>Next</button></>)}

No additional subscription is required:

// Not neededuseSelector(paginationAtom)

The hidden readonly atom tracks the external atom, and useTable reacts through table.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:

  • Suspends.
  • Is abandoned.
  • Is replaced by a newer render.
  • Never commits.

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:

  • It runs only for a committed render.
  • The committed root selection is recorded first.
  • The atom graph is updated before paint.
  • Root notifications can be filtered.
  • Isolated subscribers can observe the committed state without relying on an arbitrary delay.

Test coverage

The focused regression coverage includes:

AreaCoverage
Controlled stateLive store reads, stable root subscription and no redundant render
Ownership transitionsControlled/internal transitions, including isolated-subscriber invalidation
External atomsAtom updates without an outer useSelector, including row-model changes
PrecedenceExternal atom > controlled state > internal base atom
Warning regressionNo render-phase warning after the first or second pagination update
State consistencyReact state, table.state, slice atom, flat store, subscriber and row model remain aligned
Selector gatingUnselected internal and external changes do not rerender the root
Snapshot stabilityStable store.get(), fresh selector objects and recreated controlled slices
Rapid updatesMemoized isolated subscribers receive the final controlled value
Commit safetySuspended renders do not mutate base state or notify subscribers
Strict ModeControlled updates remain stable under repeated rendering/effect execution
Browser integrationControlled-state and external-atoms examples exercise pagination in Chromium

Verified focused results:

  • React adapter tests: 11/11
  • Core atom tests: 11/11
  • Controlled-state Chromium E2E: 3/3
  • External-atoms Chromium E2E: 3/3
  • Focused total: 28/28

Additional validation performed:

  • Full table-core suite: 1005/1005
  • Preact adapter suite: 1/1
  • React/core/preact typechecks: passed
  • React/core/preact builds: passed
  • Controlled-state and external-atoms example builds: passed
  • ESLint, Prettier and diff checks: passed

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.options is still a shared mutable object updated during render. Therefore, while a concurrent render is suspended or later abandoned, imperative reads such as:

table.store.get()table.atoms.pagination.get()table.getRowModel()

may observe render-in-progress options.

What is guaranteed by this PR is narrower:

  • A suspended render does not mutate the committed base atoms.
  • A suspended render does not notify store subscribers.
  • Only a committed render publishes controlled state into the reactive graph.

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 new commit method and a custom useTableSelector hook. 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 new syncExternalState option. [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:

Example and Usage Updates:

Summary by CodeRabbit

  • New Features
    • Added selector-based subscription support for React state, plus an isomorphic layout effect helper.
    • Introduced render-phase reactivity to defer controlled-state publishing until commit, improving adapter consistency.
  • Bug Fixes
    • Improved controlled vs external state synchronization timing to reduce render/commit update warnings.
    • Refined pagination and row-number rendering to stay in sync across pages and controls.
    • Prevented unnecessary auto-resets when grouping inputs or column-definition references change.
  • Tests
    • Added Playwright smoke tests and expanded React/unit coverage for pagination, commit gating, and error-free updates.
  • Documentation
    • Clarified initTableInstanceData feature initialization order across framework guides.

@nx-cloud

nx-cloudBot commented Jul 27, 2026

Copy link
Copy Markdown

View your CI Pipeline Execution ↗ for commit 6e781d1

CommandStatusDurationResult
nx affected --targets=test:eslint,test:sherif,t...✅ Succeeded4m 16sView ↗
nx run-many --targets=build --exclude=examples/**✅ Succeeded25sView ↗

☁️ Nx Cloud last updated this comment at 2026-07-29 19:03:44 UTC

@coderabbitai

coderabbitaiBot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Table 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.

Changes

Commit-aligned table reactivity

Layer / File(s)Summary
Controlled state and render-phase contracts
packages/table-core/src/core/table/constructTable.ts, packages/table-core/src/core/table/coreTablesFeature.utils.ts, packages/table-core/src/core/reactivity/coreReactivityFeature.types.ts, packages/table-core/tests/unit/core/tableAtoms.test.ts
Controlled values remain visible through derived atoms while synchronization supports explicit snapshots, comparators, deferred publication, and commit callbacks.
Render-phase reactivity primitives
packages/table-core/src/core/reactivity/renderPhaseReactivity.ts, packages/table-core/tests/unit/core/renderPhaseReactivity.test.ts
Render-phase bindings cache snapshots, track commit versions, and filter notifications against committed snapshots.
React commit synchronization
packages/react-table/src/..., packages/preact-table/src/useTable.ts, packages/react-table/package.json
React and Preact adapters stage options during render, select through render-phase sources, and publish controlled state after commit.
Lit render-phase integration
packages/lit-table/src/..., packages/lit-table/tests/unit/selectorGate.test.ts
Lit adopts render-phase bindings and commits captured state during hostUpdated().
React and Preact behavior validation
packages/react-table/tests/*, packages/preact-table/tests/unit/useTable.test.tsx, examples/react/basic-external-*/*, packages/react-table/vite.config.ts
Tests and examples cover controlled, uncontrolled, external-atom, concurrent, and post-commit pagination behavior.
Mantine and Material table updates
examples/react/mantine-react-table/..., examples/react/material-react-table/...
Examples use live pagination state, state-aware column ordering, dependency-aware column preparation, and new pagination and grouping smoke tests.
Grouping auto-reset conditions
packages/table-core/src/features/column-grouping/createGroupedRowModel.ts, packages/table-core/tests/implementation/core/autoReset.test.ts
Grouping auto-resets now occur only when relevant row inputs change, including coverage for equivalent column reference updates.
Feature initialization ordering documentation
docs/framework/*/guide/custom-features.md
Framework guides describe initialization and API construction as a single registration-order pass.

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
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 29.03% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title accurately captures the main change: fixing React adapter state synchronization behavior.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/react-sync-state-adapter

Warning

Review ran into problems

🔥 Problems

Git: Failed to clone repository. Please run the @coderabbitai full review command to re-trigger a full review. If the issue persists, set path_filters to include or exclude specific files.


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.

@pkg-pr-new

pkg-pr-newBot commented Jul 27, 2026

Copy link
Copy Markdown
More templates

@tanstack/alpine-table

npm i https://pkg.pr.new/TanStack/table/@tanstack/alpine-table@6458

@tanstack/angular-table

npm i https://pkg.pr.new/TanStack/table/@tanstack/angular-table@6458

@tanstack/angular-table-devtools

npm i https://pkg.pr.new/TanStack/table/@tanstack/angular-table-devtools@6458

@tanstack/ember-table

npm i https://pkg.pr.new/TanStack/table/@tanstack/ember-table@6458

@tanstack/lit-table

npm i https://pkg.pr.new/TanStack/table/@tanstack/lit-table@6458

@tanstack/match-sorter-utils

npm i https://pkg.pr.new/TanStack/table/@tanstack/match-sorter-utils@6458

@tanstack/preact-table

npm i https://pkg.pr.new/TanStack/table/@tanstack/preact-table@6458

@tanstack/preact-table-devtools

npm i https://pkg.pr.new/TanStack/table/@tanstack/preact-table-devtools@6458

@tanstack/react-table

npm i https://pkg.pr.new/TanStack/table/@tanstack/react-table@6458

@tanstack/react-table-devtools

npm i https://pkg.pr.new/TanStack/table/@tanstack/react-table-devtools@6458

@tanstack/solid-table

npm i https://pkg.pr.new/TanStack/table/@tanstack/solid-table@6458

@tanstack/solid-table-devtools

npm i https://pkg.pr.new/TanStack/table/@tanstack/solid-table-devtools@6458

@tanstack/svelte-table

npm i https://pkg.pr.new/TanStack/table/@tanstack/svelte-table@6458

@tanstack/table-core

npm i https://pkg.pr.new/TanStack/table/@tanstack/table-core@6458

@tanstack/table-devtools

npm i https://pkg.pr.new/TanStack/table/@tanstack/table-devtools@6458

@tanstack/vue-table

npm i https://pkg.pr.new/TanStack/table/@tanstack/vue-table@6458

@tanstack/vue-table-devtools

npm i https://pkg.pr.new/TanStack/table/@tanstack/vue-table-devtools@6458

commit: 6e781d1

* Isolated `table.Subscribe` consumers still receive the underlying store
* notification; only this hook's root subscription is filtered.
*/
export function useTableSelector<TState, TSelected>(

Copy link
Copy Markdown
CollaboratorAuthor

Choose a reason for hiding this comment

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

fyi this is just a useSyncWithExternalStoreWithSelector revisited implementation

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (3)
packages/react-table/tests/useTable.test.tsx (1)

1-930: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider extracting a shared DOM-query helper.

The container?.querySelector('[data-testid="..."]')?.textContent pattern is repeated across nearly every test. A small helper (e.g. queryText(testId: string)) would reduce boilerplate and keep future tests consistent.

♻️ Example helper
functionqueryText(testId: string){returncontainer?.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

compare is frozen at first render while selector is kept live via ref.

selectorRef is updated every render so the latest selector is always used, but compare is captured directly by the useState initializer closure and never refreshed — if a caller ever passes a non-stable compare function, this hook would silently keep using the very first one forever. Today this is harmless because both call sites (useTable.ts) pass the same stable shallow reference, 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 value

Skip options.state sync for keys owned by external atoms.

When a slice has both options.atoms[key] and options.state[key], table.atoms[key] reads the external atom only, so table_syncExternalStateToBaseAtoms() leaving baseAtoms[key] stale is safe unless a setter reads table.baseAtoms[key] as the current value. Current feature code reads through table.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

📥 Commits

Reviewing files that changed from the base of the PR and between 0df4675 and 46921d5.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (12)
  • examples/react/basic-external-atoms/src/main.tsx
  • examples/react/basic-external-atoms/tests/e2e/smoke.spec.ts
  • examples/react/basic-external-state/tests/e2e/smoke.spec.ts
  • packages/react-table/package.json
  • packages/react-table/src/reactivity.ts
  • packages/react-table/src/useTable.ts
  • packages/react-table/tests/test-setup.ts
  • packages/react-table/tests/useTable.test.tsx
  • packages/react-table/vite.config.ts
  • packages/table-core/src/core/table/constructTable.ts
  • packages/table-core/src/core/table/coreTablesFeature.utils.ts
  • packages/table-core/tests/unit/core/tableAtoms.test.ts

KevinVandyand others added 2 commits July 28, 2026 09:30
…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>

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between d0f7a3e and 4a5fcbe.

📒 Files selected for processing (9)
  • docs/framework/alpine/guide/custom-features.md
  • docs/framework/angular/guide/custom-features.md
  • docs/framework/ember/guide/custom-features.md
  • docs/framework/lit/guide/custom-features.md
  • docs/framework/preact/guide/custom-features.md
  • docs/framework/react/guide/custom-features.md
  • docs/framework/solid/guide/custom-features.md
  • docs/framework/svelte/guide/custom-features.md
  • docs/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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 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-L149
  • docs/framework/ember/guide/custom-features.md#L149-L149
  • docs/framework/lit/guide/custom-features.md#L141-L141
  • docs/framework/preact/guide/custom-features.md#L149-L149
  • docs/framework/react/guide/custom-features.md#L149-L149
  • docs/framework/solid/guide/custom-features.md#L143-L143
  • docs/framework/svelte/guide/custom-features.md#L143-L143
  • docs/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.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
packages/table-core/tests/implementation/core/autoReset.test.ts (1)

199-212: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add coverage for both reset paths.

This test covers only pageIndex and 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

📥 Commits

Reviewing files that changed from the base of the PR and between 99922ac and c0ff699.

📒 Files selected for processing (12)
  • examples/react/mantine-react-table/src/mantine-react-table/components/menus/MRT_ShowHideColumnsMenu.tsx
  • examples/react/mantine-react-table/src/mantine-react-table/hooks/display-columns/getMRT_RowNumbersColumnDef.tsx
  • examples/react/mantine-react-table/src/mantine-react-table/hooks/useMRT_Effects.ts
  • examples/react/mantine-react-table/src/mantine-react-table/hooks/useMRT_TableInstance.ts
  • examples/react/mantine-react-table/tests/e2e/smoke.spec.ts
  • examples/react/material-react-table/src/material-react-table/components/menus/MRT_ShowHideColumnsMenu.tsx
  • examples/react/material-react-table/src/material-react-table/hooks/display-columns/getMRT_RowNumbersColumnDef.tsx
  • examples/react/material-react-table/src/material-react-table/hooks/useMRT_Effects.ts
  • examples/react/material-react-table/src/material-react-table/hooks/useMRT_TableInstance.ts
  • examples/react/material-react-table/tests/e2e/smoke.spec.ts
  • packages/table-core/src/features/column-grouping/createGroupedRowModel.ts
  • packages/table-core/tests/implementation/core/autoReset.test.ts

Sign up for freeto 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.

2 participants

@riccardoperra@KevinVandy