atomirx 0.0.1

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 (121) hide show
  1. package/README.md +1666 -0
  2. package/coverage/base.css +224 -0
  3. package/coverage/block-navigation.js +87 -0
  4. package/coverage/clover.xml +1440 -0
  5. package/coverage/coverage-final.json +14 -0
  6. package/coverage/favicon.png +0 -0
  7. package/coverage/index.html +131 -0
  8. package/coverage/prettify.css +1 -0
  9. package/coverage/prettify.js +2 -0
  10. package/coverage/sort-arrow-sprite.png +0 -0
  11. package/coverage/sorter.js +210 -0
  12. package/coverage/src/core/atom.ts.html +889 -0
  13. package/coverage/src/core/batch.ts.html +223 -0
  14. package/coverage/src/core/define.ts.html +805 -0
  15. package/coverage/src/core/emitter.ts.html +919 -0
  16. package/coverage/src/core/equality.ts.html +631 -0
  17. package/coverage/src/core/hook.ts.html +460 -0
  18. package/coverage/src/core/index.html +281 -0
  19. package/coverage/src/core/isAtom.ts.html +100 -0
  20. package/coverage/src/core/isPromiseLike.ts.html +133 -0
  21. package/coverage/src/core/onCreateHook.ts.html +136 -0
  22. package/coverage/src/core/scheduleNotifyHook.ts.html +94 -0
  23. package/coverage/src/core/types.ts.html +523 -0
  24. package/coverage/src/core/withUse.ts.html +253 -0
  25. package/coverage/src/index.html +116 -0
  26. package/coverage/src/index.ts.html +106 -0
  27. package/dist/core/atom.d.ts +63 -0
  28. package/dist/core/atom.test.d.ts +1 -0
  29. package/dist/core/atomState.d.ts +104 -0
  30. package/dist/core/atomState.test.d.ts +1 -0
  31. package/dist/core/batch.d.ts +126 -0
  32. package/dist/core/batch.test.d.ts +1 -0
  33. package/dist/core/define.d.ts +173 -0
  34. package/dist/core/define.test.d.ts +1 -0
  35. package/dist/core/derived.d.ts +102 -0
  36. package/dist/core/derived.test.d.ts +1 -0
  37. package/dist/core/effect.d.ts +120 -0
  38. package/dist/core/effect.test.d.ts +1 -0
  39. package/dist/core/emitter.d.ts +237 -0
  40. package/dist/core/emitter.test.d.ts +1 -0
  41. package/dist/core/equality.d.ts +62 -0
  42. package/dist/core/equality.test.d.ts +1 -0
  43. package/dist/core/hook.d.ts +134 -0
  44. package/dist/core/hook.test.d.ts +1 -0
  45. package/dist/core/isAtom.d.ts +9 -0
  46. package/dist/core/isPromiseLike.d.ts +9 -0
  47. package/dist/core/isPromiseLike.test.d.ts +1 -0
  48. package/dist/core/onCreateHook.d.ts +79 -0
  49. package/dist/core/promiseCache.d.ts +134 -0
  50. package/dist/core/promiseCache.test.d.ts +1 -0
  51. package/dist/core/scheduleNotifyHook.d.ts +51 -0
  52. package/dist/core/select.d.ts +151 -0
  53. package/dist/core/selector.test.d.ts +1 -0
  54. package/dist/core/types.d.ts +279 -0
  55. package/dist/core/withUse.d.ts +38 -0
  56. package/dist/core/withUse.test.d.ts +1 -0
  57. package/dist/index-2ok7ilik.js +1217 -0
  58. package/dist/index-B_5SFzfl.cjs +1 -0
  59. package/dist/index.cjs +1 -0
  60. package/dist/index.d.ts +14 -0
  61. package/dist/index.js +20 -0
  62. package/dist/index.test.d.ts +1 -0
  63. package/dist/react/index.cjs +30 -0
  64. package/dist/react/index.d.ts +7 -0
  65. package/dist/react/index.js +823 -0
  66. package/dist/react/rx.d.ts +250 -0
  67. package/dist/react/rx.test.d.ts +1 -0
  68. package/dist/react/strictModeTest.d.ts +10 -0
  69. package/dist/react/useAction.d.ts +381 -0
  70. package/dist/react/useAction.test.d.ts +1 -0
  71. package/dist/react/useStable.d.ts +183 -0
  72. package/dist/react/useStable.test.d.ts +1 -0
  73. package/dist/react/useValue.d.ts +134 -0
  74. package/dist/react/useValue.test.d.ts +1 -0
  75. package/package.json +57 -0
  76. package/scripts/publish.js +198 -0
  77. package/src/core/atom.test.ts +369 -0
  78. package/src/core/atom.ts +189 -0
  79. package/src/core/atomState.test.ts +342 -0
  80. package/src/core/atomState.ts +256 -0
  81. package/src/core/batch.test.ts +257 -0
  82. package/src/core/batch.ts +172 -0
  83. package/src/core/define.test.ts +342 -0
  84. package/src/core/define.ts +243 -0
  85. package/src/core/derived.test.ts +381 -0
  86. package/src/core/derived.ts +339 -0
  87. package/src/core/effect.test.ts +196 -0
  88. package/src/core/effect.ts +184 -0
  89. package/src/core/emitter.test.ts +364 -0
  90. package/src/core/emitter.ts +392 -0
  91. package/src/core/equality.test.ts +392 -0
  92. package/src/core/equality.ts +182 -0
  93. package/src/core/hook.test.ts +227 -0
  94. package/src/core/hook.ts +177 -0
  95. package/src/core/isAtom.ts +27 -0
  96. package/src/core/isPromiseLike.test.ts +72 -0
  97. package/src/core/isPromiseLike.ts +16 -0
  98. package/src/core/onCreateHook.ts +92 -0
  99. package/src/core/promiseCache.test.ts +239 -0
  100. package/src/core/promiseCache.ts +279 -0
  101. package/src/core/scheduleNotifyHook.ts +53 -0
  102. package/src/core/select.ts +454 -0
  103. package/src/core/selector.test.ts +257 -0
  104. package/src/core/types.ts +311 -0
  105. package/src/core/withUse.test.ts +249 -0
  106. package/src/core/withUse.ts +56 -0
  107. package/src/index.test.ts +80 -0
  108. package/src/index.ts +51 -0
  109. package/src/react/index.ts +20 -0
  110. package/src/react/rx.test.tsx +416 -0
  111. package/src/react/rx.tsx +300 -0
  112. package/src/react/strictModeTest.tsx +71 -0
  113. package/src/react/useAction.test.ts +989 -0
  114. package/src/react/useAction.ts +605 -0
  115. package/src/react/useStable.test.ts +553 -0
  116. package/src/react/useStable.ts +288 -0
  117. package/src/react/useValue.test.ts +182 -0
  118. package/src/react/useValue.ts +261 -0
  119. package/tsconfig.json +9 -0
  120. package/v2.md +725 -0
  121. package/vite.config.ts +39 -0
@@ -0,0 +1,605 @@
1
+ import { useReducer, useCallback, useRef, useEffect } from "react";
2
+ import { isPromiseLike } from "../core/isPromiseLike";
3
+ import { useValue } from "./useValue";
4
+ import { isAtom } from "../core/isAtom";
5
+
6
+ /**
7
+ * State for an action that hasn't been dispatched yet.
8
+ */
9
+ export type ActionIdleState = {
10
+ readonly status: "idle";
11
+ readonly result: undefined;
12
+ readonly error: undefined;
13
+ };
14
+
15
+ /**
16
+ * State for an action that is currently executing.
17
+ */
18
+ export type ActionLoadingState = {
19
+ readonly status: "loading";
20
+ readonly result: undefined;
21
+ readonly error: undefined;
22
+ };
23
+
24
+ /**
25
+ * State for an action that completed successfully.
26
+ */
27
+ export type ActionSuccessState<T> = {
28
+ readonly status: "success";
29
+ readonly result: T;
30
+ readonly error: undefined;
31
+ };
32
+
33
+ /**
34
+ * State for an action that failed with an error.
35
+ */
36
+ export type ActionErrorState = {
37
+ readonly status: "error";
38
+ readonly result: undefined;
39
+ readonly error: unknown;
40
+ };
41
+
42
+ /**
43
+ * Union of all possible action states.
44
+ */
45
+ export type ActionState<T> =
46
+ | ActionIdleState
47
+ | ActionLoadingState
48
+ | ActionSuccessState<T>
49
+ | ActionErrorState;
50
+
51
+ /**
52
+ * Action state without idle (used when lazy is false).
53
+ */
54
+ export type ActionStateWithoutIdle<T> = Exclude<
55
+ ActionState<T>,
56
+ ActionIdleState
57
+ >;
58
+
59
+ /**
60
+ * A promise with an abort method for manual cancellation.
61
+ */
62
+ export type AbortablePromise<T> = PromiseLike<T> & { abort: () => void };
63
+
64
+ /**
65
+ * Options for useAction hook.
66
+ */
67
+ export interface UseActionOptions {
68
+ /**
69
+ * If true, only one request runs at a time - previous requests are aborted.
70
+ * Also aborts on unmount and reset().
71
+ * - `exclusive: true` (default) - Aborts previous request on re-call, unmount, deps change, reset()
72
+ * - `exclusive: false` - Allows concurrent requests, manual abort only via abort() or promise.abort()
73
+ * @default true
74
+ */
75
+ exclusive?: boolean;
76
+ /**
77
+ * Dependencies array. When lazy is false, re-executes when deps change.
78
+ * - Regular values: compared by reference (like useEffect deps)
79
+ * - Atoms: automatically tracked via useValue, re-executes when atom values change
80
+ * @default []
81
+ */
82
+ deps?: unknown[];
83
+ }
84
+
85
+ /**
86
+ * Context passed to the action function.
87
+ */
88
+ export interface ActionContext {
89
+ /** AbortSignal for cancellation. New signal per dispatch. */
90
+ signal: AbortSignal;
91
+ }
92
+
93
+ /**
94
+ * API methods for controlling the action.
95
+ */
96
+ export type ActionApi = {
97
+ /** Abort the current in-flight request. */
98
+ abort: () => void;
99
+ /** Reset state back to idle. Respects exclusive setting. */
100
+ reset: () => void;
101
+ };
102
+
103
+ /**
104
+ * Dispatch function type - callable and returns AbortablePromise.
105
+ */
106
+ export type ActionDispatch<T> = () => AbortablePromise<
107
+ T extends PromiseLike<infer U> ? U : T
108
+ >;
109
+
110
+ /**
111
+ * Return type for useAction - a callable dispatch function with state and API attached.
112
+ *
113
+ * @example
114
+ * ```ts
115
+ * const fetchPosts = useAction(async () => api.getPosts());
116
+ *
117
+ * // Call it like a function
118
+ * const posts = await fetchPosts();
119
+ *
120
+ * // Access state via properties
121
+ * fetchPosts.loading // boolean
122
+ * fetchPosts.status // "idle" | "loading" | "success" | "error"
123
+ * fetchPosts.result // Post[] | undefined
124
+ * fetchPosts.error // unknown
125
+ * fetchPosts.abort() // cancel current request
126
+ * fetchPosts.reset() // reset state to idle
127
+ * ```
128
+ */
129
+ export type Action<
130
+ TResult,
131
+ TLazy extends boolean = true,
132
+ > = ActionDispatch<TResult> &
133
+ (TLazy extends true
134
+ ? ActionState<Awaited<TResult>>
135
+ : ActionStateWithoutIdle<Awaited<TResult>>) &
136
+ ActionApi;
137
+
138
+ // Reducer action types
139
+ type ReducerAction<T> =
140
+ | { type: "START" }
141
+ | { type: "SUCCESS"; result: T }
142
+ | { type: "ERROR"; error: unknown }
143
+ | { type: "RESET" };
144
+
145
+ const IDLE_STATE: ActionIdleState = {
146
+ status: "idle",
147
+ result: undefined,
148
+ error: undefined,
149
+ };
150
+
151
+ const LOADING_STATE: ActionLoadingState = {
152
+ status: "loading",
153
+ result: undefined,
154
+ error: undefined,
155
+ };
156
+
157
+ function reducer<T>(
158
+ state: ActionState<T>,
159
+ action: ReducerAction<T>
160
+ ): ActionState<T> {
161
+ switch (action.type) {
162
+ case "START":
163
+ return LOADING_STATE;
164
+ case "SUCCESS":
165
+ return {
166
+ status: "success",
167
+ result: action.result,
168
+ error: undefined,
169
+ };
170
+ case "ERROR":
171
+ return {
172
+ status: "error",
173
+ result: undefined,
174
+ error: action.error,
175
+ };
176
+ case "RESET":
177
+ return IDLE_STATE;
178
+ default:
179
+ return state;
180
+ }
181
+ }
182
+
183
+ /**
184
+ * React hook for handling async actions with loading/error states and abort support.
185
+ *
186
+ * `useAction` provides a complete solution for managing async operations in React,
187
+ * with automatic state tracking, cancellation support, and race condition handling.
188
+ *
189
+ * Returns a callable function with state properties attached, making it easy to
190
+ * manage multiple actions in a single component:
191
+ *
192
+ * ```tsx
193
+ * const fetchUser = useAction(() => api.getUser(id));
194
+ * const updateUser = useAction(() => api.updateUser(id, data));
195
+ * const deleteUser = useAction(() => api.deleteUser(id));
196
+ *
197
+ * // Call directly - no need to destructure
198
+ * await fetchUser();
199
+ *
200
+ * // Access state via properties
201
+ * fetchUser.loading // boolean
202
+ * fetchUser.result // User | undefined
203
+ * fetchUser.error // unknown
204
+ * fetchUser.status // "idle" | "loading" | "success" | "error"
205
+ * fetchUser.abort() // cancel request
206
+ * fetchUser.reset() // reset to idle state
207
+ * ```
208
+ *
209
+ * ## Key Features
210
+ *
211
+ * 1. **Automatic state management**: Tracks idle → loading → success/error transitions
212
+ * 2. **AbortSignal support**: Built-in cancellation via AbortController
213
+ * 3. **Exclusive mode**: Only one request at a time - previous aborted automatically (configurable)
214
+ * 4. **Lazy/eager execution**: Wait for manual call or execute on mount (configurable)
215
+ * 5. **Stale closure prevention**: Ignores outdated results from cancelled requests
216
+ * 6. **Atom deps support**: Atoms in deps array are reactively tracked
217
+ *
218
+ * ## State Machine
219
+ *
220
+ * ```
221
+ * ┌──────┐ dispatch() ┌─────────┐ success ┌─────────┐
222
+ * │ idle │ ───────────► │ loading │ ────────► │ success │
223
+ * └──────┘ └─────────┘ └─────────┘
224
+ * │
225
+ * │ error
226
+ * ▼
227
+ * ┌─────────┐
228
+ * │ error │
229
+ * └─────────┘
230
+ * ```
231
+ *
232
+ * ## Exclusive Mode (exclusive option)
233
+ *
234
+ * The `exclusive` option controls whether only one request can run at a time:
235
+ *
236
+ * | Trigger | exclusive: true (default) | exclusive: false |
237
+ * |---------|---------------------------|------------------|
238
+ * | Call action again | ✅ Aborts previous | ❌ No abort |
239
+ * | Component unmounts | ✅ Aborts current | ❌ No abort |
240
+ * | Deps change (lazy: false) | ✅ Aborts previous | ❌ No abort |
241
+ * | `reset()` called | ✅ Aborts current | ❌ No abort |
242
+ * | `abort()` called | ✅ Always aborts | ✅ Always aborts |
243
+ * | `promise.abort()` called | ✅ Always aborts | ✅ Always aborts |
244
+ *
245
+ * ## Reset Behavior
246
+ *
247
+ * `reset()` clears the state back to idle and respects `exclusive`:
248
+ * - **exclusive: true**: Aborts any in-flight request, then resets to idle
249
+ * - **exclusive: false**: Only resets state (request continues in background)
250
+ *
251
+ * ## Race Condition Handling
252
+ *
253
+ * When a new dispatch starts before the previous completes:
254
+ * - Previous request's result is ignored (even if it resolves)
255
+ * - Only the latest request's result updates state
256
+ * - This prevents stale data from overwriting fresh data
257
+ *
258
+ * @template TResult - The return type of the action function
259
+ * @param fn - Action function receiving `{ signal: AbortSignal }`. Can be sync or async.
260
+ * @param options - Configuration options
261
+ * @param options.lazy - If true (default), waits for manual call. If false, executes on mount.
262
+ * @param options.exclusive - If true (default), aborts previous request on re-call/unmount.
263
+ * @param options.deps - Dependencies for lazy: false mode. Atoms are reactively tracked.
264
+ * @returns A callable dispatch function with state and API properties attached
265
+ *
266
+ * @example Basic usage - manual dispatch
267
+ * ```tsx
268
+ * function UserProfile({ userId }) {
269
+ * const fetchUser = useAction(async ({ signal }) => {
270
+ * const response = await fetch(`/api/users/${userId}`, { signal });
271
+ * if (!response.ok) throw new Error('Failed to fetch');
272
+ * return response.json();
273
+ * });
274
+ *
275
+ * return (
276
+ * <div>
277
+ * {fetchUser.status === 'idle' && <button onClick={fetchUser}>Load User</button>}
278
+ * {fetchUser.status === 'loading' && <Spinner />}
279
+ * {fetchUser.status === 'success' && <div>{fetchUser.result.name}</div>}
280
+ * {fetchUser.status === 'error' && <div>Error: {fetchUser.error.message}</div>}
281
+ * </div>
282
+ * );
283
+ * }
284
+ * ```
285
+ *
286
+ * @example Eager execution on mount and deps change
287
+ * ```tsx
288
+ * function UserProfile({ userId }) {
289
+ * const fetchUser = useAction(
290
+ * async ({ signal }) => {
291
+ * const response = await fetch(`/api/users/${userId}`, { signal });
292
+ * return response.json();
293
+ * },
294
+ * { lazy: false, deps: [userId] }
295
+ * );
296
+ * // Fetches automatically on mount
297
+ * // Re-fetches when userId changes
298
+ * // Previous request is aborted when userId changes
299
+ * }
300
+ * ```
301
+ *
302
+ * @example Eager execution with atom deps
303
+ * ```tsx
304
+ * const userIdAtom = atom(1);
305
+ *
306
+ * function UserProfile() {
307
+ * const fetchUser = useAction(
308
+ * async ({ signal }) => fetchUserApi(userIdAtom.value, { signal }),
309
+ * { lazy: false, deps: [userIdAtom] }
310
+ * );
311
+ * // Automatically re-fetches when userIdAtom changes
312
+ * // Atoms in deps are tracked reactively via useValue
313
+ * }
314
+ * ```
315
+ *
316
+ * @example Allow concurrent requests (non-exclusive)
317
+ * ```tsx
318
+ * function SearchResults() {
319
+ * const search = useAction(
320
+ * async ({ signal }) => searchAPI(query, { signal }),
321
+ * { exclusive: false }
322
+ * );
323
+ *
324
+ * return (
325
+ * <div>
326
+ * <button onClick={search}>Search</button>
327
+ * <button onClick={search.abort} disabled={search.status !== 'loading'}>
328
+ * Cancel
329
+ * </button>
330
+ * </div>
331
+ * );
332
+ * }
333
+ * ```
334
+ *
335
+ * @example Abort via returned promise
336
+ * ```tsx
337
+ * const longTask = useAction(async ({ signal }) => longRunningTask({ signal }));
338
+ *
339
+ * const handleClick = () => {
340
+ * const promise = longTask();
341
+ *
342
+ * // Abort after 5 seconds
343
+ * setTimeout(() => promise.abort(), 5000);
344
+ *
345
+ * // Or await the result
346
+ * try {
347
+ * const result = await promise;
348
+ * } catch (error) {
349
+ * if (error.name === 'AbortError') {
350
+ * console.log('Request was cancelled');
351
+ * }
352
+ * }
353
+ * };
354
+ * ```
355
+ *
356
+ * @example Chaining multiple actions
357
+ * ```tsx
358
+ * function CreateUserForm() {
359
+ * const createUser = useAction(({ signal }) => api.createUser(data, { signal }));
360
+ * const sendWelcomeEmail = useAction(({ signal }) => api.sendEmail(email, { signal }));
361
+ *
362
+ * const handleSubmit = async () => {
363
+ * try {
364
+ * const user = await createUser();
365
+ * await sendWelcomeEmail();
366
+ * toast.success('User created and email sent!');
367
+ * } catch (error) {
368
+ * toast.error('Operation failed');
369
+ * }
370
+ * };
371
+ *
372
+ * const isLoading = createUser.status === 'loading' || sendWelcomeEmail.status === 'loading';
373
+ *
374
+ * return <button onClick={handleSubmit} disabled={isLoading}>Create User</button>;
375
+ * }
376
+ * ```
377
+ *
378
+ * @example Sync action (non-async function)
379
+ * ```tsx
380
+ * const compute = useAction(({ signal }) => {
381
+ * // Sync computation - still works!
382
+ * return computeExpensiveValue(data);
383
+ * });
384
+ * // compute() returns a promise that resolves immediately
385
+ * ```
386
+ *
387
+ * @example Form submission with validation
388
+ * ```tsx
389
+ * function ContactForm() {
390
+ * const [formData, setFormData] = useState({ name: '', email: '' });
391
+ *
392
+ * const submit = useAction(async ({ signal }) => {
393
+ * // Validate
394
+ * if (!formData.name) throw new Error('Name required');
395
+ *
396
+ * // Submit
397
+ * const response = await fetch('/api/contact', {
398
+ * method: 'POST',
399
+ * body: JSON.stringify(formData),
400
+ * signal,
401
+ * });
402
+ *
403
+ * if (!response.ok) throw new Error('Submission failed');
404
+ * return response.json();
405
+ * });
406
+ *
407
+ * return (
408
+ * <form onSubmit={(e) => { e.preventDefault(); submit(); }}>
409
+ * <input value={formData.name} onChange={...} />
410
+ * <input value={formData.email} onChange={...} />
411
+ * <button disabled={submit.status === 'loading'}>
412
+ * {submit.status === 'loading' ? 'Submitting...' : 'Submit'}
413
+ * </button>
414
+ * {submit.status === 'error' && <p className="error">{submit.error.message}</p>}
415
+ * {submit.status === 'success' && <p className="success">Submitted!</p>}
416
+ * </form>
417
+ * );
418
+ * }
419
+ * ```
420
+ *
421
+ * @example Reset after success or error
422
+ * ```tsx
423
+ * function SubmitForm() {
424
+ * const submit = useAction(async () => api.submit(data));
425
+ *
426
+ * if (submit.status === 'success') {
427
+ * return (
428
+ * <div>
429
+ * <p>Success!</p>
430
+ * <button onClick={submit.reset}>Submit Another</button>
431
+ * </div>
432
+ * );
433
+ * }
434
+ *
435
+ * if (submit.status === 'error') {
436
+ * return (
437
+ * <div>
438
+ * <p>Error: {submit.error.message}</p>
439
+ * <button onClick={submit.reset}>Dismiss</button>
440
+ * <button onClick={submit}>Retry</button>
441
+ * </div>
442
+ * );
443
+ * }
444
+ *
445
+ * return <button onClick={submit} disabled={submit.status === 'loading'}>Submit</button>;
446
+ * }
447
+ * ```
448
+ */
449
+ export function useAction<TResult, TLazy extends boolean = true>(
450
+ fn: (context: ActionContext) => TResult,
451
+ options: UseActionOptions & {
452
+ /**
453
+ * If true, waits for manual call to execute. If false, executes on mount and when deps change.
454
+ * - `lazy: true` (default) - Action starts in "idle" state, waits for you to call it
455
+ * - `lazy: false` - Action executes immediately on mount and re-executes when deps change
456
+ * @default true
457
+ */
458
+ lazy?: TLazy;
459
+ } = {}
460
+ ): Action<TResult, TLazy> {
461
+ const { lazy = true, exclusive = true, deps = [] } = options;
462
+
463
+ // Use loading as initial state when lazy is false (eager execution)
464
+ const initialState = lazy ? IDLE_STATE : LOADING_STATE;
465
+ const [state, dispatchAction] = useReducer(
466
+ reducer<Awaited<TResult>>,
467
+ initialState
468
+ );
469
+
470
+ // Track current abort controller for auto-abort and stale result detection
471
+ const currentAbortControllerRef = useRef<AbortController | null>(null);
472
+ // Store fn ref to avoid stale closures in callbacks
473
+ const fnRef = useRef(fn);
474
+ fnRef.current = fn;
475
+
476
+ // Abort current request - returns true if there was something to abort
477
+ const abortCurrent = useCallback(() => {
478
+ const controller = currentAbortControllerRef.current;
479
+ if (controller) {
480
+ controller.abort();
481
+ currentAbortControllerRef.current = null;
482
+ return true;
483
+ }
484
+ return false;
485
+ }, []);
486
+
487
+ // Get atoms from deps for reactive tracking
488
+ const atomDeps = (lazy ? [] : (deps ?? [])).filter(isAtom);
489
+
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));
493
+ });
494
+
495
+ const dispatch = useCallback((): AbortablePromise<Awaited<TResult>> => {
496
+ // Abort previous if exclusive mode
497
+ if (exclusive) {
498
+ abortCurrent();
499
+ }
500
+
501
+ // Create new abort controller for this dispatch
502
+ const abortController = new AbortController();
503
+ currentAbortControllerRef.current = abortController;
504
+
505
+ dispatchAction({ type: "START" });
506
+
507
+ let result: TResult;
508
+ try {
509
+ result = fnRef.current({ signal: abortController.signal });
510
+ } catch (error) {
511
+ // Sync error - update state and return rejected promise
512
+ dispatchAction({ type: "ERROR", error });
513
+ return Object.assign(Promise.reject(error), {
514
+ abort: () => abortController.abort(),
515
+ });
516
+ }
517
+
518
+ // Handle async result
519
+ if (isPromiseLike(result)) {
520
+ const promise = result as PromiseLike<Awaited<TResult>>;
521
+
522
+ promise.then(
523
+ (value) => {
524
+ // Ignore stale results (a new dispatch has started)
525
+ if (currentAbortControllerRef.current !== abortController) return;
526
+ dispatchAction({ type: "SUCCESS", result: value });
527
+ },
528
+ (error) => {
529
+ // Check if this was an abort error
530
+ const isAbortError =
531
+ error instanceof DOMException && error.name === "AbortError";
532
+
533
+ // If aborted, always dispatch the error to exit loading state
534
+ if (isAbortError) {
535
+ // Only dispatch if this abort controller was the current one
536
+ // (i.e., it was manually aborted, not replaced by a new dispatch)
537
+ if (
538
+ currentAbortControllerRef.current === null ||
539
+ currentAbortControllerRef.current === abortController
540
+ ) {
541
+ dispatchAction({ type: "ERROR", error });
542
+ }
543
+ return;
544
+ }
545
+
546
+ // For non-abort errors, ignore stale results
547
+ if (currentAbortControllerRef.current !== abortController) return;
548
+ dispatchAction({ type: "ERROR", error });
549
+ }
550
+ );
551
+
552
+ // Return abortable promise
553
+ return Object.assign(promise, {
554
+ abort: () => {
555
+ abortController.abort();
556
+ // Clear ref so we know it was manually aborted
557
+ if (currentAbortControllerRef.current === abortController) {
558
+ currentAbortControllerRef.current = null;
559
+ }
560
+ },
561
+ }) as AbortablePromise<Awaited<TResult>>;
562
+ }
563
+
564
+ // Sync success - wrap in resolved promise
565
+ dispatchAction({ type: "SUCCESS", result: result as Awaited<TResult> });
566
+ return Object.assign(Promise.resolve(result as Awaited<TResult>), {
567
+ abort: () => abortController.abort(),
568
+ });
569
+ }, [exclusive, abortCurrent]);
570
+
571
+ // Get non-atom deps for effect comparison
572
+ const nonAtomDeps = (deps ?? []).filter((dep) => !isAtom(dep));
573
+
574
+ // Eager execution effect (when lazy is false)
575
+ useEffect(() => {
576
+ if (!lazy) {
577
+ dispatch();
578
+ }
579
+ // eslint-disable-next-line react-hooks/exhaustive-deps
580
+ }, [lazy, ...atomValues, ...nonAtomDeps]);
581
+
582
+ // Cleanup on unmount
583
+ useEffect(() => {
584
+ return () => {
585
+ if (exclusive) {
586
+ abortCurrent();
587
+ }
588
+ };
589
+ }, [exclusive, abortCurrent]);
590
+
591
+ // Reset state to idle, respects exclusive setting
592
+ const reset = useCallback(() => {
593
+ if (exclusive) {
594
+ abortCurrent();
595
+ }
596
+ dispatchAction({ type: "RESET" });
597
+ }, [exclusive, abortCurrent]);
598
+
599
+ // Combine dispatch function with state and API
600
+ return Object.assign(dispatch, {
601
+ ...state,
602
+ abort: abortCurrent,
603
+ reset,
604
+ }) as Action<TResult, TLazy>;
605
+ }