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 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 4eb73ea7..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: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 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 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 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 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 ( +

+ {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('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) + }) + + 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', () => { it('is a compatibility alias for useSelector', async () => { const store = createStore(0) 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: {}