Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 6 additions & 12 deletions examples/react/basic-external-atoms/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React from 'react'
import { TanStackDevtools } from '@tanstack/react-devtools'
import ReactDOM from 'react-dom/client'
import './index.css'
import { useCreateAtom, useSelector } from '@tanstack/react-store'
import { useCreateAtom } from '@tanstack/react-store'
import {
createColumnHelper,
createPaginatedRowModel,
Expand Down Expand Up @@ -79,14 +79,8 @@ function App() {
pageSize: 10,
})

// Subscribe to each atom independently — fine-grained reactivity.
const sorting = useSelector(sortingAtom)
const pagination = useSelector(paginationAtom)

console.log('sorting', sorting)
console.log('pagination', pagination)

// Create the table and pass your per-slice external atoms.
// Pass the per-slice external atoms directly. The React adapter subscribes
// through table.store, so no separate useSelector call is needed here.
const table = useTable(
{
key: 'basic-external-atoms', // needed for devtools
Expand Down Expand Up @@ -185,7 +179,7 @@ function App() {
<span className="inline-controls">
<div>Page</div>
<strong>
{(table.atoms.pagination.get().pageIndex + 1).toLocaleString()} of{' '}
{(table.state.pagination.pageIndex + 1).toLocaleString()} of{' '}
{table.getPageCount().toLocaleString()}
</strong>
</span>
Expand All @@ -195,7 +189,7 @@ function App() {
type="number"
min="1"
max={table.getPageCount()}
defaultValue={table.atoms.pagination.get().pageIndex + 1}
defaultValue={table.state.pagination.pageIndex + 1}
onChange={(e) => {
const page = e.target.value ? Number(e.target.value) - 1 : 0
table.setPageIndex(page)
Expand All @@ -204,7 +198,7 @@ function App() {
/>
</span>
<select
value={table.atoms.pagination.get().pageSize}
value={table.state.pagination.pageSize}
onChange={(e) => {
table.setPageSize(Number(e.target.value))
}}
Expand Down
27 changes: 27 additions & 0 deletions examples/react/basic-external-atoms/tests/e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,30 @@ test('regenerates table data', async ({ page }) => {
await server.close()
}
})

test('updates from external atoms without an outer React subscription', async ({
page,
}) => {
const { errors, server } = await openExample(page)

try {
const table = page.locator('table').first()
const nextPageButton = page.getByRole('button', {
name: '>',
exact: true,
})
const pageStatus = page.getByText(/^Page$/).locator('..')

await expect(table.locator('tbody tr').first()).toBeVisible()
await expect(pageStatus).toContainText('1 of 100')
const firstPageRow = await getFirstBodyRowText(table)

await nextPageButton.click()

await expect(pageStatus).toContainText('2 of 100')
await expect.poll(() => getFirstBodyRowText(table)).not.toBe(firstPageRow)
expect(errors).toEqual([])
} finally {
await server.close()
}
})
37 changes: 37 additions & 0 deletions examples/react/basic-external-state/tests/e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,40 @@ test('regenerates table data', async ({ page }) => {
await server.close()
}
})

test('updates controlled pagination without render-phase errors', async ({
page,
}) => {
const { errors, server } = await openExample(page)

try {
const table = page.locator('table').first()
const nextPageButton = page.getByRole('button', {
name: '>',
exact: true,
})
const pageStatus = page.getByText(/^Page$/).locator('..')

await expect(table.locator('tbody tr').first()).toBeVisible()
await expect(nextPageButton).toBeEnabled()
await expect(pageStatus).toContainText('1 of 100')

const firstPageRow = await getFirstBodyRowText(table)

await nextPageButton.click()

await expect(pageStatus).toContainText('2 of 100')
await expect.poll(() => getFirstBodyRowText(table)).not.toBe(firstPageRow)
expect(errors.join('\n')).not.toContain('Cannot update a component')

const secondPageRow = await getFirstBodyRowText(table)

await nextPageButton.click()

await expect(pageStatus).toContainText('3 of 100')
await expect.poll(() => getFirstBodyRowText(table)).not.toBe(secondPageRow)
expect(errors.join('\n')).not.toContain('Cannot update a component')
} finally {
await server.close()
}
})
4 changes: 3 additions & 1 deletion packages/react-table/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,12 @@
"devDependencies": {
"@eslint-react/eslint-plugin": "^5.9.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"eslint-plugin-react-compiler": "19.1.0-rc.2",
"eslint-plugin-react-hooks": "^7.1.1",
"react": "^19.2.7"
"react": "^19.2.7",
"react-dom": "^19.2.7"
},
"peerDependencies": {
"react": ">=18"
Expand Down
119 changes: 114 additions & 5 deletions packages/react-table/src/reactivity.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,35 @@
import { batch, createAtom } from '@tanstack/react-store'
import { batch, createAtom, useSelector } from '@tanstack/react-store'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import type { Subscription } from '@tanstack/react-store'
import type {
TableAtomOptions,
TableReactivityBindings,
} from '@tanstack/table-core/reactivity'

interface ExternalSource<T> {
get: () => T
subscribe: (listener: (value: T) => void) => Subscription
}

export const useIsomorphicLayoutEffect =
typeof window === 'undefined' ? useEffect : useLayoutEffect

export interface ReactTableReactivityBindings extends TableReactivityBindings {
/**
* Invalidates readonly atoms after React commits updated plain options.
*/
commit: () => void
}

/**
* Creates the table-core reactivity bindings used by the React adapter.
*
* React stores table state in TanStack Store atoms and leaves options as plain
* resolved data because `useTable` synchronizes options during render.
*/
export function reactReactivity(): TableReactivityBindings {
export function reactReactivity(): ReactTableReactivityBindings {
const commitAtom = createAtom(0)

return {
createOptionsStore: false,
wrapExternalAtoms: false,
Expand All @@ -28,14 +47,104 @@ export function reactReactivity(): TableReactivityBindings {
batch,
untrack: (fn) => fn(),
createReadonlyAtom: <T>(fn: () => T, options?: TableAtomOptions<T>) => {
return createAtom(() => fn(), {
compare: options?.compare,
})
const compare = options?.compare ?? Object.is
let hasSnapshot = false
let snapshot: T

// The returned atom is a live facade: get() must see the options from the
// render in progress, while subscribe() must only publish after commit.
// The hidden computed tracks both real atom dependencies and commitAtom,
// which covers changes whose only reactive source is a plain option
// (controlled state ownership, for example).
const getSnapshot = () => {
const nextSnapshot = fn()

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

return snapshot
}

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

return {
get: getSnapshot,
subscribe: reactiveAtom.subscribe.bind(reactiveAtom),
}
},
createWritableAtom: <T>(value: T, options?: TableAtomOptions<T>) => {
return createAtom(value, {
compare: options?.compare,
})
},
commit: () => {
commitAtom.set((version) => version + 1)
},
}
}

/**
* Selects the root table state while filtering notifications that merely
* publish a snapshot already read during the current React render.
*
* 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

source: ExternalSource<TState>,
selector: ((state: TState) => TSelected) | undefined,
compare: (previous: TSelected, next: TSelected) => boolean,
): TSelected {
const committedSelectorRef = useRef(selector)
const committedSelectionRef = useRef<
| { hasSnapshot: false }
| {
hasSnapshot: true
snapshot: TSelected
}
>({ hasSnapshot: false })

const [filteredSource] = useState(() => {
return {
get: () => source.get(),
subscribe: (onStoreChange: (value: TState) => void) => {
return source.subscribe((nextState) => {
const select =
committedSelectorRef.current ??
((state: TState) => state as unknown as TSelected)
const nextSelection = select(nextState)
const committedSelection = committedSelectionRef.current

if (
!committedSelection.hasSnapshot ||
!compare(committedSelection.snapshot, nextSelection)
) {
onStoreChange(nextState)
}
})
},
}
})

const selected = useSelector(filteredSource, selector, { compare })

useIsomorphicLayoutEffect(() => {
committedSelectorRef.current = selector
committedSelectionRef.current = {
hasSnapshot: true,
snapshot: selected,
}
})

return selected
}
56 changes: 45 additions & 11 deletions packages/react-table/src/useTable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,16 @@

import { useMemo, useState } from 'react'
import { constructTable } from '@tanstack/table-core'
import { shallow, useSelector } from '@tanstack/react-store'
import { reactReactivity } from './reactivity'
import {
table_setOptions,
table_syncExternalStateToBaseAtoms,
} from '@tanstack/table-core/static-functions'
import { shallow } from '@tanstack/react-store'
import {
reactReactivity,
useIsomorphicLayoutEffect,
useTableSelector,
} from './reactivity'
import { FlexRender } from './FlexRender'
import { Subscribe } from './Subscribe'
import type { FlexRenderProps } from './FlexRender'
Expand Down Expand Up @@ -146,14 +154,16 @@ export function useTable<
tableOptions: TableOptions<TFeatures, TData>,
selector?: (state: TableState<TFeatures>) => TSelected,
): ReactTable<TFeatures, TData, TSelected> {
const [table] = useState(() => {
const [{ table, reactivity }] = useState(() => {
const reactivity = reactReactivity()

// Explicit type arguments skip generic inference from the spread object (a
// type-check hot spot); the spread only adds the react reactivity binding
// to `features`.
const tableInstance = constructTable<TFeatures, TData>({
...tableOptions,
features: {
coreReactivityFeature: reactReactivity(),
coreReactivityFeature: reactivity,
...tableOptions.features,
},
}) as unknown as ReactTable<TFeatures, TData, TSelected>
Expand All @@ -169,16 +179,40 @@ export function useTable<

tableInstance.FlexRender = FlexRender

return tableInstance
return {
table: tableInstance,
reactivity,
}
})

// sync options on every render
table.setOptions((prev) => ({
...prev,
...tableOptions,
}))
const coreTable = table as unknown as Table<TFeatures, TData>

const state = useSelector(table.store, selector, { compare: shallow })
// Keep non-state options current during render, but publish controlled state
// into the subscribed atom graph only after React commits.
table_setOptions(
coreTable,
(prev) => ({
...prev,
...tableOptions,
}),
{
syncExternalState: false,
},
)

const controlledState = coreTable.options.state
const state = useTableSelector(table.store, selector, shallow)

useIsomorphicLayoutEffect(() => {
// Publish only the state captured by a committed render. The React
// readonly atoms already expose it through their live get(). Invalidating
// in the same pre-paint batch also updates isolated table.Subscribe
// consumers and handles changes in controlled/uncontrolled ownership.
reactivity.batch(() => {
table_syncExternalStateToBaseAtoms(coreTable, controlledState, shallow)
reactivity.commit()
})
})

// we know this is not the most efficient way to return the table,
// but it is required for the react compiler to work
Expand Down
3 changes: 3 additions & 0 deletions packages/react-table/tests/test-setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Object.assign(globalThis, {
IS_REACT_ACT_ENVIRONMENT: true,
})
Loading
Loading