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

@riccardoperra riccardoperra 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:

const getSnapshot = () => {
  const next = computeFromCurrentOptions()

  if (!hasSnapshot || !compare(snapshot, next)) {
    snapshot = next
  }

  return snapshot
}

const reactiveAtom = createAtom(() => {
  commitAtom.get()
  return getSnapshot()
})

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.Subscribe selector={(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

Scenario State source Expected behavior
Internal/uncontrolled state baseAtoms[key] Table APIs update the base atom and notify subscribers immediately. The root rerenders only if its selected value changes.
Controlled state options.state[key] The current render reads the controlled value immediately. Base synchronization and subscriber notification happen only after commit.
External atom options.atoms[key] The table subscribes internally to the atom. No outer React useSelector is required.
External atom and controlled state for the same slice External atom The external atom wins. Controlled state does not override the slice resolver.
Controlled → internal ownership Base atom After 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 ownership Controlled state The controlled value is visible in the same React render without waiting for atom synchronization.
Unselected slice changes Any source The public store may update, but the root selector does not rerender or forward onStoreChange when its selected result is unchanged.
Recreated controlled objects Controlled state Shallow comparison suppresses equivalent base writes and prevents maximum-depth loops.
Rapid controlled updates Controlled state React resolves the final controlled value; committed publication brings isolated subscribers to the same final snapshot.
Strict Mode Controlled state Repeated render/effect execution remains idempotent and comparator-gated.
Suspended render Controlled state Render-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

function App() {
  const [pagination, setPagination] = React.useState({
    pageIndex: 0,
    pageSize: 10,
  })

  const table = useTable(
    {
      data,
      columns,
      features,
      state: {
        pagination,
      },
      onPaginationChange: setPagination,
    },
    (state) => state.pagination,
  )

  return (
    <>
      <div>Page {table.state.pageIndex + 1}</div>
      <button onClick={() => 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

function App() {
  const paginationAtom = useCreateAtom({
    pageIndex: 0,
    pageSize: 10,
  })

  const table = useTable(
    {
      data,
      columns,
      features,
      atoms: {
        pagination: paginationAtom,
      },
    },
    (state) => state.pagination,
  )

  return (
    <>
      <div>Page {table.state.pageIndex + 1}</div>
      <button onClick={() => table.nextPage()}>Next</button>
    </>
  )
}

No additional subscription is required:

// Not needed
useSelector(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:

Area Coverage
Controlled state Live store reads, stable root subscription and no redundant render
Ownership transitions Controlled/internal transitions, including isolated-subscriber invalidation
External atoms Atom updates without an outer useSelector, including row-model changes
Precedence External atom > controlled state > internal base atom
Warning regression No render-phase warning after the first or second pagination update
State consistency React state, table.state, slice atom, flat store, subscriber and row model remain aligned
Selector gating Unselected internal and external changes do not rerender the root
Snapshot stability Stable store.get(), fresh selector objects and recreated controlled slices
Rapid updates Memoized isolated subscribers receive the final controlled value
Commit safety Suspended renders do not mutate base state or notify subscribers
Strict Mode Controlled updates remain stable under repeated rendering/effect execution
Browser integration Controlled-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-cloud Bot commented Jul 27, 2026

Copy link
Copy Markdown

View your CI Pipeline Execution ↗ for commit 6e781d1

Command Status Duration Result
nx affected --targets=test:eslint,test:sherif,t... ✅ Succeeded 4m 16s View ↗
nx run-many --targets=build --exclude=examples/** ✅ Succeeded 25s View ↗

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

@coderabbitai

coderabbitai Bot 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 name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring 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 name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the main change: fixing React adapter state synchronization behavior.
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 💡 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-new Bot 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

Comment thread packages/react-table/src/reactivity.ts Outdated
* 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
Collaborator Author

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

@coderabbitai coderabbitai Bot 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
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

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

KevinVandy and 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>

@coderabbitai coderabbitai Bot 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.

@coderabbitai coderabbitai Bot 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

@KevinVandy
KevinVandy merged commit 4be42f9 into beta Jul 29, 2026
9 of 10 checks passed
@KevinVandy
KevinVandy deleted the fix/react-sync-state-adapter branch July 29, 2026 21:59
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.

2 participants