From ce35abcfcb2e6d43c1b0959e629ea25510e45446 Mon Sep 17 00:00:00 2001 From: nadia Date: Fri, 22 May 2026 13:43:24 +0200 Subject: [PATCH 1/3] feat: implement useSelector --- README.md | 60 ++- index.d.ts | 58 +-- index.js | 36 +- oxfmt.config.ts | 4 +- package.json | 15 +- pnpm-lock.yaml | 21 ++ test/errors.ts | 1 + test/use-selector.test.ts | 422 ++++++++++++++++++++++ test/{index.test.ts => use-store.test.ts} | 2 +- use-selector/index.d.ts | 37 ++ use-selector/index.js | 37 ++ use-store/index.d.ts | 56 +++ use-store/index.js | 34 ++ 13 files changed, 688 insertions(+), 95 deletions(-) create mode 100644 test/use-selector.test.ts rename test/{index.test.ts => use-store.test.ts} (99%) create mode 100644 use-selector/index.d.ts create mode 100644 use-selector/index.js create mode 100644 use-store/index.d.ts create mode 100644 use-store/index.js diff --git a/README.md b/README.md index 345baf5..9b2920c 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,6 @@ export const Header = ({ postId }) => { --- - ## Options ### Keys @@ -85,3 +84,62 @@ export const Header = () => { A function set on `ssr` is provided to React's [`useSyncExternalStore`](https://react.dev/reference/react/useSyncExternalStore) as the `getServerSnapshot` option. + +### Selector + +In some cases using selector store might be the preferred way of subscribing to store changes. In that case you can use `useSelector` hook. + +```tsx +import { useStore, useSelector } from '@nanostores/react' +import { $profiles } from '../stores/profiles.js' + +export const HeaderWithKeys = ({ userId }) => { + const profiles = useStore($profiles, { keys: [userId, 'name'] }) + return
{profiles[userId].name.toUpperCase()}
+} + +export const HeaderWithSelector = ({ userId }) => { + // selector function automatically handles keys changes + const name = useSelector($profiles, profiles[userId].name.toUppercase()) + return
{name}
+} +``` + +### Compare + +You can pass a different comparison function to `useSelector`, to determine when the data should trigger a re-render. + +```tsx +import { useSelector } from '@nanostores/react' +import { $user } from '../stores/user.js' +import { shallowCompare } from '../utils/shallow.js' + +export const Posts = () => { + const ids = useSelector( + $user, + user => user.postIds, // [1, 2, 3] + { compare: shallowCompare } + ) + + return ( +
+ Post ids: {ids.join(', ')} + {/* does not trigger the re-render because ids are shallowly equal */} + +
+ ) +} +``` + +### SSR with useSelector + +```tsx +export const Header = () => { + const name = useSelector($profile, profile => profile.name, { + ssr: 'initial' + }) + return
{name}
+} +``` diff --git a/index.d.ts b/index.d.ts index da56581..1300c9d 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,56 +1,2 @@ -import type { Store, StoreValue } from 'nanostores' -import type { DependencyList } from 'react' - -type StoreKeys = T extends { setKey: (k: infer K, v: any) => unknown } - ? K - : never - -export interface UseStoreOptions { - /** - * @default - * ```ts - * [store, options.keys] - * ``` - */ - deps?: DependencyList - - /** - * Will re-render components only on specific key changes. - */ - keys?: StoreKeys[] - - /** - * Enable SSR support. Set `initial` when store's initial value is the same on - * server and client, or provide a function to return the server store state - * for advanced cases (per useSyncExternalStore's getServerSnapshot). - */ - ssr?: (() => StoreValue) | 'initial' | false -} - -/** - * Subscribe to store changes and get store’s value. - * - * Can be user with store builder too. - * - * ```js - * import { useStore } from 'nanostores/react' - * - * import { router } from '../store/router' - * - * export const Layout = () => { - * let page = useStore(router) - * if (page.route === 'home') { - * return - * } else { - * return - * } - * } - * ``` - * - * @param store Store instance. - * @returns Store value. - */ -export function useStore( - store: SomeStore, - options?: UseStoreOptions -): StoreValue +export { UseStoreOptions, useStore } from './use-store/index.d.ts' +export { UseSelectorOptions, useSelector } from './use-selector/index.d.ts' diff --git a/index.js b/index.js index 652a73a..e2c557f 100644 --- a/index.js +++ b/index.js @@ -1,34 +1,2 @@ -import { listenKeys } from 'nanostores' -import { useCallback, useRef, useSyncExternalStore } from 'react' - -let emit = (snapshotRef, onChange) => value => { - if (snapshotRef.current === value) return - snapshotRef.current = value - onChange() -} - -export function test() { - return 1 -} - -export function useStore(store, { keys, deps = [store, keys], ssr } = {}) { - let snapshotRef = useRef() - snapshotRef.current = store.get() - - let subscribe = useCallback(onChange => { - emit(snapshotRef, onChange)(store.value) - - return keys?.length > 0 - ? listenKeys(store, keys, emit(snapshotRef, onChange)) - : store.listen(emit(snapshotRef, onChange)) - }, deps) - - let get = () => snapshotRef.current - - let server = get - if (ssr && 'init' in store) { - server = ssr === 'initial' ? () => store.init : ssr - } - - return useSyncExternalStore(subscribe, get, server) -} +export { useStore } from './use-store/index.js' +export { useSelector } from './use-selector/index.js' diff --git a/oxfmt.config.ts b/oxfmt.config.ts index 3258050..255d864 100644 --- a/oxfmt.config.ts +++ b/oxfmt.config.ts @@ -1,3 +1,3 @@ -import loguxOxfmtConfig from "@logux/oxc-configs/fmt"; +import loguxOxfmtConfig from '@logux/oxc-configs/fmt' -export default loguxOxfmtConfig; +export default loguxOxfmtConfig diff --git a/package.json b/package.json index ae46c16..6aad6ca 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,10 @@ "test:size": "size-limit", "test": "pnpm run /^test:/" }, + "dependencies": { + "@types/use-sync-external-store": "^1.5.0", + "use-sync-external-store": "^1.6.0" + }, "devDependencies": { "@logux/oxc-configs": "^0.2.2", "@size-limit/preset-small-lib": "^12.0.1", @@ -61,11 +65,20 @@ }, "size-limit": [ { + "name": "min", "import": { "index.js": "{ useStore }", "nanostores": "{ map, computed }" }, - "limit": "958 B" + "limit": "954 B" + }, + { + "name": "max", + "import": { + "index.js": "{ useStore, useSelector }", + "nanostores": "{ map, computed }" + }, + "limit": "2004 B" } ], "engines": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5942378..b2fb6cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,13 @@ settings: importers: .: + dependencies: + '@types/use-sync-external-store': + specifier: ^1.5.0 + version: 1.5.0 + use-sync-external-store: + specifier: ^1.6.0 + version: 1.6.0(react@19.2.4) devDependencies: '@logux/oxc-configs': specifier: ^0.2.2 @@ -452,6 +459,9 @@ packages: '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/use-sync-external-store@1.5.0': + resolution: {integrity: sha512-5dyB8nLC/qogMrlCizZnYWQTA4lnb/v+It+sqNl5YnSRAPMlIqY/X0Xn+gZw8vOL+TgTTr28VEbn3uf8fUtAkw==} + '@types/whatwg-mimetype@3.0.2': resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} @@ -692,6 +702,11 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + 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 + whatwg-mimetype@3.0.0: resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} engines: {node: '>=12'} @@ -936,6 +951,8 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/use-sync-external-store@1.5.0': {} + '@types/whatwg-mimetype@3.0.2': {} '@types/ws@8.18.1': @@ -1181,6 +1198,10 @@ snapshots: undici-types@7.18.2: {} + use-sync-external-store@1.6.0(react@19.2.4): + dependencies: + react: 19.2.4 + whatwg-mimetype@3.0.0: {} ws@8.20.0: {} diff --git a/test/errors.ts b/test/errors.ts index 7a07746..8b803cd 100644 --- a/test/errors.ts +++ b/test/errors.ts @@ -1,4 +1,5 @@ import { map, WritableAtom } from 'nanostores' + import { useStore } from '../index.js' type TestType = diff --git a/test/use-selector.test.ts b/test/use-selector.test.ts new file mode 100644 index 0000000..490fb5b --- /dev/null +++ b/test/use-selector.test.ts @@ -0,0 +1,422 @@ +import './setup.js' + +import { act, render, screen } from '@testing-library/react' +import { delay } from 'nanodelay' +import { atom, computed, map } from 'nanostores' +import { equal, deepStrictEqual } from 'node:assert' +import { afterEach, test } from 'node:test' +import type { FC } from 'react' +import React from 'react' +import { hydrateRoot } from 'react-dom/client' +import { renderToString } from 'react-dom/server' + +import { useSelector } from '../index.js' + +let { createElement: h, useState } = React + +afterEach(() => { + window.document.head.innerHTML = '' + window.document.body.innerHTML = '
' +}) + +test('renders a simple store', () => { + let $store = map({ select: 0, ignored: 1 }) + + let Test: FC = () => { + let select = useSelector($store, v => v.select) + return h('p', { 'data-testid': 'text' }, `Select: ${select}`) + } + + render(h(Test)) + equal(screen.getByTestId('text').textContent, 'Select: 0') +}) + +test('only triggers a re-render when selector state is updated', async () => { + let renders = 0 + let $store = map({ select: 0, ignored: 1 }) + + let Test: FC = () => { + renders += 1 + let select = useSelector($store, v => v.select) + + return h( + 'div', + {}, + h('p', { 'data-testid': 'select' }, `Select: ${select}`), + h('button', { + 'data-testid': 'btn-select', + 'onClick': () => { + $store.set({ ...$store.get(), select: 10 }) + } + }), + h('button', { + 'data-testid': 'btn-ignored', + 'onClick': () => { + $store.set({ ...$store.get(), ignored: 10 }) + } + }) + ) + } + + render(h(Test)) + equal(screen.getByTestId('select').textContent, 'Select: 0') + equal(renders, 1) + + await act(async () => { + screen.getByTestId('btn-select').click() + await delay(1) + }) + + equal(screen.getByTestId('select').textContent, 'Select: 10') + equal(renders, 2) + + await act(async () => { + screen.getByTestId('btn-ignored').click() + await delay(1) + }) + + equal(renders, 2) +}) + +test('allows specifying custom equality function', async () => { + let renders = 0 + let $store = atom({ + array: [ + { select: 0, ignore: 1 }, + { select: 0, ignore: 1 } + ] + }) + + function deepEqual(objA: unknown, objB: unknown): boolean { + return JSON.stringify(objA) === JSON.stringify(objB) + } + + let Test: FC = () => { + renders += 1 + let store = useSelector( + $store, + state => state.array.map(v => ({ select: v.select })), + { compare: deepEqual } + ) + + let value = store + .map(item => item.select) + .reduce((total, num) => total + num, 0) + + return h( + 'div', + {}, + h('p', { 'data-testid': 'select' }, `Select: ${value}`), + h('button', { + 'data-testid': 'btn-select', + 'onClick': () => { + $store.set({ + array: $store.get().array.map(item => ({ + ...item, + select: item.select + 5 + })) + }) + } + }), + h('button', { + 'data-testid': 'btn-ignored', + 'onClick': () => { + $store.set({ + array: $store.get().array.map(item => ({ + ...item, + ignore: item.ignore + 2 + })) + }) + } + }) + ) + } + + render(h(Test)) + equal(screen.getByTestId('select').textContent, 'Select: 0') + equal(renders, 1) + + await act(async () => { + screen.getByTestId('btn-select').click() + await delay(1) + }) + + equal(screen.getByTestId('select').textContent, 'Select: 10') + equal(renders, 2) + + await act(async () => { + screen.getByTestId('btn-ignored').click() + await delay(1) + }) + + equal(renders, 2) +}) + +test('works with computed stores', async () => { + let $store = atom(0) + let $computed = computed($store, store => ({ val: store * 2 })) + + let Test: FC = () => { + let val = useSelector($computed, state => state.val) + + return h( + 'div', + {}, + h('p', { 'data-testid': 'computed' }, `Computed: ${val}`), + h('button', { + 'data-testid': 'btn-change', + 'onClick': () => { + $store.set($store.get() + 1) + } + }) + ) + } + + render(h(Test)) + equal(screen.getByTestId('computed').textContent, 'Computed: 0') + + await act(async () => { + screen.getByTestId('btn-change').click() + await delay(1) + }) + + equal(screen.getByTestId('computed').textContent, 'Computed: 2') +}) + +test('reacts to selector function changes and updates if value changes', async () => { + type Value = { + a: string + b: string + } + + let renders = 0 + let $store = map({ a: 'valueA', b: 'valueB' }) + + let Test: FC = () => { + renders += 1 + let [useKeyB, setUseKeyB] = useState(false) + + let selector = useKeyB ? (v: Value) => v.b : (v: Value) => v.a + let value = useSelector($store, selector) + + return h( + 'div', + {}, + h('p', { 'data-testid': 'value' }, value), + h('button', { + onClick: () => { + setUseKeyB(true) + } + }) + ) + } + + render(h(Test)) + equal(screen.getByTestId('value').textContent, 'valueA') + equal(renders, 1) + + await act(async () => { + screen.getByRole('button').click() + await delay(1) + }) + + equal(screen.getByTestId('value').textContent, 'valueB') + equal(renders, 2) +}) + +test('support for SSR does not break server behaviour in non-SSR projects', () => { + type Value = 'new' | 'old' + let atomStore = atom('old') + let mapStore = map<{ value: Value }>({ value: 'old' }) + + let atomValues: Uppercase[] = [] // Track values used across renders + + let AtomTest: FC = () => { + let value = useSelector(atomStore, v => v.toUpperCase() as Uppercase) + atomValues.push(value) + return h('div', { 'data-testid': 'atom-test' }, value) + } + + let mapValues: Uppercase[] = [] // Track values used across renders + + let MapTest: FC = () => { + let value = useSelector( + mapStore, + v => v.value.toUpperCase(), + // Setting `ssr:false` should be equivalent to not setting `ssr` at all + { ssr: false } + ) + mapValues.push(value as Uppercase) + return h('div', { 'data-testid': 'map-test' }, value) + } + + let Wrapper: FC = () => { + return h('div', { 'data-testid': 'test' }, h(AtomTest), h(MapTest)) + } + + // Simulate store state change on server side + atomStore.set('new') + mapStore.set({ value: 'new' }) + + let ssrElement = document.createElement('div') + document.body.appendChild(ssrElement) + let html = renderToString(h(Wrapper)) + ssrElement.innerHTML = html + + // Confirm server rendered HTML includes the latest store data + equal(screen.getByTestId('atom-test').textContent, 'NEW') + equal(screen.getByTestId('map-test').textContent, 'NEW') +}) + +test('support SSR to fix client hydration errors, use initial data', t => { + type Value = 'new' | 'old' + let atomStore = atom('old') + let mapStore = map<{ value: Value }>({ value: 'old' }) + + let atomValues: Uppercase[] = [] + + let AtomTest: FC = () => { + let value = useSelector(atomStore, v => v.toUpperCase(), { ssr: 'initial' }) + atomValues.push(value as Uppercase) + return h('div', { 'data-testid': 'atom-test' }, value) + } + + let mapValues: Uppercase[] = [] + + let MapTest: FC = () => { + let value = useSelector(mapStore, v => v.value.toUpperCase(), { + ssr: 'initial' + }) + mapValues.push(value as Uppercase) + return h('div', { 'data-testid': 'map-test' }, value) + } + + let Wrapper: FC = () => { + return h('div', { 'data-testid': 'test' }, h(AtomTest), h(MapTest)) + } + + let ssrElement = document.createElement('div') + document.body.appendChild(ssrElement) + let html = renderToString(h(Wrapper)) + ssrElement.innerHTML = html + + equal(screen.getByTestId('atom-test').textContent, 'OLD') + equal(screen.getByTestId('map-test').textContent, 'OLD') + + // Simulate store change on client, now different from value at "server" SSR + atomStore.set('new') + mapStore.set({ value: 'new' }) + + // Hydrate into SSR element. Logs errors to console on hydration failure + let consoleErrorMock = t.mock.method(console, 'error', () => {}) + act(() => { + hydrateRoot(ssrElement, h(Wrapper)) + }) + + // Check nothing was logged to `console.error()` + let consoleErrorCall = consoleErrorMock.mock.calls[0] as + | { arguments: any } + | undefined + let consoleErrorMessage = String(consoleErrorCall?.arguments?.[0] ?? '') + equal(consoleErrorMessage, '') + + // Confirm "server" render (renderToString) got old values, initial client + // render got old values at hydration, then post-hydration render got new values + deepStrictEqual(atomValues, ['OLD', 'OLD', 'NEW']) + deepStrictEqual(mapValues, ['OLD', 'OLD', 'NEW']) + + equal(screen.getByTestId('atom-test').textContent, 'NEW') + equal(screen.getByTestId('map-test').textContent, 'NEW') +}) + +test('support SSR to fix client hydration errors, server passes data to client', t => { + type Value = 'initial' | 'update on client' | 'update on server' + let atomStore = atom('initial') + let mapStore = map<{ value: Value }>({ value: 'initial' }) + + let ssrDataFnForAtom: typeof atomStore.get | undefined + let ssrDataFnForMap: typeof mapStore.get | undefined + + let atomValues: Uppercase[] = [] // Track values used across renders + + let AtomTest: FC = () => { + let value = useSelector(atomStore, v => v.toUpperCase(), { + ssr: ssrDataFnForAtom + }) + atomValues.push(value as Uppercase) + return h('div', { 'data-testid': 'atom-test' }, value) + } + + let mapValues: Uppercase[] = [] // Track values used across renders + + let MapTest: FC = () => { + let value = useSelector(mapStore, v => v.value.toUpperCase(), { + ssr: ssrDataFnForMap + }) + mapValues.push(value as Uppercase) + return h('div', { 'data-testid': 'map-test' }, value) + } + + let Wrapper: FC = () => { + return h( + 'div', + { 'data-testid': 'test' }, + h(AtomTest, null), + h(MapTest, null) + ) + } + + // Simulate store state change on server side + atomStore.set('update on server') + mapStore.set({ value: 'update on server' }) + + let ssrElement = document.createElement('div') + document.body.appendChild(ssrElement) + let html = renderToString(h(Wrapper)) + ssrElement.innerHTML = html + + // Confirm server render includes latest updates to server store + equal(screen.getByTestId('atom-test').textContent, 'UPDATE ON SERVER') + equal(screen.getByTestId('map-test').textContent, 'UPDATE ON SERVER') + + // Simulate store change on client, now different from value at "server" SSR + atomStore.set('update on client') + mapStore.set({ value: 'update on client' }) + + // Simulate passing of store state data from server to client, provided to + // hook via `ssr` option + ssrDataFnForAtom = (): Value => 'update on server' + let serverDataForMap = { value: 'update on server' as Value } + ssrDataFnForMap = (): { value: Value } => serverDataForMap + + // Hydrate into SSR element. Logs errors to console on hydration failure + let consoleErrorMock = t.mock.method(console, 'error', () => {}) + act(() => { + hydrateRoot(ssrElement, h(Wrapper)) + }) + + // Check nothing was logged to `console.error()` + let consoleErrorCall = consoleErrorMock.mock.calls[0] as + | { arguments: any } + | undefined + let consoleErrorMessage = String(consoleErrorCall?.arguments?.[0] ?? '') + equal(consoleErrorMessage, '') + + // Confirm "server" render (renderToString) got latest update on server, + // initial client render got latest server update at hydration, then + // post-hydration render got latest client update + deepStrictEqual(atomValues, [ + 'UPDATE ON SERVER', + 'UPDATE ON SERVER', + 'UPDATE ON CLIENT' + ]) + deepStrictEqual(mapValues, [ + 'UPDATE ON SERVER', + 'UPDATE ON SERVER', + 'UPDATE ON CLIENT' + ]) + + // Confirm final rendered version has latest updates to client store + equal(screen.getByTestId('atom-test').textContent, 'UPDATE ON CLIENT') + equal(screen.getByTestId('map-test').textContent, 'UPDATE ON CLIENT') +}) diff --git a/test/index.test.ts b/test/use-store.test.ts similarity index 99% rename from test/index.test.ts rename to test/use-store.test.ts index 17dc937..d55457c 100644 --- a/test/index.test.ts +++ b/test/use-store.test.ts @@ -10,7 +10,7 @@ import React from 'react' import { hydrateRoot } from 'react-dom/client' import { renderToString } from 'react-dom/server' -import { useStore } from '../index.js' +import { useStore } from '../use-store/index.js' let { createElement: h, useState } = React diff --git a/use-selector/index.d.ts b/use-selector/index.d.ts new file mode 100644 index 0000000..80428e5 --- /dev/null +++ b/use-selector/index.d.ts @@ -0,0 +1,37 @@ +import type { ReadableAtom } from 'nanostores' +import type { useSyncExternalStoreWithSelector } from 'use-sync-external-store/shim/with-selector' + +type SyncExternalStoreSubscribe = Parameters< + typeof useSyncExternalStoreWithSelector +>[0] + +export interface UseSelectorOptions { + /** + * Custom equality function (defaults to strict equality check). + */ + compare?: (a: T, b: T) => boolean + /** + * Enable SSR support. Set `initial` when selector's initial value is the same on + * server and client, or provide a function to return the server selector state + * for advanced cases (per useSyncExternalStoreWithSelector's getServerSnapshot). + */ + ssr?: 'initial' | (() => Snapshot) | false +} + +/** + * React hook to subscribe to a nanostores atom with high-performance selection. + * Only re-renders if the selector result changes (uses strict equality by default). + * @param store Store to subscribe to. + * @param selector Function to derive data from the store state. + * @param options Configuration options for evaluation and server-side rendering. + */ +export function useSelector( + store: Store, + selector: ( + snapshot: Store extends { get: () => infer Snapshot } ? Snapshot : undefined + ) => T, + options?: UseSelectorOptions< + T, + Store extends { get: () => infer Snapshot } ? Snapshot : undefined + > +): T diff --git a/use-selector/index.js b/use-selector/index.js new file mode 100644 index 0000000..7558565 --- /dev/null +++ b/use-selector/index.js @@ -0,0 +1,37 @@ +import { useCallback } from 'react' +import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/shim/with-selector' + +function defaultCompare(a, b) { + return a === b +} + +export function useSelector( + store, + selector, + { compare = defaultCompare, ssr } = {} +) { + let subscribe = useCallback( + handleStoreChange => { + if (!store) return () => {} + + let unsubscribe = store.listen(handleStoreChange) + return unsubscribe + }, + [store] + ) + + let get = useCallback(() => store?.get(), [store]) + + let server = get + if (ssr && store && 'init' in store) { + server = ssr === 'initial' ? () => store.init : ssr + } + + return useSyncExternalStoreWithSelector( + subscribe, + get, + server, + selector, + compare + ) +} diff --git a/use-store/index.d.ts b/use-store/index.d.ts new file mode 100644 index 0000000..da56581 --- /dev/null +++ b/use-store/index.d.ts @@ -0,0 +1,56 @@ +import type { Store, StoreValue } from 'nanostores' +import type { DependencyList } from 'react' + +type StoreKeys = T extends { setKey: (k: infer K, v: any) => unknown } + ? K + : never + +export interface UseStoreOptions { + /** + * @default + * ```ts + * [store, options.keys] + * ``` + */ + deps?: DependencyList + + /** + * Will re-render components only on specific key changes. + */ + keys?: StoreKeys[] + + /** + * Enable SSR support. Set `initial` when store's initial value is the same on + * server and client, or provide a function to return the server store state + * for advanced cases (per useSyncExternalStore's getServerSnapshot). + */ + ssr?: (() => StoreValue) | 'initial' | false +} + +/** + * Subscribe to store changes and get store’s value. + * + * Can be user with store builder too. + * + * ```js + * import { useStore } from 'nanostores/react' + * + * import { router } from '../store/router' + * + * export const Layout = () => { + * let page = useStore(router) + * if (page.route === 'home') { + * return + * } else { + * return + * } + * } + * ``` + * + * @param store Store instance. + * @returns Store value. + */ +export function useStore( + store: SomeStore, + options?: UseStoreOptions +): StoreValue diff --git a/use-store/index.js b/use-store/index.js new file mode 100644 index 0000000..652a73a --- /dev/null +++ b/use-store/index.js @@ -0,0 +1,34 @@ +import { listenKeys } from 'nanostores' +import { useCallback, useRef, useSyncExternalStore } from 'react' + +let emit = (snapshotRef, onChange) => value => { + if (snapshotRef.current === value) return + snapshotRef.current = value + onChange() +} + +export function test() { + return 1 +} + +export function useStore(store, { keys, deps = [store, keys], ssr } = {}) { + let snapshotRef = useRef() + snapshotRef.current = store.get() + + let subscribe = useCallback(onChange => { + emit(snapshotRef, onChange)(store.value) + + return keys?.length > 0 + ? listenKeys(store, keys, emit(snapshotRef, onChange)) + : store.listen(emit(snapshotRef, onChange)) + }, deps) + + let get = () => snapshotRef.current + + let server = get + if (ssr && 'init' in store) { + server = ssr === 'initial' ? () => store.init : ssr + } + + return useSyncExternalStore(subscribe, get, server) +} From ed6e4648fed29e6435cf5ece244a8ea7e2e8bdec Mon Sep 17 00:00:00 2001 From: nadia Date: Fri, 22 May 2026 14:00:45 +0200 Subject: [PATCH 2/3] fix readme --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9b2920c..2f490b5 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,10 @@ export const HeaderWithKeys = ({ userId }) => { export const HeaderWithSelector = ({ userId }) => { // selector function automatically handles keys changes - const name = useSelector($profiles, profiles[userId].name.toUppercase()) + const name = useSelector( + $profiles, + profiles => profiles[userId].name.toUppercase() + ) return
{name}
} ``` From 513c845598038ee824baa389f7c6ab4e088296f0 Mon Sep 17 00:00:00 2001 From: nadia Date: Fri, 29 May 2026 14:50:38 +0200 Subject: [PATCH 3/3] use computed instead --- README.md | 12 ++++++++---- package.json | 6 +----- pnpm-lock.yaml | 21 --------------------- use-selector/index.js | 41 ++++++++++++++++++++++++----------------- 4 files changed, 33 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 2f490b5..8ee69c3 100644 --- a/README.md +++ b/README.md @@ -100,9 +100,8 @@ export const HeaderWithKeys = ({ userId }) => { export const HeaderWithSelector = ({ userId }) => { // selector function automatically handles keys changes - const name = useSelector( - $profiles, - profiles => profiles[userId].name.toUppercase() + const name = useSelector($profiles, profiles => + profiles[userId].name.toUppercase() ) return
{name}
} @@ -138,11 +137,16 @@ export const Posts = () => { ### SSR with useSelector +SSR in `useSelector` works like in `useStore`. If you are passing a function to `ssr` make sure to return snapshot of the store you're passing into useSelector, not selected value. + ```tsx +const profileFromServer = { name: 'A User' } + export const Header = () => { const name = useSelector($profile, profile => profile.name, { - ssr: 'initial' + ssr: typeof window === 'undefined' ? false : () => profileFromServer }) + return
{name}
} ``` diff --git a/package.json b/package.json index 6aad6ca..55fddd0 100644 --- a/package.json +++ b/package.json @@ -32,10 +32,6 @@ "test:size": "size-limit", "test": "pnpm run /^test:/" }, - "dependencies": { - "@types/use-sync-external-store": "^1.5.0", - "use-sync-external-store": "^1.6.0" - }, "devDependencies": { "@logux/oxc-configs": "^0.2.2", "@size-limit/preset-small-lib": "^12.0.1", @@ -78,7 +74,7 @@ "index.js": "{ useStore, useSelector }", "nanostores": "{ map, computed }" }, - "limit": "2004 B" + "limit": "1084 B" } ], "engines": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b2fb6cf..5942378 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,13 +7,6 @@ settings: importers: .: - dependencies: - '@types/use-sync-external-store': - specifier: ^1.5.0 - version: 1.5.0 - use-sync-external-store: - specifier: ^1.6.0 - version: 1.6.0(react@19.2.4) devDependencies: '@logux/oxc-configs': specifier: ^0.2.2 @@ -459,9 +452,6 @@ packages: '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} - '@types/use-sync-external-store@1.5.0': - resolution: {integrity: sha512-5dyB8nLC/qogMrlCizZnYWQTA4lnb/v+It+sqNl5YnSRAPMlIqY/X0Xn+gZw8vOL+TgTTr28VEbn3uf8fUtAkw==} - '@types/whatwg-mimetype@3.0.2': resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} @@ -702,11 +692,6 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} - 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 - whatwg-mimetype@3.0.0: resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} engines: {node: '>=12'} @@ -951,8 +936,6 @@ snapshots: dependencies: csstype: 3.2.3 - '@types/use-sync-external-store@1.5.0': {} - '@types/whatwg-mimetype@3.0.2': {} '@types/ws@8.18.1': @@ -1198,10 +1181,6 @@ snapshots: undici-types@7.18.2: {} - use-sync-external-store@1.6.0(react@19.2.4): - dependencies: - react: 19.2.4 - whatwg-mimetype@3.0.0: {} ws@8.20.0: {} diff --git a/use-selector/index.js b/use-selector/index.js index 7558565..c10f5ee 100644 --- a/use-selector/index.js +++ b/use-selector/index.js @@ -1,37 +1,44 @@ -import { useCallback } from 'react' -import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/shim/with-selector' +import { computed } from 'nanostores' +import { useCallback, useMemo, useSyncExternalStore, useRef } from 'react' function defaultCompare(a, b) { return a === b } +function emit(snapshotRef, onChange, compare) { + return value => { + if (compare(snapshotRef.current, value)) return + snapshotRef.current = value + onChange() + } +} + export function useSelector( store, selector, { compare = defaultCompare, ssr } = {} ) { + let $computed = useMemo(() => computed(store, selector), [store, selector]) + + let snapshotRef = useRef() + snapshotRef.current = $computed.get() + let subscribe = useCallback( - handleStoreChange => { - if (!store) return () => {} + onChange => { + emit(snapshotRef, onChange, compare)($computed.value) - let unsubscribe = store.listen(handleStoreChange) - return unsubscribe + return $computed.listen(emit(snapshotRef, onChange, compare)) }, - [store] + [$computed, compare] ) - let get = useCallback(() => store?.get(), [store]) + let get = () => snapshotRef.current let server = get - if (ssr && store && 'init' in store) { - server = ssr === 'initial' ? () => store.init : ssr + if (ssr && 'init' in store) { + server = + ssr === 'initial' ? () => selector(store.init) : () => selector(ssr()) } - return useSyncExternalStoreWithSelector( - subscribe, - get, - server, - selector, - compare - ) + return useSyncExternalStore(subscribe, get, server) }