From 0918237715da01a1b649b5b8daf4c5eb1c003ea2 Mon Sep 17 00:00:00 2001
From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com>
Date: Sat, 10 Oct 2026 01:42:03 +0200
Subject: [PATCH 1/8] feat(react-store)!: build useSelector on React's
useSyncExternalStore with one selection ref, require React 18+ (#362)
* 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>
* ci: apply automated fixes and generate docs
* 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>
* ci: apply automated fixes and generate docs
* 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>
* 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>
* ci: apply automated fixes and generate docs
* 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>
* ci: apply automated fixes and generate docs
* ci: apply automated fixes and generate docs
* chore: update changsets file
---------
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Corbin Crutchley
---
.changeset/pre.json | 16 +
.../react-store-use-selector-single-ref.md | 7 +
.../react/reference/functions/useSelector.md | 2 +-
.../interfaces/UseSelectorOptions.md | 4 +-
docs/installation.md | 2 +-
packages/react-store/package.json | 8 +-
packages/react-store/src/useSelector.ts | 108 +++++--
packages/react-store/tests/index.test.tsx | 282 ++++++++++++++++++
pnpm-lock.yaml | 16 -
9 files changed, 399 insertions(+), 46 deletions(-)
create mode 100644 .changeset/pre.json
create mode 100644 .changeset/react-store-use-selector-single-ref.md
diff --git a/.changeset/pre.json b/.changeset/pre.json
new file mode 100644
index 00000000..ad703a0b
--- /dev/null
+++ b/.changeset/pre.json
@@ -0,0 +1,16 @@
+{
+ "mode": "pre",
+ "tag": "alpha",
+ "initialVersions": {
+ "@tanstack/angular-store": "0.11.2",
+ "@tanstack/lit-store": "0.14.2",
+ "@tanstack/octane-store": "0.13.0",
+ "@tanstack/preact-store": "0.13.5",
+ "@tanstack/react-store": "0.11.2",
+ "@tanstack/solid-store": "0.11.2",
+ "@tanstack/store": "0.11.2",
+ "@tanstack/svelte-store": "0.12.3",
+ "@tanstack/vue-store": "0.11.2"
+ },
+ "changesets": []
+}
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..c10eed47
--- /dev/null
+++ b/.changeset/react-store-use-selector-single-ref.md
@@ -0,0 +1,7 @@
+---
+'@tanstack/react-store': major
+---
+
+`@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/framework/react/reference/functions/useSelector.md b/docs/framework/react/reference/functions/useSelector.md
index 1ea0f378..4ea95cb5 100644
--- a/docs/framework/react/reference/functions/useSelector.md
+++ b/docs/framework/react/reference/functions/useSelector.md
@@ -10,7 +10,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: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.
diff --git a/docs/framework/react/reference/interfaces/UseSelectorOptions.md b/docs/framework/react/reference/interfaces/UseSelectorOptions.md
index 67e1e1e6..dfcd3401 100644
--- a/docs/framework/react/reference/interfaces/UseSelectorOptions.md
+++ b/docs/framework/react/reference/interfaces/UseSelectorOptions.md
@@ -3,7 +3,7 @@ id: UseSelectorOptions
title: 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
@@ -19,7 +19,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
diff --git a/docs/installation.md b/docs/installation.md
index bdbc29af..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 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+.
## Preact
diff --git a/packages/react-store/package.json b/packages/react-store/package.json
index a93c2c77..6c0b13fb 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 0f2593f0..6bf5e4c9 100644
--- a/packages/react-store/src/useSelector.ts
+++ b/packages/react-store/src/useSelector.ts
@@ -1,14 +1,9 @@
-import { useCallback } from 'react'
-import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/shim/with-selector'
+import { useRef, useSyncExternalStore } from 'react'
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 +11,26 @@ type SelectionSource = {
}
}
+/**
+ * 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?: (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 {
+ return snapshot as unknown as TSelected
+}
+
function defaultCompare(a: T, b: T) {
return a === b
}
@@ -42,26 +57,77 @@ 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
- },
- [source],
- )
+ // 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)
+ const instance =
+ instanceRef.current ?? (instanceRef.current = { owner: null })
+ const sourceChanged = instance.source !== source
+
+ if (sourceChanged) {
+ instance.subscribe = (onStoreChange) => {
+ const subscription = source.subscribe(onStoreChange)
- const getSnapshot = useCallback(() => source.get(), [source])
+ // Call `unsubscribe` on the subscription so sources that rely on `this`
+ // keep working.
+ return () => subscription.unsubscribe()
+ }
+ }
+
+ if (
+ sourceChanged ||
+ instance.selector !== selector ||
+ instance.compare !== compare
+ ) {
+ 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 useSyncExternalStoreWithSelector(
- subscribe,
- getSnapshot,
- getSnapshot,
- selector,
- compare,
+ 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 5f36579b..5de37bb7 100644
--- a/packages/react-store/tests/index.test.tsx
+++ b/packages/react-store/tests/index.test.tsx
@@ -637,6 +637,288 @@ 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 (
+