@montra-interactive/deepstate-react 0.3.5 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -103,15 +103,32 @@ const summary = useSelect(
103
103
  );
104
104
  ```
105
105
 
106
+ #### Selector Memoization
107
+
108
+ `useSelect` automatically memoizes selectors: the selector function only re-runs when its input values actually change (compared by reference). This means you can safely return new arrays or objects from selectors without causing infinite re-render loops:
109
+
110
+ ```tsx
111
+ // Safe! The selector only re-runs when items or sortBy actually change.
112
+ // Even though .sort() returns a new array, it won't cause infinite emissions.
113
+ const sorted = useSelect(
114
+ [store.items, store.sortBy],
115
+ ([items, sortBy]) => Array.from(items).sort((a, b) =>
116
+ sortBy === 'name' ? a.name.localeCompare(b.name) : b.updatedAt - a.updatedAt
117
+ )
118
+ );
119
+ ```
120
+
121
+ This works like Redux's `createSelector` / Reselect — if the inputs haven't changed, the selector doesn't re-run, preserving the previous output reference.
122
+
106
123
  #### Custom Equality Function
107
124
 
108
- Prevent re-renders with a custom equality check:
125
+ For additional control, provide a custom equality check as the third argument. This is evaluated on the selector's **output** and acts as a safety net when different inputs might produce equivalent results:
109
126
 
110
127
  ```tsx
111
128
  const ids = useSelect(
112
129
  store.items,
113
130
  items => items.map(i => i.id),
114
- // Custom array equality
131
+ // Custom array equality on the output
115
132
  (a, b) => a.length === b.length && a.every((v, i) => v === b[i])
116
133
  );
117
134
  ```
package/dist/hooks.d.ts CHANGED
@@ -42,6 +42,20 @@ export declare function useObservable<T>(observable$: Observable<T>, getSnapshot
42
42
  * This is the primary hook for using deepstate in React.
43
43
  * Uses React 18's useSyncExternalStore for concurrent-mode safety.
44
44
  *
45
+ * ## Selector Memoization
46
+ *
47
+ * Selectors are automatically memoized on their inputs, similar to Redux's
48
+ * `createSelector` / Reselect. The selector function only re-executes when
49
+ * input values change by reference. This means selectors that return new
50
+ * arrays or objects (e.g. via `.sort()`, `.filter()`, `.map()`) are safe
51
+ * without needing custom equality functions.
52
+ *
53
+ * Memoization works in two layers:
54
+ * 1. **Input dedup** — `distinctUntilChanged` before the selector prevents
55
+ * re-execution when inputs are referentially identical.
56
+ * 2. **Output dedup** — `distinctUntilChanged(equalityFn)` after the selector
57
+ * catches cases where different inputs produce equivalent outputs.
58
+ *
45
59
  * @example Single node (get raw value)
46
60
  * ```tsx
47
61
  * import { state } from 'deepstate';
@@ -83,6 +97,22 @@ export declare function useObservable<T>(observable$: Observable<T>, getSnapshot
83
97
  * }
84
98
  * ```
85
99
  *
100
+ * @example Selector returning new array (safe - auto-memoized)
101
+ * ```tsx
102
+ * // .sort() returns a new array each time, but the selector only
103
+ * // re-runs when items or sortBy actually change.
104
+ * function SortedItems() {
105
+ * const sorted = useSelect(
106
+ * [store.items, store.sortBy],
107
+ * ([items, sortBy]) =>
108
+ * Array.from(items).sort((a, b) =>
109
+ * sortBy === 'name' ? a.name.localeCompare(b.name) : b.date - a.date
110
+ * ),
111
+ * );
112
+ * return <ItemList items={sorted} />;
113
+ * }
114
+ * ```
115
+ *
86
116
  * @example Multiple nodes (array form)
87
117
  * ```tsx
88
118
  * // Combine multiple nodes - receives values as tuple
@@ -1 +1 @@
1
- {"version":3,"file":"hooks.d.ts","sourceRoot":"","sources":["../src/hooks.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"hooks.d.ts","sourceRoot":"","sources":["../src/hooks.ts"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
@@ -20,6 +20,34 @@ function isObservable(obj) {
20
20
  return false;
21
21
  }
22
22
  }
23
+ function useStableNodes(nodeOrNodes) {
24
+ const ref = useRef(nodeOrNodes);
25
+ if (Array.isArray(nodeOrNodes)) {
26
+ const prev = ref.current;
27
+ if (!Array.isArray(prev) || prev.length !== nodeOrNodes.length || nodeOrNodes.some((n, i) => n !== prev[i])) {
28
+ ref.current = nodeOrNodes;
29
+ }
30
+ return ref.current;
31
+ }
32
+ if (!isObservable(nodeOrNodes)) {
33
+ const prev = ref.current;
34
+ if (isObservable(prev) || Array.isArray(prev)) {
35
+ ref.current = nodeOrNodes;
36
+ return ref.current;
37
+ }
38
+ const prevObj = prev;
39
+ const currKeys = Object.keys(nodeOrNodes);
40
+ const prevKeys = Object.keys(prevObj);
41
+ if (currKeys.length !== prevKeys.length || currKeys.some((k) => nodeOrNodes[k] !== prevObj[k])) {
42
+ ref.current = nodeOrNodes;
43
+ }
44
+ return ref.current;
45
+ }
46
+ if (nodeOrNodes !== ref.current) {
47
+ ref.current = nodeOrNodes;
48
+ }
49
+ return ref.current;
50
+ }
23
51
  function useObservable(observable$, getSnapshot) {
24
52
  const valueRef = useRef(getSnapshot());
25
53
  const subscribe = useCallback((onStoreChange) => {
@@ -33,55 +61,58 @@ function useObservable(observable$, getSnapshot) {
33
61
  return useSyncExternalStore(subscribe, getSnapshotMemo, getSnapshotMemo);
34
62
  }
35
63
  function useSelect(nodeOrNodes, selector, equalityFn = Object.is) {
64
+ const selectorRef = useRef(selector);
65
+ selectorRef.current = selector;
66
+ const equalityFnRef = useRef(equalityFn);
67
+ equalityFnRef.current = equalityFn;
68
+ const stableNodes = useStableNodes(nodeOrNodes);
36
69
  const { combined$, getInitialValue } = useMemo(() => {
37
- if (Array.isArray(nodeOrNodes)) {
38
- const nodes = nodeOrNodes;
39
- const sel = selector;
70
+ if (Array.isArray(stableNodes)) {
71
+ const nodes = stableNodes;
40
72
  return {
41
- combined$: combineLatest(nodes).pipe(map((values) => sel(values)), distinctUntilChanged(equalityFn)),
73
+ combined$: combineLatest(nodes).pipe(distinctUntilChanged((a, b) => a.length === b.length && a.every((v, i) => Object.is(v, b[i]))), map((values) => selectorRef.current(values)), distinctUntilChanged((a, b) => equalityFnRef.current(a, b))),
42
74
  getInitialValue: () => {
43
75
  const values = nodes.map((n) => hasGet(n) ? n.get() : undefined);
44
- return sel(values);
76
+ return selectorRef.current(values);
45
77
  }
46
78
  };
47
79
  }
48
- if (!isObservable(nodeOrNodes)) {
49
- const obj = nodeOrNodes;
80
+ if (!isObservable(stableNodes)) {
81
+ const obj = stableNodes;
50
82
  const keys = Object.keys(obj);
51
83
  const observables = keys.map((k) => obj[k]);
52
- const sel = selector;
53
84
  return {
54
- combined$: combineLatest(observables).pipe(map((values) => {
85
+ combined$: combineLatest(observables).pipe(distinctUntilChanged((a, b) => a.length === b.length && a.every((v, i) => Object.is(v, b[i]))), map((values) => {
55
86
  const result = {};
56
87
  keys.forEach((key, i) => {
57
88
  result[key] = values[i];
58
89
  });
59
- return sel(result);
60
- }), distinctUntilChanged(equalityFn)),
90
+ return selectorRef.current(result);
91
+ }), distinctUntilChanged((a, b) => equalityFnRef.current(a, b))),
61
92
  getInitialValue: () => {
62
93
  const result = {};
63
94
  keys.forEach((key) => {
64
95
  const node2 = obj[key];
65
96
  result[key] = hasGet(node2) ? node2.get() : undefined;
66
97
  });
67
- return sel(result);
98
+ return selectorRef.current(result);
68
99
  }
69
100
  };
70
101
  }
71
- const node = nodeOrNodes;
72
- if (selector) {
102
+ const node = stableNodes;
103
+ if (selectorRef.current) {
73
104
  return {
74
- combined$: node.pipe(map((value) => selector(value)), distinctUntilChanged(equalityFn)),
105
+ combined$: node.pipe(distinctUntilChanged(), map((value) => selectorRef.current(value)), distinctUntilChanged((a, b) => equalityFnRef.current(a, b))),
75
106
  getInitialValue: () => {
76
107
  if (hasGet(node)) {
77
- return selector(node.get());
108
+ return selectorRef.current(node.get());
78
109
  }
79
110
  return;
80
111
  }
81
112
  };
82
113
  } else {
83
114
  return {
84
- combined$: node.pipe(distinctUntilChanged(equalityFn)),
115
+ combined$: node.pipe(distinctUntilChanged((a, b) => equalityFnRef.current(a, b))),
85
116
  getInitialValue: () => {
86
117
  if (hasGet(node)) {
87
118
  return node.get();
@@ -90,7 +121,7 @@ function useSelect(nodeOrNodes, selector, equalityFn = Object.is) {
90
121
  }
91
122
  };
92
123
  }
93
- }, [nodeOrNodes, selector, equalityFn]);
124
+ }, [stableNodes]);
94
125
  const valueRef = useRef(getInitialValue());
95
126
  const subscribe = useCallback((onStoreChange) => {
96
127
  const subscription = combined$.subscribe((newValue) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@montra-interactive/deepstate-react",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
4
4
  "description": "React bindings for deepstate - Proxy-based reactive state management with RxJS.",
5
5
  "keywords": [
6
6
  "react",
package/src/hooks.ts CHANGED
@@ -59,6 +59,62 @@ function isObservable(obj: unknown): obj is Observable<unknown> {
59
59
  }
60
60
  }
61
61
 
62
+ /**
63
+ * Stabilizes the identity of nodeOrNodes across re-renders.
64
+ *
65
+ * Users typically pass inline arrays like `[store.a, store.b]` or inline objects
66
+ * like `{ a: store.a }` to useSelect. These are new references every render,
67
+ * which would cause useMemo to recreate the observable pipeline, leading to
68
+ * resubscription and potential infinite render loops (shareReplay replays the
69
+ * last value → onStoreChange → re-render → new useMemo → resubscribe → replay → …).
70
+ *
71
+ * This hook compares the individual node references inside the container and
72
+ * returns a stable reference as long as the nodes themselves haven't changed.
73
+ */
74
+ type NodeInput = Observable<unknown> | Observable<unknown>[] | Record<string, Observable<unknown>>;
75
+
76
+ function useStableNodes(nodeOrNodes: NodeInput): NodeInput {
77
+ const ref = useRef(nodeOrNodes);
78
+
79
+ // For arrays: check element-wise identity
80
+ if (Array.isArray(nodeOrNodes)) {
81
+ const prev = ref.current;
82
+ if (
83
+ !Array.isArray(prev) ||
84
+ prev.length !== nodeOrNodes.length ||
85
+ nodeOrNodes.some((n, i) => n !== (prev as Observable<unknown>[])[i])
86
+ ) {
87
+ ref.current = nodeOrNodes;
88
+ }
89
+ return ref.current;
90
+ }
91
+
92
+ // For objects (not observable): check key-wise identity
93
+ if (!isObservable(nodeOrNodes)) {
94
+ const prev = ref.current;
95
+ if (isObservable(prev) || Array.isArray(prev)) {
96
+ ref.current = nodeOrNodes;
97
+ return ref.current;
98
+ }
99
+ const prevObj = prev as Record<string, Observable<unknown>>;
100
+ const currKeys = Object.keys(nodeOrNodes);
101
+ const prevKeys = Object.keys(prevObj);
102
+ if (
103
+ currKeys.length !== prevKeys.length ||
104
+ currKeys.some((k) => nodeOrNodes[k] !== prevObj[k])
105
+ ) {
106
+ ref.current = nodeOrNodes;
107
+ }
108
+ return ref.current;
109
+ }
110
+
111
+ // For single observable: direct identity check
112
+ if (nodeOrNodes !== ref.current) {
113
+ ref.current = nodeOrNodes;
114
+ }
115
+ return ref.current;
116
+ }
117
+
62
118
  /**
63
119
  * Hook to subscribe to any Observable and get its current value.
64
120
  * Re-renders the component whenever the observable emits a new value.
@@ -112,6 +168,20 @@ export function useObservable<T>(
112
168
  * This is the primary hook for using deepstate in React.
113
169
  * Uses React 18's useSyncExternalStore for concurrent-mode safety.
114
170
  *
171
+ * ## Selector Memoization
172
+ *
173
+ * Selectors are automatically memoized on their inputs, similar to Redux's
174
+ * `createSelector` / Reselect. The selector function only re-executes when
175
+ * input values change by reference. This means selectors that return new
176
+ * arrays or objects (e.g. via `.sort()`, `.filter()`, `.map()`) are safe
177
+ * without needing custom equality functions.
178
+ *
179
+ * Memoization works in two layers:
180
+ * 1. **Input dedup** — `distinctUntilChanged` before the selector prevents
181
+ * re-execution when inputs are referentially identical.
182
+ * 2. **Output dedup** — `distinctUntilChanged(equalityFn)` after the selector
183
+ * catches cases where different inputs produce equivalent outputs.
184
+ *
115
185
  * @example Single node (get raw value)
116
186
  * ```tsx
117
187
  * import { state } from 'deepstate';
@@ -153,6 +223,22 @@ export function useObservable<T>(
153
223
  * }
154
224
  * ```
155
225
  *
226
+ * @example Selector returning new array (safe - auto-memoized)
227
+ * ```tsx
228
+ * // .sort() returns a new array each time, but the selector only
229
+ * // re-runs when items or sortBy actually change.
230
+ * function SortedItems() {
231
+ * const sorted = useSelect(
232
+ * [store.items, store.sortBy],
233
+ * ([items, sortBy]) =>
234
+ * Array.from(items).sort((a, b) =>
235
+ * sortBy === 'name' ? a.name.localeCompare(b.name) : b.date - a.date
236
+ * ),
237
+ * );
238
+ * return <ItemList items={sorted} />;
239
+ * }
240
+ * ```
241
+ *
156
242
  * @example Multiple nodes (array form)
157
243
  * ```tsx
158
244
  * // Combine multiple nodes - receives values as tuple
@@ -239,41 +325,62 @@ export function useSelect(
239
325
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
240
326
  equalityFn: (a: any, b: any) => boolean = Object.is
241
327
  ): unknown {
328
+ // Use refs for selector and equalityFn so we always call the latest version
329
+ // without needing them as useMemo deps (which would recreate the observable).
330
+ const selectorRef = useRef(selector);
331
+ selectorRef.current = selector;
332
+ const equalityFnRef = useRef(equalityFn);
333
+ equalityFnRef.current = equalityFn;
334
+
335
+ // Stabilize the node identity across renders.
336
+ // Users pass inline arrays/objects like [store.a, store.b] or { a: store.a },
337
+ // which are new references each render. We extract the actual node references
338
+ // and only recreate the observable when the nodes themselves change.
339
+ const stableNodes = useStableNodes(nodeOrNodes);
340
+
242
341
  // Determine the form and create the combined observable
243
342
  const { combined$, getInitialValue } = useMemo(() => {
244
343
  // Array form: [node1, node2, ...] - always requires selector
245
- if (Array.isArray(nodeOrNodes)) {
246
- const nodes = nodeOrNodes as Observable<unknown>[];
247
- const sel = selector!; // selector is required for array form
344
+ if (Array.isArray(stableNodes)) {
345
+ const nodes = stableNodes as Observable<unknown>[];
248
346
  return {
249
347
  combined$: combineLatest(nodes).pipe(
250
- map((values) => sel(values)),
251
- distinctUntilChanged(equalityFn)
348
+ // Deduplicate inputs so the selector only re-runs when an input actually changes.
349
+ // This prevents selectors that return new references (e.g. .sort(), .map())
350
+ // from causing infinite emission loops with the default Object.is equality.
351
+ distinctUntilChanged((a, b) =>
352
+ a.length === b.length && a.every((v, i) => Object.is(v, b[i]))
353
+ ),
354
+ map((values) => selectorRef.current!(values)),
355
+ distinctUntilChanged((a, b) => equalityFnRef.current(a, b))
252
356
  ),
253
357
  getInitialValue: (): unknown => {
254
358
  const values = nodes.map((n) => (hasGet<unknown>(n) ? n.get() : undefined));
255
- return sel(values);
359
+ return selectorRef.current!(values);
256
360
  },
257
361
  };
258
362
  }
259
363
 
260
364
  // Object form: { a: node1, b: node2, ... } - always requires selector
261
- if (!isObservable(nodeOrNodes)) {
262
- const obj = nodeOrNodes as Record<string, Observable<unknown>>;
365
+ if (!isObservable(stableNodes)) {
366
+ const obj = stableNodes as Record<string, Observable<unknown>>;
263
367
  const keys = Object.keys(obj);
264
368
  const observables = keys.map((k) => obj[k]);
265
- const sel = selector!; // selector is required for object form
266
369
 
267
370
  return {
268
371
  combined$: combineLatest(observables).pipe(
372
+ // Deduplicate inputs so the selector only re-runs when an input actually changes.
373
+ distinctUntilChanged((a, b) =>
374
+ a.length === b.length && a.every((v, i) => Object.is(v, b[i]))
375
+ ),
269
376
  map((values) => {
270
377
  const result: Record<string, unknown> = {};
271
378
  keys.forEach((key, i) => {
272
379
  result[key] = values[i];
273
380
  });
274
- return sel(result);
381
+ return selectorRef.current!(result);
275
382
  }),
276
- distinctUntilChanged(equalityFn)
383
+ distinctUntilChanged((a, b) => equalityFnRef.current(a, b))
277
384
  ),
278
385
  getInitialValue: (): unknown => {
279
386
  const result: Record<string, unknown> = {};
@@ -281,24 +388,26 @@ export function useSelect(
281
388
  const node = obj[key];
282
389
  result[key] = hasGet<unknown>(node) ? node.get() : undefined;
283
390
  });
284
- return sel(result);
391
+ return selectorRef.current!(result);
285
392
  },
286
393
  };
287
394
  }
288
395
 
289
396
  // Single node form - selector is optional
290
- const node = nodeOrNodes as Observable<unknown>;
291
-
292
- if (selector) {
397
+ const node = stableNodes as Observable<unknown>;
398
+
399
+ if (selectorRef.current) {
293
400
  // With selector - apply transformation
294
401
  return {
295
402
  combined$: node.pipe(
296
- map((value) => selector(value)),
297
- distinctUntilChanged(equalityFn)
403
+ // Deduplicate inputs so the selector only re-runs when the input actually changes.
404
+ distinctUntilChanged(),
405
+ map((value) => selectorRef.current!(value)),
406
+ distinctUntilChanged((a, b) => equalityFnRef.current(a, b))
298
407
  ),
299
408
  getInitialValue: (): unknown => {
300
409
  if (hasGet<unknown>(node)) {
301
- return selector(node.get());
410
+ return selectorRef.current!(node.get());
302
411
  }
303
412
  return undefined;
304
413
  },
@@ -307,7 +416,7 @@ export function useSelect(
307
416
  // No selector - return raw value
308
417
  return {
309
418
  combined$: node.pipe(
310
- distinctUntilChanged(equalityFn)
419
+ distinctUntilChanged((a, b) => equalityFnRef.current(a, b))
311
420
  ),
312
421
  getInitialValue: (): unknown => {
313
422
  if (hasGet<unknown>(node)) {
@@ -317,7 +426,8 @@ export function useSelect(
317
426
  },
318
427
  };
319
428
  }
320
- }, [nodeOrNodes, selector, equalityFn]);
429
+ // eslint-disable-next-line react-hooks/exhaustive-deps
430
+ }, [stableNodes]);
321
431
 
322
432
  // Ref to hold the current derived value
323
433
  const valueRef = useRef<unknown>(getInitialValue());