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.
- package/README.md +1666 -0
- package/coverage/base.css +224 -0
- package/coverage/block-navigation.js +87 -0
- package/coverage/clover.xml +1440 -0
- package/coverage/coverage-final.json +14 -0
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +131 -0
- package/coverage/prettify.css +1 -0
- package/coverage/prettify.js +2 -0
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +210 -0
- package/coverage/src/core/atom.ts.html +889 -0
- package/coverage/src/core/batch.ts.html +223 -0
- package/coverage/src/core/define.ts.html +805 -0
- package/coverage/src/core/emitter.ts.html +919 -0
- package/coverage/src/core/equality.ts.html +631 -0
- package/coverage/src/core/hook.ts.html +460 -0
- package/coverage/src/core/index.html +281 -0
- package/coverage/src/core/isAtom.ts.html +100 -0
- package/coverage/src/core/isPromiseLike.ts.html +133 -0
- package/coverage/src/core/onCreateHook.ts.html +136 -0
- package/coverage/src/core/scheduleNotifyHook.ts.html +94 -0
- package/coverage/src/core/types.ts.html +523 -0
- package/coverage/src/core/withUse.ts.html +253 -0
- package/coverage/src/index.html +116 -0
- package/coverage/src/index.ts.html +106 -0
- package/dist/core/atom.d.ts +63 -0
- package/dist/core/atom.test.d.ts +1 -0
- package/dist/core/atomState.d.ts +104 -0
- package/dist/core/atomState.test.d.ts +1 -0
- package/dist/core/batch.d.ts +126 -0
- package/dist/core/batch.test.d.ts +1 -0
- package/dist/core/define.d.ts +173 -0
- package/dist/core/define.test.d.ts +1 -0
- package/dist/core/derived.d.ts +102 -0
- package/dist/core/derived.test.d.ts +1 -0
- package/dist/core/effect.d.ts +120 -0
- package/dist/core/effect.test.d.ts +1 -0
- package/dist/core/emitter.d.ts +237 -0
- package/dist/core/emitter.test.d.ts +1 -0
- package/dist/core/equality.d.ts +62 -0
- package/dist/core/equality.test.d.ts +1 -0
- package/dist/core/hook.d.ts +134 -0
- package/dist/core/hook.test.d.ts +1 -0
- package/dist/core/isAtom.d.ts +9 -0
- package/dist/core/isPromiseLike.d.ts +9 -0
- package/dist/core/isPromiseLike.test.d.ts +1 -0
- package/dist/core/onCreateHook.d.ts +79 -0
- package/dist/core/promiseCache.d.ts +134 -0
- package/dist/core/promiseCache.test.d.ts +1 -0
- package/dist/core/scheduleNotifyHook.d.ts +51 -0
- package/dist/core/select.d.ts +151 -0
- package/dist/core/selector.test.d.ts +1 -0
- package/dist/core/types.d.ts +279 -0
- package/dist/core/withUse.d.ts +38 -0
- package/dist/core/withUse.test.d.ts +1 -0
- package/dist/index-2ok7ilik.js +1217 -0
- package/dist/index-B_5SFzfl.cjs +1 -0
- package/dist/index.cjs +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +20 -0
- package/dist/index.test.d.ts +1 -0
- package/dist/react/index.cjs +30 -0
- package/dist/react/index.d.ts +7 -0
- package/dist/react/index.js +823 -0
- package/dist/react/rx.d.ts +250 -0
- package/dist/react/rx.test.d.ts +1 -0
- package/dist/react/strictModeTest.d.ts +10 -0
- package/dist/react/useAction.d.ts +381 -0
- package/dist/react/useAction.test.d.ts +1 -0
- package/dist/react/useStable.d.ts +183 -0
- package/dist/react/useStable.test.d.ts +1 -0
- package/dist/react/useValue.d.ts +134 -0
- package/dist/react/useValue.test.d.ts +1 -0
- package/package.json +57 -0
- package/scripts/publish.js +198 -0
- package/src/core/atom.test.ts +369 -0
- package/src/core/atom.ts +189 -0
- package/src/core/atomState.test.ts +342 -0
- package/src/core/atomState.ts +256 -0
- package/src/core/batch.test.ts +257 -0
- package/src/core/batch.ts +172 -0
- package/src/core/define.test.ts +342 -0
- package/src/core/define.ts +243 -0
- package/src/core/derived.test.ts +381 -0
- package/src/core/derived.ts +339 -0
- package/src/core/effect.test.ts +196 -0
- package/src/core/effect.ts +184 -0
- package/src/core/emitter.test.ts +364 -0
- package/src/core/emitter.ts +392 -0
- package/src/core/equality.test.ts +392 -0
- package/src/core/equality.ts +182 -0
- package/src/core/hook.test.ts +227 -0
- package/src/core/hook.ts +177 -0
- package/src/core/isAtom.ts +27 -0
- package/src/core/isPromiseLike.test.ts +72 -0
- package/src/core/isPromiseLike.ts +16 -0
- package/src/core/onCreateHook.ts +92 -0
- package/src/core/promiseCache.test.ts +239 -0
- package/src/core/promiseCache.ts +279 -0
- package/src/core/scheduleNotifyHook.ts +53 -0
- package/src/core/select.ts +454 -0
- package/src/core/selector.test.ts +257 -0
- package/src/core/types.ts +311 -0
- package/src/core/withUse.test.ts +249 -0
- package/src/core/withUse.ts +56 -0
- package/src/index.test.ts +80 -0
- package/src/index.ts +51 -0
- package/src/react/index.ts +20 -0
- package/src/react/rx.test.tsx +416 -0
- package/src/react/rx.tsx +300 -0
- package/src/react/strictModeTest.tsx +71 -0
- package/src/react/useAction.test.ts +989 -0
- package/src/react/useAction.ts +605 -0
- package/src/react/useStable.test.ts +553 -0
- package/src/react/useStable.ts +288 -0
- package/src/react/useValue.test.ts +182 -0
- package/src/react/useValue.ts +261 -0
- package/tsconfig.json +9 -0
- package/v2.md +725 -0
- 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
|
+
}
|