atomirx 0.0.1 → 0.0.4

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.
Files changed (53) hide show
  1. package/README.md +867 -160
  2. package/dist/core/atom.d.ts +83 -6
  3. package/dist/core/batch.d.ts +3 -3
  4. package/dist/core/derived.d.ts +55 -21
  5. package/dist/core/effect.d.ts +47 -51
  6. package/dist/core/getAtomState.d.ts +29 -0
  7. package/dist/core/promiseCache.d.ts +23 -32
  8. package/dist/core/select.d.ts +208 -29
  9. package/dist/core/types.d.ts +55 -19
  10. package/dist/core/withReady.d.ts +69 -0
  11. package/dist/index-CqO6BDwj.cjs +1 -0
  12. package/dist/index-D8RDOTB_.js +1319 -0
  13. package/dist/index.cjs +1 -1
  14. package/dist/index.d.ts +9 -7
  15. package/dist/index.js +12 -10
  16. package/dist/react/index.cjs +10 -10
  17. package/dist/react/index.d.ts +2 -1
  18. package/dist/react/index.js +423 -379
  19. package/dist/react/rx.d.ts +114 -25
  20. package/dist/react/useAction.d.ts +5 -4
  21. package/dist/react/{useValue.d.ts → useSelector.d.ts} +56 -25
  22. package/dist/react/useSelector.test.d.ts +1 -0
  23. package/package.json +17 -1
  24. package/src/core/atom.test.ts +307 -43
  25. package/src/core/atom.ts +143 -21
  26. package/src/core/batch.test.ts +10 -10
  27. package/src/core/batch.ts +3 -3
  28. package/src/core/derived.test.ts +727 -72
  29. package/src/core/derived.ts +141 -73
  30. package/src/core/effect.test.ts +259 -39
  31. package/src/core/effect.ts +62 -85
  32. package/src/core/getAtomState.ts +69 -0
  33. package/src/core/promiseCache.test.ts +5 -3
  34. package/src/core/promiseCache.ts +76 -71
  35. package/src/core/select.ts +405 -130
  36. package/src/core/selector.test.ts +574 -32
  37. package/src/core/types.ts +54 -26
  38. package/src/core/withReady.test.ts +360 -0
  39. package/src/core/withReady.ts +127 -0
  40. package/src/core/withUse.ts +1 -1
  41. package/src/index.test.ts +4 -4
  42. package/src/index.ts +11 -6
  43. package/src/react/index.ts +2 -1
  44. package/src/react/rx.test.tsx +173 -18
  45. package/src/react/rx.tsx +274 -43
  46. package/src/react/useAction.test.ts +12 -14
  47. package/src/react/useAction.ts +11 -9
  48. package/src/react/{useValue.test.ts → useSelector.test.ts} +16 -16
  49. package/src/react/{useValue.ts → useSelector.ts} +64 -33
  50. package/v2.md +44 -44
  51. package/dist/index-2ok7ilik.js +0 -1217
  52. package/dist/index-B_5SFzfl.cjs +0 -1
  53. /package/dist/{react/useValue.test.d.ts → core/withReady.test.d.ts} +0 -0
package/src/react/rx.tsx CHANGED
@@ -1,14 +1,58 @@
1
- import { memo } from "react";
1
+ import {
2
+ Component,
3
+ memo,
4
+ ReactElement,
5
+ ReactNode,
6
+ Suspense,
7
+ ErrorInfo,
8
+ useCallback,
9
+ useRef,
10
+ } from "react";
2
11
  import { Atom, Equality } from "../core/types";
3
- import { useValue } from "./useValue";
12
+ import { useSelector } from "./useSelector";
4
13
  import { shallowEqual } from "../core/equality";
5
14
  import { isAtom } from "../core/isAtom";
6
- import { ContextSelectorFn } from "../core/select";
15
+ import { ReactiveSelector, SelectContext } from "../core/select";
16
+
17
+ /**
18
+ * Options for rx() with inline loading/error handling and memoization control.
19
+ */
20
+ export interface RxOptions<T> {
21
+ /** Equality function for value comparison */
22
+ equals?: Equality<T>;
23
+ /** Render function for loading state */
24
+ loading?: () => ReactNode;
25
+ /** Render function for error state */
26
+ error?: (props: { error: unknown }) => ReactNode;
27
+
28
+ /**
29
+ * Dependencies array for selector memoization.
30
+ *
31
+ * Controls when the selector callback is recreated:
32
+ * - **Atom shorthand** (`rx(atom$)`): Always memoized by atom reference (deps ignored)
33
+ * - **Function selector without deps**: No memoization (recreated every render)
34
+ * - **Function selector with `deps: []`**: Stable forever (never recreated)
35
+ * - **Function selector with `deps: [a, b]`**: Recreated when deps change
36
+ *
37
+ * @example
38
+ * ```tsx
39
+ * // No memoization (default for functions) - selector recreated every render
40
+ * rx(({ read }) => read(count$) * 2)
41
+ *
42
+ * // Stable selector - never recreated
43
+ * rx(({ read }) => read(count$) * 2, { deps: [] })
44
+ *
45
+ * // Recreate when multiplier changes
46
+ * rx(({ read }) => read(count$) * multiplier, { deps: [multiplier] })
47
+ * ```
48
+ */
49
+ deps?: unknown[];
50
+ }
7
51
 
8
52
  /**
9
53
  * Reactive inline component that renders atom values directly in JSX.
10
54
  *
11
- * `rx` is a convenience wrapper around `useValue` that returns a memoized
55
+ * `rx` is a convenience wrapper around `useSelector` that returns a memoized
12
56
  * React component instead of a value. This enables fine-grained reactivity
13
57
  * without creating separate components for each reactive value.
14
58
  *
@@ -18,25 +62,54 @@ import { ContextSelectorFn } from "../core/select";
18
62
  *
19
63
  * ```tsx
20
64
  * // ❌ WRONG - Don't use async function
21
- * rx(async ({ get }) => {
65
+ * rx(async ({ read }) => {
22
66
  * const data = await fetch('/api');
23
67
  * return data.name;
24
68
  * });
25
69
  *
26
70
  * // ❌ WRONG - Don't return a Promise
27
- * rx(({ get }) => fetch('/api').then(r => r.json()));
71
+ * rx(({ read }) => fetch('/api').then(r => r.json()));
28
72
  *
29
- * // ✅ CORRECT - Create async atom and read with get()
73
+ * // ✅ CORRECT - Create async atom and read with read()
30
74
  * const data$ = atom(fetch('/api').then(r => r.json()));
31
- * rx(({ get }) => get(data$).name); // Suspends until resolved
75
+ * rx(({ read }) => read(data$).name); // Suspends until resolved
76
+ * ```
77
+ *
78
+ * ## IMPORTANT: Do NOT Use try/catch - Use safe() Instead
79
+ *
80
+ * **Never wrap `read()` calls in try/catch blocks.** The `read()` function throws
81
+ * Promises when atoms are loading (Suspense pattern). A try/catch will catch
82
+ * these Promises and break the Suspense mechanism.
83
+ *
84
+ * ```tsx
85
+ * // ❌ WRONG - Catches Suspense Promise, breaks loading state
86
+ * rx(({ read }) => {
87
+ * try {
88
+ * return <span>{read(user$).name}</span>;
89
+ * } catch (e) {
90
+ * return <span>Error</span>; // Catches BOTH errors AND loading promises!
91
+ * }
92
+ * });
93
+ *
94
+ * // ✅ CORRECT - Use safe() to catch errors but preserve Suspense
95
+ * rx(({ read, safe }) => {
96
+ * const [err, user] = safe(() => read(user$));
97
+ * if (err) return <span>Error: {err.message}</span>;
98
+ * return <span>{user.name}</span>;
99
+ * });
32
100
  * ```
33
101
  *
102
+ * The `safe()` utility:
103
+ * - **Catches errors** and returns `[error, undefined]`
104
+ * - **Re-throws Promises** to preserve Suspense behavior
105
+ * - Returns `[undefined, result]` on success
106
+ *
34
107
  * ## Why Use `rx`?
35
108
  *
36
109
  * Without `rx`, you need a separate component to subscribe to an atom:
37
110
  * ```tsx
38
111
  * function PostsList() {
39
- * const posts = useValue(postsAtom);
112
+ * const posts = useSelector(postsAtom);
40
113
  * return posts.map((post) => <Post post={post} />);
41
114
  * }
42
115
  *
@@ -54,8 +127,8 @@ import { ContextSelectorFn } from "../core/select";
54
127
  * function Page() {
55
128
  * return (
56
129
  * <Suspense fallback={<Loading />}>
57
- * {rx(({ get }) =>
58
- * get(postsAtom).map((post) => <Post post={post} />)
130
+ * {rx(({ read }) =>
131
+ * read(postsAtom).map((post) => <Post post={post} />)
59
132
  * )}
60
133
  * </Suspense>
61
134
  * );
@@ -73,7 +146,7 @@ import { ContextSelectorFn } from "../core/select";
73
146
  *
74
147
  * ## Async Atoms (Suspense-Style API)
75
148
  *
76
- * `rx` inherits the Suspense-style API from `useValue`:
149
+ * `rx` inherits the Suspense-style API from `useSelector`:
77
150
  * - **Loading state**: The getter throws a Promise (triggers Suspense)
78
151
  * - **Error state**: The getter throws the error (triggers ErrorBoundary)
79
152
  * - **Resolved state**: The getter returns the value
@@ -84,7 +157,7 @@ import { ContextSelectorFn } from "../core/select";
84
157
  * return (
85
158
  * <ErrorBoundary fallback={<div>Error!</div>}>
86
159
  * <Suspense fallback={<div>Loading...</div>}>
87
- * {rx(({ get }) => get(userAtom).name)}
160
+ * {rx(({ read }) => read(userAtom).name)}
88
161
  * </Suspense>
89
162
  * </ErrorBoundary>
90
163
  * );
@@ -93,9 +166,9 @@ import { ContextSelectorFn } from "../core/select";
93
166
  *
94
167
  * Or catch errors in the selector to handle loading/error inline:
95
168
  * ```tsx
96
- * {rx(({ get }) => {
169
+ * {rx(({ read }) => {
97
170
  * try {
98
- * return get(userAtom).name;
171
+ * return read(userAtom).name;
99
172
  * } catch {
100
173
  * return "Loading...";
101
174
  * }
@@ -103,13 +176,37 @@ import { ContextSelectorFn } from "../core/select";
103
176
  * ```
104
177
  *
105
178
  * @template T - The type of the selected/derived value
106
- * @param selector - Context-based selector function with `{ get, all, any, race, settled }`.
179
+ * @param selector - Context-based selector function with `{ read, all, any, race, settled }`.
107
180
  * Must return sync value, not a Promise.
108
181
  * @param equals - Equality function or shorthand ("strict", "shallow", "deep").
109
182
  * Defaults to "shallow".
110
183
  * @returns A React element that renders the selected value
111
184
  * @throws Error if selector returns a Promise or PromiseLike
112
185
  *
186
+ * ## IMPORTANT: Atom Value Must Be ReactNode
187
+ *
188
+ * When using the shorthand `rx(atom)`, the atom's value must be a valid `ReactNode`
189
+ * (string, number, boolean, null, undefined, or React element). Objects and arrays
190
+ * are NOT valid ReactNode values and will cause React to throw an error.
191
+ *
192
+ * ```tsx
193
+ * // ✅ CORRECT - Atom contains ReactNode (number)
194
+ * const count$ = atom(5);
195
+ * rx(count$);
196
+ *
197
+ * // ✅ CORRECT - Atom contains ReactNode (string)
198
+ * const name$ = atom("John");
199
+ * rx(name$);
200
+ *
201
+ * // ❌ WRONG - Atom contains object (not ReactNode)
202
+ * const user$ = atom({ name: "John", age: 30 });
203
+ * rx(user$); // React error: "Objects are not valid as a React child"
204
+ *
205
+ * // ✅ CORRECT - Use selector to extract ReactNode from object
206
+ * rx(({ read }) => read(user$).name);
207
+ * rx(({ read }) => <UserCard user={read(user$)} />);
208
+ * ```
209
+ *
113
210
  * @example Shorthand - render atom value directly
114
211
  * ```tsx
115
212
  * const count = atom(5);
@@ -124,7 +221,7 @@ import { ContextSelectorFn } from "../core/select";
124
221
  * const count = atom(5);
125
222
  *
126
223
  * function DoubledCounter() {
127
- * return <div>Doubled: {rx(({ get }) => get(count) * 2)}</div>;
224
+ * return <div>Doubled: {rx(({ read }) => read(count) * 2)}</div>;
128
225
  * }
129
226
  * ```
130
227
  *
@@ -136,7 +233,7 @@ import { ContextSelectorFn } from "../core/select";
136
233
  * function FullName() {
137
234
  * return (
138
235
  * <div>
139
- * {rx(({ get }) => `${get(firstName)} ${get(lastName)}`)}
236
+ * {rx(({ read }) => `${read(firstName)} ${read(lastName)}`)}
140
237
  * </div>
141
238
  * );
142
239
  * }
@@ -163,14 +260,14 @@ import { ContextSelectorFn } from "../core/select";
163
260
  * return (
164
261
  * <div>
165
262
  * <header>
166
- * <Suspense fallback="...">{rx(({ get }) => get(userAtom).name)}</Suspense>
263
+ * <Suspense fallback="...">{rx(({ read }) => read(userAtom).name)}</Suspense>
167
264
  * </header>
168
265
  * <main>
169
266
  * <Suspense fallback="...">
170
- * {rx(({ get }) => get(postsAtom).length)} posts
267
+ * {rx(({ read }) => read(postsAtom).length)} posts
171
268
  * </Suspense>
172
269
  * <Suspense fallback="...">
173
- * {rx(({ get }) => get(notificationsAtom).length)} notifications
270
+ * {rx(({ read }) => read(notificationsAtom).length)} notifications
174
271
  * </Suspense>
175
272
  * </main>
176
273
  * </div>
@@ -187,8 +284,8 @@ import { ContextSelectorFn } from "../core/select";
187
284
  * function Info() {
188
285
  * return (
189
286
  * <div>
190
- * {rx(({ get }) =>
191
- * get(showDetails) ? get(details) : get(summary)
287
+ * {rx(({ read }) =>
288
+ * read(showDetails) ? read(details) : read(summary)
192
289
  * )}
193
290
  * </div>
194
291
  * );
@@ -203,7 +300,7 @@ import { ContextSelectorFn } from "../core/select";
203
300
  * return (
204
301
  * <div>
205
302
  * {rx(
206
- * ({ get }) => get(user).name,
303
+ * ({ read }) => read(user).name,
207
304
  * (a, b) => a === b // Only re-render if name string changes
208
305
  * )}
209
306
  * </div>
@@ -221,7 +318,7 @@ import { ContextSelectorFn } from "../core/select";
221
318
  * <Suspense fallback={<Loading />}>
222
319
  * {rx(({ all }) => {
223
320
  * // Use all() to wait for multiple atoms
224
- * const [user, posts] = all([userAtom, postsAtom]);
321
+ * const [user, posts] = all([user$, posts$]);
225
322
  * return <DashboardContent user={user} posts={posts} />;
226
323
  * })}
227
324
  * </Suspense>
@@ -252,23 +349,133 @@ import { ContextSelectorFn } from "../core/select";
252
349
  * ```
253
350
  */
254
351
  // Overload: Pass atom directly to get its value (shorthand)
255
- export function rx<T>(atom: Atom<T>, equals?: Equality<Awaited<T>>): Awaited<T>;
352
+ export function rx<T extends ReactNode | PromiseLike<ReactNode>>(
353
+ atom: Atom<T>,
354
+ options?: Equality<T> | RxOptions<T>
355
+ ): ReactElement;
256
356
 
257
357
  // Overload: Context-based selector function
258
- export function rx<T>(selector: ContextSelectorFn<T>, equals?: Equality<T>): T;
358
+ export function rx<T extends ReactNode | PromiseLike<ReactNode>>(
359
+ selector: ReactiveSelector<T>,
360
+ options?: Equality<T> | RxOptions<T>
361
+ ): ReactElement;
259
362
 
260
363
  export function rx<T>(
261
- selectorOrAtom: ContextSelectorFn<T> | Atom<T>,
262
- equals?: Equality<unknown>
263
- ): T {
364
+ selectorOrAtom: ReactiveSelector<T> | Atom<T>,
365
+ options?: Equality<unknown> | RxOptions<unknown>
366
+ ): ReactElement {
367
+ // Normalize options
368
+ const normalizedOptions: RxOptions<unknown> | undefined =
369
+ options === undefined
370
+ ? undefined
371
+ : typeof options === "object" &&
372
+ options !== null &&
373
+ !Array.isArray(options) &&
374
+ ("equals" in options || "loading" in options || "error" in options)
375
+ ? (options as RxOptions<unknown>)
376
+ : { equals: options as Equality<unknown> };
377
+
264
378
  return (
265
379
  <Rx
266
380
  selectorOrAtom={
267
- selectorOrAtom as ContextSelectorFn<unknown> | Atom<unknown>
381
+ selectorOrAtom as ReactiveSelector<unknown> | Atom<unknown>
268
382
  }
269
- equals={equals}
383
+ options={normalizedOptions}
270
384
  />
271
- ) as unknown as T;
385
+ );
386
+ }
387
+
388
+ /**
389
+ * Internal ErrorBoundary for rx with error handler.
390
+ */
391
+ interface RxErrorBoundaryProps {
392
+ children: ReactNode;
393
+ onError?: (props: { error: unknown }) => ReactNode;
394
+ }
395
+
396
+ interface RxErrorBoundaryState {
397
+ error: unknown | null;
398
+ }
399
+
400
+ class RxErrorBoundary extends Component<
401
+ RxErrorBoundaryProps,
402
+ RxErrorBoundaryState
403
+ > {
404
+ state: RxErrorBoundaryState = { error: null };
405
+
406
+ static getDerivedStateFromError(error: unknown): RxErrorBoundaryState {
407
+ return { error };
408
+ }
409
+
410
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
411
+ componentDidCatch(_error: Error, _errorInfo: ErrorInfo) {
412
+ // Error already captured in state
413
+ }
414
+
415
+ render() {
416
+ if (this.state.error !== null && this.props.onError) {
417
+ return <>{this.props.onError({ error: this.state.error })}</>;
418
+ }
419
+
420
+ if (this.state.error !== null) {
421
+ // No handler - re-throw to parent ErrorBoundary
422
+ throw this.state.error;
423
+ }
424
+
425
+ return this.props.children;
426
+ }
427
+ }
428
+
429
+ /**
430
+ * Internal component that renders the selector value.
431
+ */
432
+ function RxInner(props: {
433
+ selector: ReactiveSelector<unknown>;
434
+ equals?: Equality<unknown>;
435
+ }) {
436
+ const selected = useSelector(props.selector, props.equals);
437
+ return <>{selected ?? null}</>;
438
+ }
439
+
440
+ /**
441
+ * Wrapper component to defer loading() call until actually needed.
442
+ */
443
+ function RxLoadingFallback(props: { render: () => ReactNode }) {
444
+ return <>{props.render()}</>;
445
+ }
446
+
447
+ /**
448
+ * Optional Suspense wrapper - only wraps if fallback is provided.
449
+ */
450
+ function RxSuspenseWrapper(props: {
451
+ fallback?: () => ReactNode;
452
+ children: ReactNode;
453
+ }) {
454
+ if (props.fallback) {
455
+ return (
456
+ <Suspense fallback={<RxLoadingFallback render={props.fallback} />}>
457
+ {props.children}
458
+ </Suspense>
459
+ );
460
+ }
461
+ return <>{props.children}</>;
462
+ }
463
+
464
+ /**
465
+ * Optional ErrorBoundary wrapper - only wraps if onError is provided.
466
+ */
467
+ function RxErrorWrapper(props: {
468
+ onError?: (props: { error: unknown }) => ReactNode;
469
+ children: ReactNode;
470
+ }) {
471
+ if (props.onError) {
472
+ return (
473
+ <RxErrorBoundary onError={props.onError}>
474
+ {props.children}
475
+ </RxErrorBoundary>
476
+ );
477
+ }
478
+ return <>{props.children}</>;
272
479
  }
273
480
 
274
481
  /**
@@ -277,24 +484,48 @@ export function rx<T>(
277
484
  * Memoized with React.memo to ensure:
278
485
  * 1. Parent components don't cause unnecessary re-renders
279
486
  * 2. Only atom changes trigger re-renders
280
- * 3. Props comparison is shallow (selectorOrAtom, equals references)
487
+ * 3. Props comparison is shallow (selectorOrAtom, options references)
281
488
  *
282
489
  * Renders `selected ?? null` to handle null/undefined values gracefully in JSX.
283
490
  */
284
491
  const Rx = memo(
285
492
  function Rx(props: {
286
- selectorOrAtom: ContextSelectorFn<unknown> | Atom<unknown>;
287
- equals?: Equality<unknown>;
493
+ selectorOrAtom: ReactiveSelector<unknown> | Atom<unknown>;
494
+ options?: RxOptions<unknown>;
288
495
  }) {
289
- // Convert atom shorthand to context selector
290
- const selector: ContextSelectorFn<unknown> = isAtom(props.selectorOrAtom)
291
- ? ({ get }) => get(props.selectorOrAtom as Atom<unknown>)
292
- : (props.selectorOrAtom as ContextSelectorFn<unknown>);
496
+ // Store latest selector/atom in ref to avoid stale closures
497
+ const selectorRef = useRef(props.selectorOrAtom);
498
+ selectorRef.current = props.selectorOrAtom;
499
+
500
+ // Compute memoization dependencies:
501
+ // - Atom: always include atom reference for stability
502
+ // - Function + no deps: new object each render (no memoization)
503
+ // - Function + deps: use provided deps for controlled memoization
504
+ const isAtomInput = isAtom(props.selectorOrAtom);
505
+ const userDeps = props.options?.deps;
506
+ const deps = isAtomInput
507
+ ? [props.selectorOrAtom, ...(userDeps ?? [])] // Atom: stable + optional user deps
508
+ : (userDeps ?? [{}]); // Function: user deps or no memoization
509
+
510
+ // Memoized selector that reads from ref to always get latest value
511
+ // eslint-disable-next-line react-hooks/exhaustive-deps
512
+ const selector = useCallback(
513
+ (context: SelectContext) =>
514
+ isAtom(selectorRef.current)
515
+ ? context.read(selectorRef.current as Atom<unknown>)
516
+ : (selectorRef.current as ReactiveSelector<unknown>)(context),
517
+ deps
518
+ );
293
519
 
294
- const selected = useValue(selector, props.equals);
295
- return <>{selected ?? null}</>;
520
+ return (
521
+ <RxErrorWrapper onError={props.options?.error}>
522
+ <RxSuspenseWrapper fallback={props.options?.loading}>
523
+ <RxInner selector={selector} equals={props.options?.equals} />
524
+ </RxSuspenseWrapper>
525
+ </RxErrorWrapper>
526
+ );
296
527
  },
297
528
  (prev, next) =>
298
529
  shallowEqual(prev.selectorOrAtom, next.selectorOrAtom) &&
299
- prev.equals === next.equals
530
+ shallowEqual(prev.options, next.options)
300
531
  );
@@ -28,9 +28,7 @@ describe.each(wrappers)("useAction - $mode", ({ mode, renderHook }) => {
28
28
  it("should execute on mount when lazy is false", () => {
29
29
  const fn = vi.fn(() => "result");
30
30
 
31
- const { result } = renderHook(() =>
32
- useAction(fn, { lazy: false })
33
- );
31
+ const { result } = renderHook(() => useAction(fn, { lazy: false }));
34
32
 
35
33
  // Sync function completes immediately, so status is success
36
34
  expect(result.current.status).toBe("success");
@@ -47,9 +45,7 @@ describe.each(wrappers)("useAction - $mode", ({ mode, renderHook }) => {
47
45
  })
48
46
  );
49
47
 
50
- const { result } = renderHook(() =>
51
- useAction(fn, { lazy: false })
52
- );
48
+ const { result } = renderHook(() => useAction(fn, { lazy: false }));
53
49
 
54
50
  expect(result.current.status).toBe("loading");
55
51
  // In strict mode, effects run twice
@@ -248,7 +244,9 @@ describe.each(wrappers)("useAction - $mode", ({ mode, renderHook }) => {
248
244
  result.current();
249
245
  });
250
246
 
251
- expect(fn).toHaveBeenCalledWith({ signal: expect.any(AbortSignal) });
247
+ expect(fn).toHaveBeenCalledWith(
248
+ expect.objectContaining({ signal: expect.any(AbortSignal) })
249
+ );
252
250
  });
253
251
 
254
252
  it("should create new AbortSignal per call", () => {
@@ -802,7 +800,7 @@ describe.each(wrappers)("useAction - $mode", ({ mode, renderHook }) => {
802
800
  describe("atom deps", () => {
803
801
  it("should execute when atom in deps changes", () => {
804
802
  const userId = atom(1);
805
- const fn = vi.fn(() => `user-${userId.value}`);
803
+ const fn = vi.fn(() => `user-${userId.get()}`);
806
804
 
807
805
  const { result } = renderHook(() =>
808
806
  useAction(fn, { lazy: false, deps: [userId] })
@@ -825,7 +823,7 @@ describe.each(wrappers)("useAction - $mode", ({ mode, renderHook }) => {
825
823
  it("should NOT re-execute when atom value is shallowly equal", () => {
826
824
  // Use atom with shallow equals so it doesn't notify on shallow equal values
827
825
  const config = atom({ page: 1 }, { equals: "shallow" });
828
- const fn = vi.fn(() => `page-${config.value?.page}`);
826
+ const fn = vi.fn(() => `page-${config.get()?.page}`);
829
827
 
830
828
  renderHook(() => useAction(fn, { lazy: false, deps: [config] }));
831
829
 
@@ -847,7 +845,7 @@ describe.each(wrappers)("useAction - $mode", ({ mode, renderHook }) => {
847
845
  // Even if atom notifies, if the selected values are shallow equal,
848
846
  // the effect should not re-run
849
847
  const userId = atom(1);
850
- const fn = vi.fn(() => `user-${userId.value}`);
848
+ const fn = vi.fn(() => `user-${userId.get()}`);
851
849
 
852
850
  renderHook(() => useAction(fn, { lazy: false, deps: [userId] }));
853
851
 
@@ -866,7 +864,7 @@ describe.each(wrappers)("useAction - $mode", ({ mode, renderHook }) => {
866
864
 
867
865
  it("should re-execute when atom value changes (not shallow equal)", () => {
868
866
  const config = atom({ page: 1 });
869
- const fn = vi.fn(() => `page-${config.value?.page}`);
867
+ const fn = vi.fn(() => `page-${config.get()?.page}`);
870
868
 
871
869
  const { result } = renderHook(() =>
872
870
  useAction(fn, { lazy: false, deps: [config] })
@@ -915,7 +913,7 @@ describe.each(wrappers)("useAction - $mode", ({ mode, renderHook }) => {
915
913
 
916
914
  it("should NOT track atoms when lazy is true (default)", () => {
917
915
  const userId = atom(1);
918
- const fn = vi.fn(() => `user-${userId.value}`);
916
+ const fn = vi.fn(() => `user-${userId.get()}`);
919
917
 
920
918
  renderHook(() => useAction(fn, { lazy: true, deps: [userId] }));
921
919
 
@@ -936,7 +934,7 @@ describe.each(wrappers)("useAction - $mode", ({ mode, renderHook }) => {
936
934
  const fn = vi.fn(({ signal }: { signal: AbortSignal }) => {
937
935
  signals.push(signal);
938
936
  return new Promise<string>((resolve) => {
939
- setTimeout(() => resolve(`user-${userId.value}`), 1000);
937
+ setTimeout(() => resolve(`user-${userId.get()}`), 1000);
940
938
  });
941
939
  });
942
940
 
@@ -958,7 +956,7 @@ describe.each(wrappers)("useAction - $mode", ({ mode, renderHook }) => {
958
956
  it("should work with multiple atoms in deps", () => {
959
957
  const userId = atom(1);
960
958
  const orgId = atom(100);
961
- const fn = vi.fn(() => `user-${userId.value}-org-${orgId.value}`);
959
+ const fn = vi.fn(() => `user-${userId.get()}-org-${orgId.get()}`);
962
960
 
963
961
  const { result } = renderHook(() =>
964
962
  useAction(fn, { lazy: false, deps: [userId, orgId] })
@@ -1,7 +1,9 @@
1
1
  import { useReducer, useCallback, useRef, useEffect } from "react";
2
2
  import { isPromiseLike } from "../core/isPromiseLike";
3
- import { useValue } from "./useValue";
3
+ import { useSelector } from "./useSelector";
4
4
  import { isAtom } from "../core/isAtom";
5
+ import { Pipeable } from "../core/types";
6
+ import { withUse } from "../core/withUse";
5
7
 
6
8
  /**
7
9
  * State for an action that hasn't been dispatched yet.
@@ -76,7 +78,7 @@ export interface UseActionOptions {
76
78
  /**
77
79
  * Dependencies array. When lazy is false, re-executes when deps change.
78
80
  * - Regular values: compared by reference (like useEffect deps)
79
- * - Atoms: automatically tracked via useValue, re-executes when atom values change
81
+ * - Atoms: automatically tracked via useSelector, re-executes when atom values change
80
82
  * @default []
81
83
  */
82
84
  deps?: unknown[];
@@ -85,7 +87,7 @@ export interface UseActionOptions {
85
87
  /**
86
88
  * Context passed to the action function.
87
89
  */
88
- export interface ActionContext {
90
+ export interface ActionContext extends Pipeable {
89
91
  /** AbortSignal for cancellation. New signal per dispatch. */
90
92
  signal: AbortSignal;
91
93
  }
@@ -305,11 +307,11 @@ function reducer<T>(
305
307
  *
306
308
  * function UserProfile() {
307
309
  * const fetchUser = useAction(
308
- * async ({ signal }) => fetchUserApi(userIdAtom.value, { signal }),
310
+ * async ({ signal }) => fetchUserApi(userIdAtom.get(), { signal }),
309
311
  * { lazy: false, deps: [userIdAtom] }
310
312
  * );
311
313
  * // Automatically re-fetches when userIdAtom changes
312
- * // Atoms in deps are tracked reactively via useValue
314
+ * // Atoms in deps are tracked reactively via useSelector
313
315
  * }
314
316
  * ```
315
317
  *
@@ -487,9 +489,9 @@ export function useAction<TResult, TLazy extends boolean = true>(
487
489
  // Get atoms from deps for reactive tracking
488
490
  const atomDeps = (lazy ? [] : (deps ?? [])).filter(isAtom);
489
491
 
490
- // Use useValue to track atom deps and get their values for effect deps comparison
491
- const atomValues = useValue(({ get }) => {
492
- return atomDeps.map((atom) => get(atom));
492
+ // Use useSelector to track atom deps and get their values for effect deps comparison
493
+ const atomValues = useSelector(({ read }) => {
494
+ return atomDeps.map((atom) => read(atom));
493
495
  });
494
496
 
495
497
  const dispatch = useCallback((): AbortablePromise<Awaited<TResult>> => {
@@ -506,7 +508,7 @@ export function useAction<TResult, TLazy extends boolean = true>(
506
508
 
507
509
  let result: TResult;
508
510
  try {
509
- result = fnRef.current({ signal: abortController.signal });
511
+ result = fnRef.current(withUse({ signal: abortController.signal }));
510
512
  } catch (error) {
511
513
  // Sync error - update state and return rejected promise
512
514
  dispatchAction({ type: "ERROR", error });