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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/pre.json
Original file line number Diff line number Diff line change
@@ -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": []
}
7 changes: 7 additions & 0 deletions .changeset/react-store-use-selector-single-ref.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion docs/framework/react/reference/functions/useSelector.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ function useSelector<TSource, TSelected>(
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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ title: UseSelectorOptions

# Interface: UseSelectorOptions\<TSelected\>

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

Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 3 additions & 5 deletions packages/react-store/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
108 changes: 87 additions & 21 deletions packages/react-store/src/useSelector.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,36 @@
import { useCallback } from 'react'
import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/shim/with-selector'
import { useRef, useSyncExternalStore } from 'react'

export interface UseSelectorOptions<TSelected> {
compare?: (a: TSelected, b: TSelected) => boolean
}

type SyncExternalStoreSubscribe = Parameters<
typeof useSyncExternalStoreWithSelector
>[0]

type SelectionSource<T> = {
get: () => T
subscribe: (listener: (value: T) => void) => {
unsubscribe: () => void
}
}

/**
* 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<TSource, TSelected> = {
source?: SelectionSource<TSource>
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<TSource, TSelected>(snapshot: TSource): TSelected {
return snapshot as unknown as TSelected
}

function defaultCompare<T>(a: T, b: T) {
return a === b
}
Expand All @@ -42,26 +57,77 @@ function defaultCompare<T>(a: T, b: T) {
*/
export function useSelector<TSource, TSelected = NoInfer<TSource>>(
source: SelectionSource<TSource>,
selector: (snapshot: TSource) => TSelected = (s) => s as unknown as TSelected,
selector: (snapshot: TSource) => TSelected = identity,
options?: UseSelectorOptions<TSelected>,
): 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<Instance<TSource, TSelected> | 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,
)
}
Loading
Loading