From b0302f68ed2f187dc3ad00cf42aed43ef3e4b47d Mon Sep 17 00:00:00 2001 From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:02:31 +0200 Subject: [PATCH 1/9] perf(react-store): build useSelector on useSyncExternalStore with one selection ref `useSelector` wrapped `use-sync-external-store/shim/with-selector`. Per subscribed component and per render that stack ran two `useCallback`s in `useSelector` (`subscribe`, `getSnapshot`) and, inside the shim, a `useRef`, a `useMemo` with four deps that rebuilt the memoized selector whenever the (usually inline) selector changed identity, a `useEffect` copying the committed value into the ref, `useDebugValue`, and finally `useSyncExternalStore`: about seven hook slots and six allocations per render plus a passive effect React had to traverse on every commit. Measured in TanStack Router with 200 mounted ``s, a plain `useSyncExternalStore` plus a single ref cut retained heap by 8% (2738 -> 2512 KB) and re-render CPU by about 5% on renders that recompute the selection. `useSelector` now calls `useSyncExternalStore` from `use-sync-external-store/shim` directly. One `useRef` holds the last `{ selector, snapshot, selected }` record, mutated in place. `getSnapshot` reads `source.get()`; when the record's selector and snapshot are identical (`===`) it returns the stored selection, otherwise it runs the selector and, when `compare(previous, next)` holds, keeps the previous selection so `useSyncExternalStore` sees an unchanged value and skips the re-render. Keying the memo on the selector identity as well as the snapshot is what keeps a render that suspends with a different selector (pinned by the existing suspended-transition test) from poisoning the committed selector's selection. As in the with-selector shim, `compare` runs against the previous selection regardless of which selector produced it, which is what keeps inline selectors identity-stable across re-renders. The default identity selector is hoisted so `useSelector(atom)` hits the memo too. `subscribe` stays memoized on `[source]`: React re-subscribes in a passive effect whenever `subscribe` changes identity (its deps array is `[subscribe]`), so a per-render closure would tear down and recreate the store subscription on every render. `getSnapshot` is a plain closure: it has to read this render's `selector` and `compare`, which are usually inline and would defeat a `useCallback` anyway; React only compares its identity to decide whether to re-check the store after commit. The base shim is kept because the peer range still includes React 16.8 and 17, which have no native `useSyncExternalStore`; on React 18+ the shim delegates to the native hook. Only the `with-selector` entry is dropped, so that module leaves consumer bundles (react-store + shim, minified: 3385 -> 2808 B raw, 1510 -> 1331 B gzip). Public API and semantics are unchanged; all existing tests pass unmodified. New tests pin that a stable selector is not re-run on a re-render with an unchanged store value, that `compare` returning true keeps the previous selection identity without re-rendering, that a new selector is re-run and its selection returned, and that a store update re-runs the installed selector exactly once. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../react-store-use-selector-single-ref.md | 5 + packages/react-store/src/useSelector.ts | 86 ++++++++--- packages/react-store/tests/index.test.tsx | 138 ++++++++++++++++++ 3 files changed, 209 insertions(+), 20 deletions(-) create mode 100644 .changeset/react-store-use-selector-single-ref.md diff --git a/.changeset/react-store-use-selector-single-ref.md b/.changeset/react-store-use-selector-single-ref.md new file mode 100644 index 00000000..0b1470b1 --- /dev/null +++ b/.changeset/react-store-use-selector-single-ref.md @@ -0,0 +1,5 @@ +--- +'@tanstack/react-store': patch +--- + +`useSelector` now builds on `useSyncExternalStore` directly with a single memoized selection ref instead of the `use-sync-external-store/shim/with-selector` helper: fewer hook slots and allocations per subscribed component, no per-component passive effect, and the `with-selector` module leaves consumer bundles. Public API and semantics are unchanged. diff --git a/packages/react-store/src/useSelector.ts b/packages/react-store/src/useSelector.ts index 0f2593f0..d194c609 100644 --- a/packages/react-store/src/useSelector.ts +++ b/packages/react-store/src/useSelector.ts @@ -1,14 +1,10 @@ -import { useCallback } from 'react' -import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/shim/with-selector' +import { useCallback, useRef } from 'react' +import { useSyncExternalStore } from 'use-sync-external-store/shim' export interface UseSelectorOptions { compare?: (a: TSelected, b: TSelected) => boolean } -type SyncExternalStoreSubscribe = Parameters< - typeof useSyncExternalStoreWithSelector ->[0] - type SelectionSource = { get: () => T subscribe: (listener: (value: T) => void) => { @@ -16,6 +12,20 @@ type SelectionSource = { } } +/** + * The last selection computed for a component, keyed on the selector and the + * source snapshot that produced it. + */ +type Selection = { + selector: (snapshot: TSource) => TSelected + snapshot: TSource + selected: TSelected +} + +function identity(snapshot: TSource): TSelected { + return snapshot as unknown as TSelected +} + function defaultCompare(a: T, b: T) { return a === b } @@ -42,26 +52,62 @@ function defaultCompare(a: T, b: T) { */ export function useSelector>( source: SelectionSource, - selector: (snapshot: TSource) => TSelected = (s) => s as unknown as TSelected, + selector: (snapshot: TSource) => TSelected = identity, options?: UseSelectorOptions, ): TSelected { const compare = options?.compare ?? defaultCompare - const subscribe: SyncExternalStoreSubscribe = useCallback( - (handleStoreChange) => { - const { unsubscribe } = source.subscribe(handleStoreChange) - return unsubscribe - }, + // `useSyncExternalStore` re-subscribes in a passive effect whenever + // `subscribe` changes identity, so it is the one callback worth memoizing. + const subscribe = useCallback( + (onStoreChange: () => void) => source.subscribe(onStoreChange).unsubscribe, [source], ) - const getSnapshot = useCallback(() => source.get(), [source]) + const selectionRef = useRef | null>(null) - return useSyncExternalStoreWithSelector( - subscribe, - getSnapshot, - getSnapshot, - selector, - compare, - ) + // `getSnapshot` is a plain closure: it must read this render's `selector` and + // `compare`, which are usually inline and would defeat a `useCallback` + // anyway. React only compares it to decide whether to re-check the store + // after commit, so a fresh identity per render is cheap. + // + // The memo is keyed on the selector identity as well as the snapshot so that + // a render that suspends with a different selector cannot poison the + // selection of the committed selector, which stays subscribed meanwhile. + const getSnapshot = () => { + const snapshot = source.get() + const cached = selectionRef.current + + if ( + cached !== null && + cached.selector === selector && + cached.snapshot === snapshot + ) { + return cached.selected + } + + const selected = selector(snapshot) + + if (cached === null) { + selectionRef.current = { selector, snapshot, selected } + return selected + } + + cached.selector = selector + cached.snapshot = snapshot + + // Keep the previous selection's identity when `compare` considers the new + // one equal so that `useSyncExternalStore` does not re-render the component. + // Like `useSyncExternalStoreWithSelector`, this compares against the + // previous selection even when the selector identity changed: inline + // selectors are recreated on every render and must still return the same + // object when the selection is equal. + if (!compare(cached.selected, selected)) { + cached.selected = selected + } + + return cached.selected + } + + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot) } diff --git a/packages/react-store/tests/index.test.tsx b/packages/react-store/tests/index.test.tsx index 5f36579b..9fc0da76 100644 --- a/packages/react-store/tests/index.test.tsx +++ b/packages/react-store/tests/index.test.tsx @@ -637,6 +637,144 @@ describe('store hooks', () => { }) }) +describe('useSelector selection memo', () => { + type State = { a: number; b: number } + + it('does not re-run a stable selector when the component re-renders with an unchanged store value', () => { + const store = createStore({ a: 1, b: 2 }) + const selector = vi.fn((state: State) => state.a) + + function Comp({ label }: { label: string }) { + const value = useSelector(store, selector) + + return ( +

+ {label}: {value} +

+ ) + } + + const { getByText, rerender } = render() + + expect(getByText('First: 1')).toBeInTheDocument() + expect(selector).toHaveBeenCalledTimes(1) + + rerender() + + expect(getByText('Second: 1')).toBeInTheDocument() + expect(selector).toHaveBeenCalledTimes(1) + }) + + it('keeps the previous selection identity when compare returns true', () => { + const store = createStore({ items: [1, 2], other: 0 }) + const selections: Array<{ items: Array }> = [] + + function Comp() { + const selected = useSelector(store, (state) => ({ items: state.items }), { + compare: shallow, + }) + selections.push(selected) + + return

Items: {selected.items.join(',')}

+ } + + const { getByText, rerender } = render() + + expect(getByText('Items: 1,2')).toBeInTheDocument() + expect(selections).toHaveLength(1) + + // The selection is shallowly equal, so the component must not re-render. + act(() => { + store.setState((prev) => ({ ...prev, other: 1 })) + }) + + expect(selections).toHaveLength(1) + + // The inline selector has a new identity on every render; compare still + // runs against the previous selection so the component receives the same + // object. + rerender() + + expect(selections).toHaveLength(2) + expect(selections[1]).toBe(selections[0]) + + act(() => { + store.setState((prev) => ({ ...prev, items: [...prev.items, 3] })) + }) + + expect(getByText('Items: 1,2,3')).toBeInTheDocument() + expect(selections).toHaveLength(3) + expect(selections[2]).not.toBe(selections[0]) + }) + + it('re-runs a new selector and returns its selection', () => { + const store = createStore({ a: 1, b: 2 }) + const selectA = vi.fn((state: State) => state.a) + const selectB = vi.fn((state: State) => state.b) + + function Comp({ selector }: { selector: (state: State) => number }) { + const value = useSelector(store, selector) + + return

Value: {value}

+ } + + const { getByText, rerender } = render() + + expect(getByText('Value: 1')).toBeInTheDocument() + expect(selectA).toHaveBeenCalledTimes(1) + expect(selectB).not.toHaveBeenCalled() + + rerender() + + expect(getByText('Value: 2')).toBeInTheDocument() + expect(selectA).toHaveBeenCalledTimes(1) + expect(selectB).toHaveBeenCalledTimes(1) + + rerender() + + expect(getByText('Value: 1')).toBeInTheDocument() + expect(selectA).toHaveBeenCalledTimes(2) + expect(selectB).toHaveBeenCalledTimes(1) + }) + + it('re-runs the installed selector exactly once per store update', () => { + const store = createStore({ a: 1, b: 2 }) + const selector = vi.fn((state: State) => state.a) + const renderSpy = vi.fn() + + function Comp() { + const value = useSelector(store, selector) + renderSpy() + + return

Value: {value}

+ } + + const { getByText } = render() + + expect(getByText('Value: 1')).toBeInTheDocument() + expect(selector).toHaveBeenCalledTimes(1) + expect(renderSpy).toHaveBeenCalledTimes(1) + + act(() => { + store.setState((prev) => ({ ...prev, a: 10 })) + }) + + expect(getByText('Value: 10')).toBeInTheDocument() + expect(selector).toHaveBeenCalledTimes(2) + expect(renderSpy).toHaveBeenCalledTimes(2) + + // An update that leaves the selection unchanged still runs the selector once + // to find that out, but does not re-render. + act(() => { + store.setState((prev) => ({ ...prev, b: 20 })) + }) + + expect(getByText('Value: 10')).toBeInTheDocument() + expect(selector).toHaveBeenCalledTimes(3) + expect(renderSpy).toHaveBeenCalledTimes(2) + }) +}) + describe('useStore', () => { it('is a compatibility alias for useSelector', async () => { const store = createStore(0) From e8244f54d494724bc0e242be35c9b84b27220781 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:04:02 +0000 Subject: [PATCH 2/9] ci: apply automated fixes and generate docs --- docs/framework/react/reference/functions/useSelector.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/framework/react/reference/functions/useSelector.md b/docs/framework/react/reference/functions/useSelector.md index 4eb73ea7..c26c30ce 100644 --- a/docs/framework/react/reference/functions/useSelector.md +++ b/docs/framework/react/reference/functions/useSelector.md @@ -12,7 +12,7 @@ function useSelector( options?): TSelected; ``` -Defined in: [packages/react-store/src/useSelector.ts:43](https://github.com/TanStack/store/blob/main/packages/react-store/src/useSelector.ts#L43) +Defined in: [packages/react-store/src/useSelector.ts:53](https://github.com/TanStack/store/blob/main/packages/react-store/src/useSelector.ts#L53) Selects a slice of state from an atom or store and subscribes the component to that selection. From 2911179e49d6b4a88a0a6b6a04733ad7403f9c55 Mon Sep 17 00:00:00 2001 From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:15:42 +0200 Subject: [PATCH 3/9] feat(react-store)!: require React 18+, use the built-in useSyncExternalStore Review feedback on #362 asked to change the supported React versions rather than keep the `use-sync-external-store` shim around for React 16.8 and 17. The peer range is now `react` / `react-dom` `^18.0.0 || ^19.0.0`, so `useSelector` imports `useSyncExternalStore` from `react` and the `use-sync-external-store` dependency and its types are removed. The consumer bundle (react-store, minified, `react` and `@tanstack/store` external) goes from 3385 B raw / 1510 B gzip with both shim modules to 1347 B raw / 646 B gzip. Because dropping React 16/17 is breaking, the changeset is now `major` and the repo enters changesets pre mode with the `alpha` tag (`.changeset/pre.json`), so the release lands as `@tanstack/react-store@1.0.0-alpha.0`. `docs/installation.md` states the new minimum React version. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .changeset/pre.json | 21 +++++++++++++++++++ .../react-store-use-selector-single-ref.md | 6 ++++-- docs/installation.md | 2 +- packages/react-store/package.json | 8 +++---- packages/react-store/src/useSelector.ts | 11 +++++----- pnpm-lock.yaml | 16 -------------- 6 files changed, 34 insertions(+), 30 deletions(-) create mode 100644 .changeset/pre.json diff --git a/.changeset/pre.json b/.changeset/pre.json new file mode 100644 index 00000000..18aba404 --- /dev/null +++ b/.changeset/pre.json @@ -0,0 +1,21 @@ +{ + "mode": "pre", + "tag": "alpha", + "initialVersions": { + "@tanstack/store-example-svelte-atoms": "0.0.0", + "@tanstack/store-example-svelte-simple": "0.0.0", + "@tanstack/store-example-svelte-store-actions": "0.0.0", + "@tanstack/store-example-svelte-store-context": "0.0.0", + "@tanstack/store-example-svelte-stores": "0.0.0", + "@tanstack/angular-store": "0.11.1", + "@tanstack/lit-store": "0.14.1", + "@tanstack/octane-store": "0.12.2", + "@tanstack/preact-store": "0.13.2", + "@tanstack/react-store": "0.11.1", + "@tanstack/solid-store": "0.11.1", + "@tanstack/store": "0.11.1", + "@tanstack/svelte-store": "0.12.1", + "@tanstack/vue-store": "0.11.1" + }, + "changesets": [] +} diff --git a/.changeset/react-store-use-selector-single-ref.md b/.changeset/react-store-use-selector-single-ref.md index 0b1470b1..c10eed47 100644 --- a/.changeset/react-store-use-selector-single-ref.md +++ b/.changeset/react-store-use-selector-single-ref.md @@ -1,5 +1,7 @@ --- -'@tanstack/react-store': patch +'@tanstack/react-store': major --- -`useSelector` now builds on `useSyncExternalStore` directly with a single memoized selection ref instead of the `use-sync-external-store/shim/with-selector` helper: fewer hook slots and allocations per subscribed component, no per-component passive effect, and the `with-selector` module leaves consumer bundles. Public API and semantics are unchanged. +`@tanstack/react-store` now requires React 18 or newer (`peerDependencies` are `react` and `react-dom` `^18.0.0 || ^19.0.0`); support for React 16.8 and 17 has been dropped. + +`useSelector` builds on React's built-in `useSyncExternalStore` with a single memoized selection ref instead of the `use-sync-external-store/shim/with-selector` helper: fewer hook slots and allocations per subscribed component, no per-component passive effect, and the `use-sync-external-store` dependency is gone from consumer bundles. The public API and selection semantics of `useSelector`, `useAtom`, `_useStore` and `useStore` are unchanged. diff --git a/docs/installation.md b/docs/installation.md index bdbc29af..fd6d6257 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -11,7 +11,7 @@ You can install TanStack Store with any [NPM](https://npmjs.com) package manager npm install @tanstack/react-store ``` -TanStack Store is compatible with React v16.8+ and is currently only compatible with ReactDOM only. If you would like to contribute to the React Native adapter, please reach out to us on [Discord](https://tlinz.com/discord). +TanStack Store is compatible with React v18+ and is currently only compatible with ReactDOM only. If you would like to contribute to the React Native adapter, please reach out to us on [Discord](https://tlinz.com/discord). ## Preact diff --git a/packages/react-store/package.json b/packages/react-store/package.json index 4fdc9ad8..7e9b16ab 100644 --- a/packages/react-store/package.json +++ b/packages/react-store/package.json @@ -49,20 +49,18 @@ "src" ], "dependencies": { - "@tanstack/store": "workspace:*", - "use-sync-external-store": "^1.6.0" + "@tanstack/store": "workspace:*" }, "devDependencies": { "@testing-library/react": "^16.3.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "@types/use-sync-external-store": "^1.5.0", "@vitejs/plugin-react": "^6.0.1", "react": "^19.2.5", "react-dom": "^19.2.5" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" } } diff --git a/packages/react-store/src/useSelector.ts b/packages/react-store/src/useSelector.ts index d194c609..ad7de73b 100644 --- a/packages/react-store/src/useSelector.ts +++ b/packages/react-store/src/useSelector.ts @@ -1,5 +1,4 @@ -import { useCallback, useRef } from 'react' -import { useSyncExternalStore } from 'use-sync-external-store/shim' +import { useCallback, useRef, useSyncExternalStore } from 'react' export interface UseSelectorOptions { compare?: (a: TSelected, b: TSelected) => boolean @@ -98,10 +97,10 @@ export function useSelector>( // Keep the previous selection's identity when `compare` considers the new // one equal so that `useSyncExternalStore` does not re-render the component. - // Like `useSyncExternalStoreWithSelector`, this compares against the - // previous selection even when the selector identity changed: inline - // selectors are recreated on every render and must still return the same - // object when the selection is equal. + // Like the former `use-sync-external-store/shim/with-selector` helper, this + // compares against the previous selection even when the selector identity + // changed: inline selectors are recreated on every render and must still + // return the same object when the selection is equal. if (!compare(cached.selected, selected)) { cached.selected = selected } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 19aabd91..d7c6ec6c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1139,9 +1139,6 @@ importers: '@tanstack/store': specifier: workspace:* version: link:../store - use-sync-external-store: - specifier: ^1.6.0 - version: 1.6.0(react@19.2.5) devDependencies: '@testing-library/react': specifier: ^16.3.2 @@ -1152,9 +1149,6 @@ importers: '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.17) - '@types/use-sync-external-store': - specifier: ^1.5.0 - version: 1.5.0 '@vitejs/plugin-react': specifier: ^6.0.1 version: 6.0.1(vite@8.1.5(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.0)(yaml@2.9.0)) @@ -4490,8 +4484,6 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} - '@types/use-sync-external-store@1.5.0': - resolution: {integrity: sha512-5dyB8nLC/qogMrlCizZnYWQTA4lnb/v+It+sqNl5YnSRAPMlIqY/X0Xn+gZw8vOL+TgTTr28VEbn3uf8fUtAkw==} '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -8790,10 +8782,6 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - use-sync-external-store@1.6.0: - resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -12560,7 +12548,6 @@ snapshots: '@types/unist@3.0.3': {} - '@types/use-sync-external-store@1.5.0': {} '@types/ws@8.18.1': dependencies: @@ -17672,9 +17659,6 @@ snapshots: dependencies: punycode: 2.3.1 - use-sync-external-store@1.6.0(react@19.2.5): - dependencies: - react: 19.2.5 util-deprecate@1.0.2: {} From 1111b9721c73b9c47310e237995e53018a7f309b Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:22:00 +0000 Subject: [PATCH 4/9] ci: apply automated fixes and generate docs --- docs/framework/react/reference/functions/useSelector.md | 2 +- .../react/reference/interfaces/UseSelectorOptions.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/framework/react/reference/functions/useSelector.md b/docs/framework/react/reference/functions/useSelector.md index c26c30ce..11e179d2 100644 --- a/docs/framework/react/reference/functions/useSelector.md +++ b/docs/framework/react/reference/functions/useSelector.md @@ -12,7 +12,7 @@ function useSelector( options?): TSelected; ``` -Defined in: [packages/react-store/src/useSelector.ts:53](https://github.com/TanStack/store/blob/main/packages/react-store/src/useSelector.ts#L53) +Defined in: [packages/react-store/src/useSelector.ts:52](https://github.com/TanStack/store/blob/main/packages/react-store/src/useSelector.ts#L52) Selects a slice of state from an atom or store and subscribes the component to that selection. diff --git a/docs/framework/react/reference/interfaces/UseSelectorOptions.md b/docs/framework/react/reference/interfaces/UseSelectorOptions.md index efaa91df..dcf53f29 100644 --- a/docs/framework/react/reference/interfaces/UseSelectorOptions.md +++ b/docs/framework/react/reference/interfaces/UseSelectorOptions.md @@ -5,7 +5,7 @@ title: UseSelectorOptions # Interface: UseSelectorOptions\ -Defined in: [packages/react-store/src/useSelector.ts:4](https://github.com/TanStack/store/blob/main/packages/react-store/src/useSelector.ts#L4) +Defined in: [packages/react-store/src/useSelector.ts:3](https://github.com/TanStack/store/blob/main/packages/react-store/src/useSelector.ts#L3) ## Type Parameters @@ -21,7 +21,7 @@ Defined in: [packages/react-store/src/useSelector.ts:4](https://github.com/TanSt optional compare: (a, b) => boolean; ``` -Defined in: [packages/react-store/src/useSelector.ts:5](https://github.com/TanStack/store/blob/main/packages/react-store/src/useSelector.ts#L5) +Defined in: [packages/react-store/src/useSelector.ts:4](https://github.com/TanStack/store/blob/main/packages/react-store/src/useSelector.ts#L4) #### Parameters From c9e75aff7aa3022ca068be7ca3eaa06d2348ba28 Mon Sep 17 00:00:00 2001 From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:29:02 +0200 Subject: [PATCH 5/9] fix(react-store): call unsubscribe on the subscription object `useSelector` handed React the `unsubscribe` method detached from the subscription object, both before this PR (destructured) and in the rewrite. `SelectionSource` is structural, so a source whose `unsubscribe` relies on `this` (a class-based subscription, for example) satisfies the type but threw `TypeError` from React's effect cleanup and stayed subscribed. The cleanup is now a closure that calls `subscription.unsubscribe()`. Adds a regression test with a class-based subscription that fails with "Cannot set properties of undefined (setting 'closed')" on the previous code, and tidies the ReactDOM sentence in docs/installation.md. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/installation.md | 2 +- packages/react-store/src/useSelector.ts | 10 +++++- packages/react-store/tests/index.test.tsx | 41 +++++++++++++++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/docs/installation.md b/docs/installation.md index fd6d6257..816ef2fe 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -11,7 +11,7 @@ You can install TanStack Store with any [NPM](https://npmjs.com) package manager npm install @tanstack/react-store ``` -TanStack Store is compatible with React v18+ and is currently only compatible with ReactDOM only. If you would like to contribute to the React Native adapter, please reach out to us on [Discord](https://tlinz.com/discord). +TanStack Store is compatible with React v18+ and currently supports ReactDOM only. If you would like to contribute to the React Native adapter, please reach out to us on [Discord](https://tlinz.com/discord). ## Preact diff --git a/packages/react-store/src/useSelector.ts b/packages/react-store/src/useSelector.ts index ad7de73b..78c6ed04 100644 --- a/packages/react-store/src/useSelector.ts +++ b/packages/react-store/src/useSelector.ts @@ -58,8 +58,16 @@ export function useSelector>( // `useSyncExternalStore` re-subscribes in a passive effect whenever // `subscribe` changes identity, so it is the one callback worth memoizing. + // The cleanup calls `unsubscribe` on the subscription object so that sources + // whose `unsubscribe` relies on `this` keep working. const subscribe = useCallback( - (onStoreChange: () => void) => source.subscribe(onStoreChange).unsubscribe, + (onStoreChange: () => void) => { + const subscription = source.subscribe(onStoreChange) + + return () => { + subscription.unsubscribe() + } + }, [source], ) diff --git a/packages/react-store/tests/index.test.tsx b/packages/react-store/tests/index.test.tsx index 9fc0da76..1ef1e199 100644 --- a/packages/react-store/tests/index.test.tsx +++ b/packages/react-store/tests/index.test.tsx @@ -775,6 +775,47 @@ describe('useSelector selection memo', () => { }) }) +describe('useSelector subscription cleanup', () => { + it('unsubscribes through the subscription object so `this`-based sources clean up', () => { + const listeners = new Set<(value: number) => void>() + + class Subscription { + closed = false + + constructor(private readonly listener: (value: number) => void) {} + + // Throws if React calls it detached from the subscription object. + unsubscribe() { + this.closed = true + listeners.delete(this.listener) + } + } + + const source = { + get: () => 1, + subscribe: (listener: (value: number) => void) => { + listeners.add(listener) + return new Subscription(listener) + }, + } + + function Comp() { + const value = useSelector(source) + + return

Value: {value}

+ } + + const { getByText, unmount } = render() + + expect(getByText('Value: 1')).toBeInTheDocument() + expect(listeners.size).toBe(1) + + unmount() + + expect(listeners.size).toBe(0) + }) +}) + describe('useStore', () => { it('is a compatibility alias for useSelector', async () => { const store = createStore(0) From d617a5c19379cd87967118324bea3b4d4866dab2 Mon Sep 17 00:00:00 2001 From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com> Date: Sat, 12 Sep 2026 18:58:05 +0200 Subject: [PATCH 6/9] perf(react-store): keep useSelector callbacks in one ref, stable across renders `useSelector` still paid for three of its own hook slots per render (two `useCallback`s plus the store hook; React clones every hook object on each re-render and `useCallback` allocates the closure and deps array every time) and handed `useSyncExternalStore` a fresh `getSnapshot` each render. React compares `getSnapshot` by identity: whenever it changes it flags the fiber for passive effects, pushes an `updateStoreInstance` effect (plus a `bind`) and, in transitions, a store consistency check that calls `getSnapshot` again, even when nothing about the component changed. The hook now keeps a single instance in one `useRef`: the `subscribe` and `getSnapshot` callbacks together with the `source`, `selector` and `compare` they were built for. A new instance is only created when one of those inputs changes; `subscribe` is carried over unless the source changed, so React re-subscribes only then. Both closures capture their inputs instead of reading them from the ref, so a render that suspends with a different selector cannot change what the committed subscription selects (the suspended-transition test still passes). The selection record is shared by all instances of a component so inline selectors keep their identity-stable results, and it is now keyed on the compare function as well: after a compare-equal update the record advances its snapshot while keeping the previous selection, and a later render with a different `compare` used to hit that memo without ever consulting the new function. The with-selector shim keyed its memo on `isEqual`, so this restores parity; a new test pins it and fails on the previous commit. Measured with a throwaway vitest bench (production React 19.2.5, jsdom, 200 subscribed components, mean per operation): parent re-render with stable selectors and an unchanged store 0.164 -> 0.128 ms (-22%), inline selectors 0.161 -> 0.153 ms (-5%), store update re-rendering all 200 0.203 -> 0.188 ms (-8%), mount + unmount 0.795 -> 0.732 ms (-8%). A variant that kept `useCallback` for both callbacks was slower than the previous code, so the extra hook slot costs more than the skipped effect saves. The consumer bundle (react-store minified, react and @tanstack/store external) is 1347 -> 1660 B raw, 646 -> 740 B gzip for this, still down from 3385 / 1510 B on main. Tests: source switch moves the subscription and reads the new source; the compare function from the latest render is used. Docs: the installation page no longer claims ReactDOM-only support, React Native works as well. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/installation.md | 2 +- packages/react-store/src/useSelector.ts | 181 ++++++++++++++-------- packages/react-store/tests/index.test.tsx | 103 ++++++++++++ 3 files changed, 221 insertions(+), 65 deletions(-) diff --git a/docs/installation.md b/docs/installation.md index 816ef2fe..6db7a2c4 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -11,7 +11,7 @@ You can install TanStack Store with any [NPM](https://npmjs.com) package manager npm install @tanstack/react-store ``` -TanStack Store is compatible with React v18+ and currently supports ReactDOM only. If you would like to contribute to the React Native adapter, please reach out to us on [Discord](https://tlinz.com/discord). +TanStack Store is compatible with React v18+. ## Preact diff --git a/packages/react-store/src/useSelector.ts b/packages/react-store/src/useSelector.ts index 78c6ed04..11a354f7 100644 --- a/packages/react-store/src/useSelector.ts +++ b/packages/react-store/src/useSelector.ts @@ -1,4 +1,4 @@ -import { useCallback, useRef, useSyncExternalStore } from 'react' +import { useRef, useSyncExternalStore } from 'react' export interface UseSelectorOptions { compare?: (a: TSelected, b: TSelected) => boolean @@ -11,16 +11,35 @@ type SelectionSource = { } } +type Selector = (snapshot: TSource) => TSelected + +type Compare = (a: TSelected, b: TSelected) => boolean + /** - * The last selection computed for a component, keyed on the selector and the - * source snapshot that produced it. + * The last selection computed for a component, keyed on the selector, the + * compare function and the source snapshot that produced it. */ type Selection = { - selector: (snapshot: TSource) => TSelected + selector: Selector + compare: Compare snapshot: TSource selected: TSelected } +/** + * The callbacks handed to `useSyncExternalStore` together with the inputs they + * were built for. The selection record is mutated in place and carried over, + * so every instance of a component shares it. + */ +type Instance = { + source: SelectionSource + selector: Selector + compare: Compare + subscribe: (onStoreChange: () => void) => () => void + getSnapshot: () => TSelected + selection: Selection | null +} + function identity(snapshot: TSource): TSelected { return snapshot as unknown as TSelected } @@ -29,6 +48,75 @@ function defaultCompare(a: T, b: T) { return a === b } +function createInstance( + source: SelectionSource, + selector: Selector, + compare: Compare, + previous: Instance | null, +): Instance { + const instance: Instance = { + source, + selector, + compare, + selection: previous === null ? null : previous.selection, + // `useSyncExternalStore` re-subscribes whenever `subscribe` changes + // identity, so it is only replaced when the source changes. + subscribe: + previous?.source === source + ? previous.subscribe + : (onStoreChange) => { + const subscription = source.subscribe(onStoreChange) + + // Call `unsubscribe` on the subscription so sources that rely on + // `this` keep working. + return () => subscription.unsubscribe() + }, + // The closure captures its inputs instead of reading them from the ref so + // that a render which suspends with a different selector cannot change + // what the committed subscription selects. The shared selection record is + // keyed on the selector and compare identities for the same reason. + getSnapshot: () => { + const snapshot = source.get() + const cached = instance.selection + + if ( + cached !== null && + cached.selector === selector && + cached.compare === compare && + cached.snapshot === snapshot + ) { + return cached.selected + } + + const selected = selector(snapshot) + + if (cached === null) { + instance.selection = { selector, compare, snapshot, selected } + return selected + } + + cached.selector = selector + cached.compare = compare + cached.snapshot = snapshot + + // Keep the previous selection's identity when `compare` considers the + // new one equal so that `useSyncExternalStore` does not re-render the + // component. Like the former `use-sync-external-store/shim/with-selector` + // helper, this compares against the previous selection even when the + // selector identity changed: inline selectors are recreated on every + // render and must still return the same object when the selection is + // equal. + if (!compare(cached.selected, selected)) { + cached.selected = selected + } + + return cached.selected + }, + } + + return instance +} + /** * Selects a slice of state from an atom or store and subscribes the component * to that selection. @@ -51,70 +139,35 @@ function defaultCompare(a: T, b: T) { */ export function useSelector>( source: SelectionSource, - selector: (snapshot: TSource) => TSelected = identity, + selector: Selector = identity, options?: UseSelectorOptions, ): TSelected { const compare = options?.compare ?? defaultCompare - // `useSyncExternalStore` re-subscribes in a passive effect whenever - // `subscribe` changes identity, so it is the one callback worth memoizing. - // The cleanup calls `unsubscribe` on the subscription object so that sources - // whose `unsubscribe` relies on `this` keep working. - const subscribe = useCallback( - (onStoreChange: () => void) => { - const subscription = source.subscribe(onStoreChange) - - return () => { - subscription.unsubscribe() - } - }, - [source], - ) - - const selectionRef = useRef | null>(null) - - // `getSnapshot` is a plain closure: it must read this render's `selector` and - // `compare`, which are usually inline and would defeat a `useCallback` - // anyway. React only compares it to decide whether to re-check the store - // after commit, so a fresh identity per render is cheap. - // - // The memo is keyed on the selector identity as well as the snapshot so that - // a render that suspends with a different selector cannot poison the - // selection of the committed selector, which stays subscribed meanwhile. - const getSnapshot = () => { - const snapshot = source.get() - const cached = selectionRef.current - - if ( - cached !== null && - cached.selector === selector && - cached.snapshot === snapshot - ) { - return cached.selected - } - - const selected = selector(snapshot) - - if (cached === null) { - selectionRef.current = { selector, snapshot, selected } - return selected - } - - cached.selector = selector - cached.snapshot = snapshot - - // Keep the previous selection's identity when `compare` considers the new - // one equal so that `useSyncExternalStore` does not re-render the component. - // Like the former `use-sync-external-store/shim/with-selector` helper, this - // compares against the previous selection even when the selector identity - // changed: inline selectors are recreated on every render and must still - // return the same object when the selection is equal. - if (!compare(cached.selected, selected)) { - cached.selected = selected - } - - return cached.selected + // One ref instead of `useCallback`s: React schedules a passive effect and a + // consistency check whenever `getSnapshot` changes identity, so it is only + // rebuilt when its inputs change. With a stable selector, a re-render that + // leaves the store untouched costs no allocations and no effects. + const instanceRef = useRef | null>(null) + let instance = instanceRef.current + + if ( + instance === null || + instance.source !== source || + instance.selector !== selector || + instance.compare !== compare + ) { + instance = instanceRef.current = createInstance( + source, + selector, + compare, + instance, + ) } - return useSyncExternalStore(subscribe, getSnapshot, getSnapshot) + return useSyncExternalStore( + instance.subscribe, + instance.getSnapshot, + instance.getSnapshot, + ) } diff --git a/packages/react-store/tests/index.test.tsx b/packages/react-store/tests/index.test.tsx index 1ef1e199..5de37bb7 100644 --- a/packages/react-store/tests/index.test.tsx +++ b/packages/react-store/tests/index.test.tsx @@ -814,6 +814,109 @@ describe('useSelector subscription cleanup', () => { expect(listeners.size).toBe(0) }) + + it('moves the subscription when the source changes', () => { + function createSource(initial: number) { + const listeners = new Set<(value: number) => void>() + let value = initial + + return { + listeners, + get: () => value, + set: (next: number) => { + value = next + listeners.forEach((listener) => listener(next)) + }, + subscribe: (listener: (value: number) => void) => { + listeners.add(listener) + return { + unsubscribe: () => { + listeners.delete(listener) + }, + } + }, + } + } + + const first = createSource(1) + const second = createSource(10) + + function Comp({ source }: { source: typeof first }) { + const value = useSelector(source) + + return

Value: {value}

+ } + + const { getByText, rerender } = render() + + expect(getByText('Value: 1')).toBeInTheDocument() + expect(first.listeners.size).toBe(1) + + rerender() + + expect(getByText('Value: 10')).toBeInTheDocument() + expect(first.listeners.size).toBe(0) + expect(second.listeners.size).toBe(1) + + act(() => { + second.set(20) + }) + + expect(getByText('Value: 20')).toBeInTheDocument() + }) +}) + +describe('useSelector compare changes', () => { + it('uses the compare function from the latest render', () => { + type State = { a: number; b: number } + const store = createStore({ a: 0, b: 0 }) + const compareA = (x: State, y: State) => x.a === y.a + const compareB = (x: State, y: State) => x.b === y.b + const renderSpy = vi.fn() + + function Comp({ compare }: { compare: typeof compareA }) { + const value = useSelector(store, undefined, { compare }) + renderSpy() + + return ( +

+ a{value.a} b{value.b} +

+ ) + } + + const { getByText, rerender } = render() + + expect(getByText('a0 b0')).toBeInTheDocument() + + // compareA ignores `b`, so this update does not re-render. + act(() => { + store.setState((prev) => ({ ...prev, b: 1 })) + }) + + expect(getByText('a0 b0')).toBeInTheDocument() + expect(renderSpy).toHaveBeenCalledTimes(1) + + rerender() + + expect(getByText('a0 b1')).toBeInTheDocument() + expect(renderSpy).toHaveBeenCalledTimes(2) + + // compareB ignores `a`, so this update does not re-render. + act(() => { + store.setState((prev) => ({ ...prev, a: 1 })) + }) + + expect(getByText('a0 b1')).toBeInTheDocument() + expect(renderSpy).toHaveBeenCalledTimes(2) + + act(() => { + store.setState((prev) => ({ ...prev, b: 2 })) + }) + + expect(getByText('a1 b2')).toBeInTheDocument() + expect(renderSpy).toHaveBeenCalledTimes(3) + }) }) describe('useStore', () => { From 57cd44021c1ed5c94f08ea2e700a4ca3f1851668 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:59:05 +0000 Subject: [PATCH 7/9] ci: apply automated fixes and generate docs --- docs/framework/react/reference/functions/useSelector.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/framework/react/reference/functions/useSelector.md b/docs/framework/react/reference/functions/useSelector.md index 11e179d2..70e0b232 100644 --- a/docs/framework/react/reference/functions/useSelector.md +++ b/docs/framework/react/reference/functions/useSelector.md @@ -12,7 +12,7 @@ function useSelector( options?): TSelected; ``` -Defined in: [packages/react-store/src/useSelector.ts:52](https://github.com/TanStack/store/blob/main/packages/react-store/src/useSelector.ts#L52) +Defined in: [packages/react-store/src/useSelector.ts:140](https://github.com/TanStack/store/blob/main/packages/react-store/src/useSelector.ts#L140) Selects a slice of state from an atom or store and subscribes the component to that selection. @@ -41,7 +41,7 @@ Omit the selector to subscribe to the whole value. ### selector -(`snapshot`) => `TSelected` +`Selector`\<`TSource`, `TSelected`\> = `identity` ### options? From bde2e0e976391584533d6d0eb98c8b933d624341 Mon Sep 17 00:00:00 2001 From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com> Date: Sat, 12 Sep 2026 19:12:24 +0200 Subject: [PATCH 8/9] perf(react-store): key the useSelector memo on the getSnapshot closure Flatten useSelector's per-component state into one object that is mutated in place instead of an instance that was re-created on every input change plus a nested selection record, and key the memoized selection on the `getSnapshot` closure that computed it. That closure already captures the source, selector and compare it was built for, so the memo hit is two identity checks (owner, snapshot) instead of four, the record needs no selector/compare fields, and the three factory functions and the `previous` plumbing go away. Per render this removes one object allocation for inline selectors (only the closure is created now) and the nested record indirection from every `getSnapshot` call; a mount allocates one object instead of two. The stable path is unchanged: one ref, three comparisons, no allocations, no effects. A whole-render bench with 200 components cannot separate this from the previous commit (the hook is now a small fraction of React's per-component work), so the gain is by operation count. useSelector minified: 812 -> 595 B raw, 400 -> 343 B gzip. Consumer bundle (react-store minified, react and @tanstack/store external): 1660 -> 1443 B raw, 740 -> 676 B gzip; main ships 3385 / 1510 B. Dropping the owner check makes the suspended-transition, selector-switch and compare-change tests fail, so the key stays pinned. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- packages/react-store/src/useSelector.ts | 178 +++++++++--------------- 1 file changed, 69 insertions(+), 109 deletions(-) diff --git a/packages/react-store/src/useSelector.ts b/packages/react-store/src/useSelector.ts index 11a354f7..6bf5e4c9 100644 --- a/packages/react-store/src/useSelector.ts +++ b/packages/react-store/src/useSelector.ts @@ -11,33 +11,20 @@ type SelectionSource = { } } -type Selector = (snapshot: TSource) => TSelected - -type Compare = (a: TSelected, b: TSelected) => boolean - /** - * The last selection computed for a component, keyed on the selector, the - * compare function and the source snapshot that produced it. - */ -type Selection = { - selector: Selector - compare: Compare - snapshot: TSource - selected: TSelected -} - -/** - * The callbacks handed to `useSyncExternalStore` together with the inputs they - * were built for. The selection record is mutated in place and carried over, - * so every instance of a component shares it. + * Per-component state, mutated in place. The inputs and the callbacks built + * for them are written during render; the selection is written by whichever + * `getSnapshot` closure computed it last and is keyed on that closure. */ type Instance = { - source: SelectionSource - selector: Selector - compare: Compare - subscribe: (onStoreChange: () => void) => () => void - getSnapshot: () => TSelected - selection: Selection | null + source?: SelectionSource + selector?: (snapshot: TSource) => TSelected + compare?: (a: TSelected, b: TSelected) => boolean + subscribe?: (onStoreChange: () => void) => () => void + getSnapshot?: () => TSelected + owner: (() => TSelected) | null + snapshot?: TSource + selected?: TSelected } function identity(snapshot: TSource): TSelected { @@ -48,75 +35,6 @@ function defaultCompare(a: T, b: T) { return a === b } -function createInstance( - source: SelectionSource, - selector: Selector, - compare: Compare, - previous: Instance | null, -): Instance { - const instance: Instance = { - source, - selector, - compare, - selection: previous === null ? null : previous.selection, - // `useSyncExternalStore` re-subscribes whenever `subscribe` changes - // identity, so it is only replaced when the source changes. - subscribe: - previous?.source === source - ? previous.subscribe - : (onStoreChange) => { - const subscription = source.subscribe(onStoreChange) - - // Call `unsubscribe` on the subscription so sources that rely on - // `this` keep working. - return () => subscription.unsubscribe() - }, - // The closure captures its inputs instead of reading them from the ref so - // that a render which suspends with a different selector cannot change - // what the committed subscription selects. The shared selection record is - // keyed on the selector and compare identities for the same reason. - getSnapshot: () => { - const snapshot = source.get() - const cached = instance.selection - - if ( - cached !== null && - cached.selector === selector && - cached.compare === compare && - cached.snapshot === snapshot - ) { - return cached.selected - } - - const selected = selector(snapshot) - - if (cached === null) { - instance.selection = { selector, compare, snapshot, selected } - return selected - } - - cached.selector = selector - cached.compare = compare - cached.snapshot = snapshot - - // Keep the previous selection's identity when `compare` considers the - // new one equal so that `useSyncExternalStore` does not re-render the - // component. Like the former `use-sync-external-store/shim/with-selector` - // helper, this compares against the previous selection even when the - // selector identity changed: inline selectors are recreated on every - // render and must still return the same object when the selection is - // equal. - if (!compare(cached.selected, selected)) { - cached.selected = selected - } - - return cached.selected - }, - } - - return instance -} - /** * Selects a slice of state from an atom or store and subscribes the component * to that selection. @@ -139,35 +57,77 @@ function createInstance( */ export function useSelector>( source: SelectionSource, - selector: Selector = identity, + selector: (snapshot: TSource) => TSelected = identity, options?: UseSelectorOptions, ): TSelected { const compare = options?.compare ?? defaultCompare - // One ref instead of `useCallback`s: React schedules a passive effect and a - // consistency check whenever `getSnapshot` changes identity, so it is only - // rebuilt when its inputs change. With a stable selector, a re-render that - // leaves the store untouched costs no allocations and no effects. + // One ref instead of `useCallback`s. `useSyncExternalStore` re-subscribes + // whenever `subscribe` changes identity and schedules a passive effect plus + // a consistency check whenever `getSnapshot` does, so both are only rebuilt + // when their inputs change. With a stable selector, a re-render that leaves + // the store untouched costs no allocations and no effects. const instanceRef = useRef | null>(null) - let instance = instanceRef.current + const instance = + instanceRef.current ?? (instanceRef.current = { owner: null }) + const sourceChanged = instance.source !== source + + if (sourceChanged) { + instance.subscribe = (onStoreChange) => { + const subscription = source.subscribe(onStoreChange) + + // Call `unsubscribe` on the subscription so sources that rely on `this` + // keep working. + return () => subscription.unsubscribe() + } + } if ( - instance === null || - instance.source !== source || + sourceChanged || instance.selector !== selector || instance.compare !== compare ) { - instance = instanceRef.current = createInstance( - source, - selector, - compare, - instance, - ) + instance.source = source + instance.selector = selector + instance.compare = compare + + // The closure captures its inputs instead of reading them from the + // instance so that a render which suspends with a different selector + // cannot change what the committed subscription selects. The selection is + // keyed on the closure for the same reason. + const getSnapshot = () => { + const snapshot = source.get() + + if (instance.owner !== getSnapshot || instance.snapshot !== snapshot) { + const selected = selector(snapshot) + + // Keep the previous selection's identity when `compare` considers the + // new one equal so that `useSyncExternalStore` does not re-render the + // component. Like the former `use-sync-external-store/shim/with-selector` + // helper, this compares against the previous selection even when the + // selector identity changed: inline selectors are recreated on every + // render and must still return the same object when the selection is + // equal. + if ( + instance.owner === null || + !compare(instance.selected as TSelected, selected) + ) { + instance.selected = selected + } + + instance.owner = getSnapshot + instance.snapshot = snapshot + } + + return instance.selected as TSelected + } + + instance.getSnapshot = getSnapshot } return useSyncExternalStore( - instance.subscribe, - instance.getSnapshot, + instance.subscribe!, + instance.getSnapshot!, instance.getSnapshot, ) } From c37621daa1b8802b97f41d99675d280c585e5e21 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 12 Sep 2026 17:13:24 +0000 Subject: [PATCH 9/9] ci: apply automated fixes and generate docs --- docs/framework/react/reference/functions/useSelector.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/framework/react/reference/functions/useSelector.md b/docs/framework/react/reference/functions/useSelector.md index 70e0b232..5ffb124d 100644 --- a/docs/framework/react/reference/functions/useSelector.md +++ b/docs/framework/react/reference/functions/useSelector.md @@ -12,7 +12,7 @@ function useSelector( options?): TSelected; ``` -Defined in: [packages/react-store/src/useSelector.ts:140](https://github.com/TanStack/store/blob/main/packages/react-store/src/useSelector.ts#L140) +Defined in: [packages/react-store/src/useSelector.ts:58](https://github.com/TanStack/store/blob/main/packages/react-store/src/useSelector.ts#L58) Selects a slice of state from an atom or store and subscribes the component to that selection. @@ -41,7 +41,7 @@ Omit the selector to subscribe to the whole value. ### selector -`Selector`\<`TSource`, `TSelected`\> = `identity` +(`snapshot`) => `TSelected` ### options?