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,250 @@
|
|
|
1
|
+
import { Atom, Equality } from '../core/types';
|
|
2
|
+
import { ContextSelectorFn } from '../core/select';
|
|
3
|
+
/**
|
|
4
|
+
* Reactive inline component that renders atom values directly in JSX.
|
|
5
|
+
*
|
|
6
|
+
* `rx` is a convenience wrapper around `useValue` that returns a memoized
|
|
7
|
+
* React component instead of a value. This enables fine-grained reactivity
|
|
8
|
+
* without creating separate components for each reactive value.
|
|
9
|
+
*
|
|
10
|
+
* ## IMPORTANT: Selector Must Return Synchronous Value
|
|
11
|
+
*
|
|
12
|
+
* **The selector function MUST NOT be async or return a Promise.**
|
|
13
|
+
*
|
|
14
|
+
* ```tsx
|
|
15
|
+
* // ❌ WRONG - Don't use async function
|
|
16
|
+
* rx(async ({ get }) => {
|
|
17
|
+
* const data = await fetch('/api');
|
|
18
|
+
* return data.name;
|
|
19
|
+
* });
|
|
20
|
+
*
|
|
21
|
+
* // ❌ WRONG - Don't return a Promise
|
|
22
|
+
* rx(({ get }) => fetch('/api').then(r => r.json()));
|
|
23
|
+
*
|
|
24
|
+
* // ✅ CORRECT - Create async atom and read with get()
|
|
25
|
+
* const data$ = atom(fetch('/api').then(r => r.json()));
|
|
26
|
+
* rx(({ get }) => get(data$).name); // Suspends until resolved
|
|
27
|
+
* ```
|
|
28
|
+
*
|
|
29
|
+
* ## Why Use `rx`?
|
|
30
|
+
*
|
|
31
|
+
* Without `rx`, you need a separate component to subscribe to an atom:
|
|
32
|
+
* ```tsx
|
|
33
|
+
* function PostsList() {
|
|
34
|
+
* const posts = useValue(postsAtom);
|
|
35
|
+
* return posts.map((post) => <Post post={post} />);
|
|
36
|
+
* }
|
|
37
|
+
*
|
|
38
|
+
* function Page() {
|
|
39
|
+
* return (
|
|
40
|
+
* <Suspense fallback={<Loading />}>
|
|
41
|
+
* <PostsList />
|
|
42
|
+
* </Suspense>
|
|
43
|
+
* );
|
|
44
|
+
* }
|
|
45
|
+
* ```
|
|
46
|
+
*
|
|
47
|
+
* With `rx`, you can subscribe inline:
|
|
48
|
+
* ```tsx
|
|
49
|
+
* function Page() {
|
|
50
|
+
* return (
|
|
51
|
+
* <Suspense fallback={<Loading />}>
|
|
52
|
+
* {rx(({ get }) =>
|
|
53
|
+
* get(postsAtom).map((post) => <Post post={post} />)
|
|
54
|
+
* )}
|
|
55
|
+
* </Suspense>
|
|
56
|
+
* );
|
|
57
|
+
* }
|
|
58
|
+
* ```
|
|
59
|
+
*
|
|
60
|
+
* ## Key Benefits
|
|
61
|
+
*
|
|
62
|
+
* 1. **Fine-grained updates**: Only the `rx` component re-renders when the atom changes,
|
|
63
|
+
* not the parent component
|
|
64
|
+
* 2. **Less boilerplate**: No need to create single-purpose wrapper components
|
|
65
|
+
* 3. **Colocation**: Keep reactive logic inline where it's used
|
|
66
|
+
* 4. **Memoized**: Uses `React.memo` to prevent unnecessary re-renders
|
|
67
|
+
* 5. **Type-safe**: Full TypeScript support with proper type inference
|
|
68
|
+
*
|
|
69
|
+
* ## Async Atoms (Suspense-Style API)
|
|
70
|
+
*
|
|
71
|
+
* `rx` inherits the Suspense-style API from `useValue`:
|
|
72
|
+
* - **Loading state**: The getter throws a Promise (triggers Suspense)
|
|
73
|
+
* - **Error state**: The getter throws the error (triggers ErrorBoundary)
|
|
74
|
+
* - **Resolved state**: The getter returns the value
|
|
75
|
+
*
|
|
76
|
+
* For async atoms, you MUST wrap with `<Suspense>` and `<ErrorBoundary>`:
|
|
77
|
+
* ```tsx
|
|
78
|
+
* function App() {
|
|
79
|
+
* return (
|
|
80
|
+
* <ErrorBoundary fallback={<div>Error!</div>}>
|
|
81
|
+
* <Suspense fallback={<div>Loading...</div>}>
|
|
82
|
+
* {rx(({ get }) => get(userAtom).name)}
|
|
83
|
+
* </Suspense>
|
|
84
|
+
* </ErrorBoundary>
|
|
85
|
+
* );
|
|
86
|
+
* }
|
|
87
|
+
* ```
|
|
88
|
+
*
|
|
89
|
+
* Or catch errors in the selector to handle loading/error inline:
|
|
90
|
+
* ```tsx
|
|
91
|
+
* {rx(({ get }) => {
|
|
92
|
+
* try {
|
|
93
|
+
* return get(userAtom).name;
|
|
94
|
+
* } catch {
|
|
95
|
+
* return "Loading...";
|
|
96
|
+
* }
|
|
97
|
+
* })}
|
|
98
|
+
* ```
|
|
99
|
+
*
|
|
100
|
+
* @template T - The type of the selected/derived value
|
|
101
|
+
* @param selector - Context-based selector function with `{ get, all, any, race, settled }`.
|
|
102
|
+
* Must return sync value, not a Promise.
|
|
103
|
+
* @param equals - Equality function or shorthand ("strict", "shallow", "deep").
|
|
104
|
+
* Defaults to "shallow".
|
|
105
|
+
* @returns A React element that renders the selected value
|
|
106
|
+
* @throws Error if selector returns a Promise or PromiseLike
|
|
107
|
+
*
|
|
108
|
+
* @example Shorthand - render atom value directly
|
|
109
|
+
* ```tsx
|
|
110
|
+
* const count = atom(5);
|
|
111
|
+
*
|
|
112
|
+
* function Counter() {
|
|
113
|
+
* return <div>Count: {rx(count)}</div>;
|
|
114
|
+
* }
|
|
115
|
+
* ```
|
|
116
|
+
*
|
|
117
|
+
* @example Context selector - derive a value
|
|
118
|
+
* ```tsx
|
|
119
|
+
* const count = atom(5);
|
|
120
|
+
*
|
|
121
|
+
* function DoubledCounter() {
|
|
122
|
+
* return <div>Doubled: {rx(({ get }) => get(count) * 2)}</div>;
|
|
123
|
+
* }
|
|
124
|
+
* ```
|
|
125
|
+
*
|
|
126
|
+
* @example Multiple atoms
|
|
127
|
+
* ```tsx
|
|
128
|
+
* const firstName = atom("John");
|
|
129
|
+
* const lastName = atom("Doe");
|
|
130
|
+
*
|
|
131
|
+
* function FullName() {
|
|
132
|
+
* return (
|
|
133
|
+
* <div>
|
|
134
|
+
* {rx(({ get }) => `${get(firstName)} ${get(lastName)}`)}
|
|
135
|
+
* </div>
|
|
136
|
+
* );
|
|
137
|
+
* }
|
|
138
|
+
* ```
|
|
139
|
+
*
|
|
140
|
+
* @example Fine-grained updates - parent doesn't re-render
|
|
141
|
+
* ```tsx
|
|
142
|
+
* const count = atom(0);
|
|
143
|
+
*
|
|
144
|
+
* function Parent() {
|
|
145
|
+
* console.log("Parent renders once");
|
|
146
|
+
* return (
|
|
147
|
+
* <div>
|
|
148
|
+
* {rx(count)} {/* Only this re-renders when count changes *\/}
|
|
149
|
+
* <button onClick={() => count.set((n) => n + 1)}>+</button>
|
|
150
|
+
* </div>
|
|
151
|
+
* );
|
|
152
|
+
* }
|
|
153
|
+
* ```
|
|
154
|
+
*
|
|
155
|
+
* @example Multiple subscriptions in one component
|
|
156
|
+
* ```tsx
|
|
157
|
+
* function Dashboard() {
|
|
158
|
+
* return (
|
|
159
|
+
* <div>
|
|
160
|
+
* <header>
|
|
161
|
+
* <Suspense fallback="...">{rx(({ get }) => get(userAtom).name)}</Suspense>
|
|
162
|
+
* </header>
|
|
163
|
+
* <main>
|
|
164
|
+
* <Suspense fallback="...">
|
|
165
|
+
* {rx(({ get }) => get(postsAtom).length)} posts
|
|
166
|
+
* </Suspense>
|
|
167
|
+
* <Suspense fallback="...">
|
|
168
|
+
* {rx(({ get }) => get(notificationsAtom).length)} notifications
|
|
169
|
+
* </Suspense>
|
|
170
|
+
* </main>
|
|
171
|
+
* </div>
|
|
172
|
+
* );
|
|
173
|
+
* }
|
|
174
|
+
* ```
|
|
175
|
+
*
|
|
176
|
+
* @example Conditional dependencies - only subscribes to accessed atoms
|
|
177
|
+
* ```tsx
|
|
178
|
+
* const showDetails = atom(false);
|
|
179
|
+
* const summary = atom("Brief info");
|
|
180
|
+
* const details = atom("Detailed info");
|
|
181
|
+
*
|
|
182
|
+
* function Info() {
|
|
183
|
+
* return (
|
|
184
|
+
* <div>
|
|
185
|
+
* {rx(({ get }) =>
|
|
186
|
+
* get(showDetails) ? get(details) : get(summary)
|
|
187
|
+
* )}
|
|
188
|
+
* </div>
|
|
189
|
+
* );
|
|
190
|
+
* }
|
|
191
|
+
* ```
|
|
192
|
+
*
|
|
193
|
+
* @example With custom equality
|
|
194
|
+
* ```tsx
|
|
195
|
+
* const user = atom({ id: 1, name: "John" });
|
|
196
|
+
*
|
|
197
|
+
* function UserName() {
|
|
198
|
+
* return (
|
|
199
|
+
* <div>
|
|
200
|
+
* {rx(
|
|
201
|
+
* ({ get }) => get(user).name,
|
|
202
|
+
* (a, b) => a === b // Only re-render if name string changes
|
|
203
|
+
* )}
|
|
204
|
+
* </div>
|
|
205
|
+
* );
|
|
206
|
+
* }
|
|
207
|
+
* ```
|
|
208
|
+
*
|
|
209
|
+
* @example Combining multiple async atoms with async utilities
|
|
210
|
+
* ```tsx
|
|
211
|
+
* const userAtom = atom(fetchUser());
|
|
212
|
+
* const postsAtom = atom(fetchPosts());
|
|
213
|
+
*
|
|
214
|
+
* function Dashboard() {
|
|
215
|
+
* return (
|
|
216
|
+
* <Suspense fallback={<Loading />}>
|
|
217
|
+
* {rx(({ all }) => {
|
|
218
|
+
* // Use all() to wait for multiple atoms
|
|
219
|
+
* const [user, posts] = all([userAtom, postsAtom]);
|
|
220
|
+
* return <DashboardContent user={user} posts={posts} />;
|
|
221
|
+
* })}
|
|
222
|
+
* </Suspense>
|
|
223
|
+
* );
|
|
224
|
+
* }
|
|
225
|
+
* ```
|
|
226
|
+
*
|
|
227
|
+
* @example Using settled for partial failures
|
|
228
|
+
* ```tsx
|
|
229
|
+
* const userAtom = atom(fetchUser());
|
|
230
|
+
* const postsAtom = atom(fetchPosts());
|
|
231
|
+
*
|
|
232
|
+
* function Dashboard() {
|
|
233
|
+
* return (
|
|
234
|
+
* <Suspense fallback={<Loading />}>
|
|
235
|
+
* {rx(({ settled }) => {
|
|
236
|
+
* const [userResult, postsResult] = settled([userAtom, postsAtom]);
|
|
237
|
+
* return (
|
|
238
|
+
* <DashboardContent
|
|
239
|
+
* user={userResult.status === 'resolved' ? userResult.value : null}
|
|
240
|
+
* posts={postsResult.status === 'resolved' ? postsResult.value : []}
|
|
241
|
+
* />
|
|
242
|
+
* );
|
|
243
|
+
* })}
|
|
244
|
+
* </Suspense>
|
|
245
|
+
* );
|
|
246
|
+
* }
|
|
247
|
+
* ```
|
|
248
|
+
*/
|
|
249
|
+
export declare function rx<T>(atom: Atom<T>, equals?: Equality<Awaited<T>>): Awaited<T>;
|
|
250
|
+
export declare function rx<T>(selector: ContextSelectorFn<T>, equals?: Equality<T>): T;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { default as React } from 'react';
|
|
2
|
+
import { render, renderHook, RenderHookOptions } from '@testing-library/react';
|
|
3
|
+
export declare const wrappers: {
|
|
4
|
+
mode: "normal" | "strict";
|
|
5
|
+
Wrapper: React.FC<{
|
|
6
|
+
children: React.ReactNode;
|
|
7
|
+
}>;
|
|
8
|
+
render: (ui: React.ReactElement) => ReturnType<typeof render>;
|
|
9
|
+
renderHook: <TResult, TProps>(render: (props: TProps) => TResult, options?: RenderHookOptions<TProps>) => ReturnType<typeof renderHook<TResult, TProps>>;
|
|
10
|
+
}[];
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State for an action that hasn't been dispatched yet.
|
|
3
|
+
*/
|
|
4
|
+
export type ActionIdleState = {
|
|
5
|
+
readonly status: "idle";
|
|
6
|
+
readonly result: undefined;
|
|
7
|
+
readonly error: undefined;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* State for an action that is currently executing.
|
|
11
|
+
*/
|
|
12
|
+
export type ActionLoadingState = {
|
|
13
|
+
readonly status: "loading";
|
|
14
|
+
readonly result: undefined;
|
|
15
|
+
readonly error: undefined;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* State for an action that completed successfully.
|
|
19
|
+
*/
|
|
20
|
+
export type ActionSuccessState<T> = {
|
|
21
|
+
readonly status: "success";
|
|
22
|
+
readonly result: T;
|
|
23
|
+
readonly error: undefined;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* State for an action that failed with an error.
|
|
27
|
+
*/
|
|
28
|
+
export type ActionErrorState = {
|
|
29
|
+
readonly status: "error";
|
|
30
|
+
readonly result: undefined;
|
|
31
|
+
readonly error: unknown;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Union of all possible action states.
|
|
35
|
+
*/
|
|
36
|
+
export type ActionState<T> = ActionIdleState | ActionLoadingState | ActionSuccessState<T> | ActionErrorState;
|
|
37
|
+
/**
|
|
38
|
+
* Action state without idle (used when lazy is false).
|
|
39
|
+
*/
|
|
40
|
+
export type ActionStateWithoutIdle<T> = Exclude<ActionState<T>, ActionIdleState>;
|
|
41
|
+
/**
|
|
42
|
+
* A promise with an abort method for manual cancellation.
|
|
43
|
+
*/
|
|
44
|
+
export type AbortablePromise<T> = PromiseLike<T> & {
|
|
45
|
+
abort: () => void;
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Options for useAction hook.
|
|
49
|
+
*/
|
|
50
|
+
export interface UseActionOptions {
|
|
51
|
+
/**
|
|
52
|
+
* If true, only one request runs at a time - previous requests are aborted.
|
|
53
|
+
* Also aborts on unmount and reset().
|
|
54
|
+
* - `exclusive: true` (default) - Aborts previous request on re-call, unmount, deps change, reset()
|
|
55
|
+
* - `exclusive: false` - Allows concurrent requests, manual abort only via abort() or promise.abort()
|
|
56
|
+
* @default true
|
|
57
|
+
*/
|
|
58
|
+
exclusive?: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* Dependencies array. When lazy is false, re-executes when deps change.
|
|
61
|
+
* - Regular values: compared by reference (like useEffect deps)
|
|
62
|
+
* - Atoms: automatically tracked via useValue, re-executes when atom values change
|
|
63
|
+
* @default []
|
|
64
|
+
*/
|
|
65
|
+
deps?: unknown[];
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Context passed to the action function.
|
|
69
|
+
*/
|
|
70
|
+
export interface ActionContext {
|
|
71
|
+
/** AbortSignal for cancellation. New signal per dispatch. */
|
|
72
|
+
signal: AbortSignal;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* API methods for controlling the action.
|
|
76
|
+
*/
|
|
77
|
+
export type ActionApi = {
|
|
78
|
+
/** Abort the current in-flight request. */
|
|
79
|
+
abort: () => void;
|
|
80
|
+
/** Reset state back to idle. Respects exclusive setting. */
|
|
81
|
+
reset: () => void;
|
|
82
|
+
};
|
|
83
|
+
/**
|
|
84
|
+
* Dispatch function type - callable and returns AbortablePromise.
|
|
85
|
+
*/
|
|
86
|
+
export type ActionDispatch<T> = () => AbortablePromise<T extends PromiseLike<infer U> ? U : T>;
|
|
87
|
+
/**
|
|
88
|
+
* Return type for useAction - a callable dispatch function with state and API attached.
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* ```ts
|
|
92
|
+
* const fetchPosts = useAction(async () => api.getPosts());
|
|
93
|
+
*
|
|
94
|
+
* // Call it like a function
|
|
95
|
+
* const posts = await fetchPosts();
|
|
96
|
+
*
|
|
97
|
+
* // Access state via properties
|
|
98
|
+
* fetchPosts.loading // boolean
|
|
99
|
+
* fetchPosts.status // "idle" | "loading" | "success" | "error"
|
|
100
|
+
* fetchPosts.result // Post[] | undefined
|
|
101
|
+
* fetchPosts.error // unknown
|
|
102
|
+
* fetchPosts.abort() // cancel current request
|
|
103
|
+
* fetchPosts.reset() // reset state to idle
|
|
104
|
+
* ```
|
|
105
|
+
*/
|
|
106
|
+
export type Action<TResult, TLazy extends boolean = true> = ActionDispatch<TResult> & (TLazy extends true ? ActionState<Awaited<TResult>> : ActionStateWithoutIdle<Awaited<TResult>>) & ActionApi;
|
|
107
|
+
/**
|
|
108
|
+
* React hook for handling async actions with loading/error states and abort support.
|
|
109
|
+
*
|
|
110
|
+
* `useAction` provides a complete solution for managing async operations in React,
|
|
111
|
+
* with automatic state tracking, cancellation support, and race condition handling.
|
|
112
|
+
*
|
|
113
|
+
* Returns a callable function with state properties attached, making it easy to
|
|
114
|
+
* manage multiple actions in a single component:
|
|
115
|
+
*
|
|
116
|
+
* ```tsx
|
|
117
|
+
* const fetchUser = useAction(() => api.getUser(id));
|
|
118
|
+
* const updateUser = useAction(() => api.updateUser(id, data));
|
|
119
|
+
* const deleteUser = useAction(() => api.deleteUser(id));
|
|
120
|
+
*
|
|
121
|
+
* // Call directly - no need to destructure
|
|
122
|
+
* await fetchUser();
|
|
123
|
+
*
|
|
124
|
+
* // Access state via properties
|
|
125
|
+
* fetchUser.loading // boolean
|
|
126
|
+
* fetchUser.result // User | undefined
|
|
127
|
+
* fetchUser.error // unknown
|
|
128
|
+
* fetchUser.status // "idle" | "loading" | "success" | "error"
|
|
129
|
+
* fetchUser.abort() // cancel request
|
|
130
|
+
* fetchUser.reset() // reset to idle state
|
|
131
|
+
* ```
|
|
132
|
+
*
|
|
133
|
+
* ## Key Features
|
|
134
|
+
*
|
|
135
|
+
* 1. **Automatic state management**: Tracks idle → loading → success/error transitions
|
|
136
|
+
* 2. **AbortSignal support**: Built-in cancellation via AbortController
|
|
137
|
+
* 3. **Exclusive mode**: Only one request at a time - previous aborted automatically (configurable)
|
|
138
|
+
* 4. **Lazy/eager execution**: Wait for manual call or execute on mount (configurable)
|
|
139
|
+
* 5. **Stale closure prevention**: Ignores outdated results from cancelled requests
|
|
140
|
+
* 6. **Atom deps support**: Atoms in deps array are reactively tracked
|
|
141
|
+
*
|
|
142
|
+
* ## State Machine
|
|
143
|
+
*
|
|
144
|
+
* ```
|
|
145
|
+
* ┌──────┐ dispatch() ┌─────────┐ success ┌─────────┐
|
|
146
|
+
* │ idle │ ───────────► │ loading │ ────────► │ success │
|
|
147
|
+
* └──────┘ └─────────┘ └─────────┘
|
|
148
|
+
* │
|
|
149
|
+
* │ error
|
|
150
|
+
* ▼
|
|
151
|
+
* ┌─────────┐
|
|
152
|
+
* │ error │
|
|
153
|
+
* └─────────┘
|
|
154
|
+
* ```
|
|
155
|
+
*
|
|
156
|
+
* ## Exclusive Mode (exclusive option)
|
|
157
|
+
*
|
|
158
|
+
* The `exclusive` option controls whether only one request can run at a time:
|
|
159
|
+
*
|
|
160
|
+
* | Trigger | exclusive: true (default) | exclusive: false |
|
|
161
|
+
* |---------|---------------------------|------------------|
|
|
162
|
+
* | Call action again | ✅ Aborts previous | ❌ No abort |
|
|
163
|
+
* | Component unmounts | ✅ Aborts current | ❌ No abort |
|
|
164
|
+
* | Deps change (lazy: false) | ✅ Aborts previous | ❌ No abort |
|
|
165
|
+
* | `reset()` called | ✅ Aborts current | ❌ No abort |
|
|
166
|
+
* | `abort()` called | ✅ Always aborts | ✅ Always aborts |
|
|
167
|
+
* | `promise.abort()` called | ✅ Always aborts | ✅ Always aborts |
|
|
168
|
+
*
|
|
169
|
+
* ## Reset Behavior
|
|
170
|
+
*
|
|
171
|
+
* `reset()` clears the state back to idle and respects `exclusive`:
|
|
172
|
+
* - **exclusive: true**: Aborts any in-flight request, then resets to idle
|
|
173
|
+
* - **exclusive: false**: Only resets state (request continues in background)
|
|
174
|
+
*
|
|
175
|
+
* ## Race Condition Handling
|
|
176
|
+
*
|
|
177
|
+
* When a new dispatch starts before the previous completes:
|
|
178
|
+
* - Previous request's result is ignored (even if it resolves)
|
|
179
|
+
* - Only the latest request's result updates state
|
|
180
|
+
* - This prevents stale data from overwriting fresh data
|
|
181
|
+
*
|
|
182
|
+
* @template TResult - The return type of the action function
|
|
183
|
+
* @param fn - Action function receiving `{ signal: AbortSignal }`. Can be sync or async.
|
|
184
|
+
* @param options - Configuration options
|
|
185
|
+
* @param options.lazy - If true (default), waits for manual call. If false, executes on mount.
|
|
186
|
+
* @param options.exclusive - If true (default), aborts previous request on re-call/unmount.
|
|
187
|
+
* @param options.deps - Dependencies for lazy: false mode. Atoms are reactively tracked.
|
|
188
|
+
* @returns A callable dispatch function with state and API properties attached
|
|
189
|
+
*
|
|
190
|
+
* @example Basic usage - manual dispatch
|
|
191
|
+
* ```tsx
|
|
192
|
+
* function UserProfile({ userId }) {
|
|
193
|
+
* const fetchUser = useAction(async ({ signal }) => {
|
|
194
|
+
* const response = await fetch(`/api/users/${userId}`, { signal });
|
|
195
|
+
* if (!response.ok) throw new Error('Failed to fetch');
|
|
196
|
+
* return response.json();
|
|
197
|
+
* });
|
|
198
|
+
*
|
|
199
|
+
* return (
|
|
200
|
+
* <div>
|
|
201
|
+
* {fetchUser.status === 'idle' && <button onClick={fetchUser}>Load User</button>}
|
|
202
|
+
* {fetchUser.status === 'loading' && <Spinner />}
|
|
203
|
+
* {fetchUser.status === 'success' && <div>{fetchUser.result.name}</div>}
|
|
204
|
+
* {fetchUser.status === 'error' && <div>Error: {fetchUser.error.message}</div>}
|
|
205
|
+
* </div>
|
|
206
|
+
* );
|
|
207
|
+
* }
|
|
208
|
+
* ```
|
|
209
|
+
*
|
|
210
|
+
* @example Eager execution on mount and deps change
|
|
211
|
+
* ```tsx
|
|
212
|
+
* function UserProfile({ userId }) {
|
|
213
|
+
* const fetchUser = useAction(
|
|
214
|
+
* async ({ signal }) => {
|
|
215
|
+
* const response = await fetch(`/api/users/${userId}`, { signal });
|
|
216
|
+
* return response.json();
|
|
217
|
+
* },
|
|
218
|
+
* { lazy: false, deps: [userId] }
|
|
219
|
+
* );
|
|
220
|
+
* // Fetches automatically on mount
|
|
221
|
+
* // Re-fetches when userId changes
|
|
222
|
+
* // Previous request is aborted when userId changes
|
|
223
|
+
* }
|
|
224
|
+
* ```
|
|
225
|
+
*
|
|
226
|
+
* @example Eager execution with atom deps
|
|
227
|
+
* ```tsx
|
|
228
|
+
* const userIdAtom = atom(1);
|
|
229
|
+
*
|
|
230
|
+
* function UserProfile() {
|
|
231
|
+
* const fetchUser = useAction(
|
|
232
|
+
* async ({ signal }) => fetchUserApi(userIdAtom.value, { signal }),
|
|
233
|
+
* { lazy: false, deps: [userIdAtom] }
|
|
234
|
+
* );
|
|
235
|
+
* // Automatically re-fetches when userIdAtom changes
|
|
236
|
+
* // Atoms in deps are tracked reactively via useValue
|
|
237
|
+
* }
|
|
238
|
+
* ```
|
|
239
|
+
*
|
|
240
|
+
* @example Allow concurrent requests (non-exclusive)
|
|
241
|
+
* ```tsx
|
|
242
|
+
* function SearchResults() {
|
|
243
|
+
* const search = useAction(
|
|
244
|
+
* async ({ signal }) => searchAPI(query, { signal }),
|
|
245
|
+
* { exclusive: false }
|
|
246
|
+
* );
|
|
247
|
+
*
|
|
248
|
+
* return (
|
|
249
|
+
* <div>
|
|
250
|
+
* <button onClick={search}>Search</button>
|
|
251
|
+
* <button onClick={search.abort} disabled={search.status !== 'loading'}>
|
|
252
|
+
* Cancel
|
|
253
|
+
* </button>
|
|
254
|
+
* </div>
|
|
255
|
+
* );
|
|
256
|
+
* }
|
|
257
|
+
* ```
|
|
258
|
+
*
|
|
259
|
+
* @example Abort via returned promise
|
|
260
|
+
* ```tsx
|
|
261
|
+
* const longTask = useAction(async ({ signal }) => longRunningTask({ signal }));
|
|
262
|
+
*
|
|
263
|
+
* const handleClick = () => {
|
|
264
|
+
* const promise = longTask();
|
|
265
|
+
*
|
|
266
|
+
* // Abort after 5 seconds
|
|
267
|
+
* setTimeout(() => promise.abort(), 5000);
|
|
268
|
+
*
|
|
269
|
+
* // Or await the result
|
|
270
|
+
* try {
|
|
271
|
+
* const result = await promise;
|
|
272
|
+
* } catch (error) {
|
|
273
|
+
* if (error.name === 'AbortError') {
|
|
274
|
+
* console.log('Request was cancelled');
|
|
275
|
+
* }
|
|
276
|
+
* }
|
|
277
|
+
* };
|
|
278
|
+
* ```
|
|
279
|
+
*
|
|
280
|
+
* @example Chaining multiple actions
|
|
281
|
+
* ```tsx
|
|
282
|
+
* function CreateUserForm() {
|
|
283
|
+
* const createUser = useAction(({ signal }) => api.createUser(data, { signal }));
|
|
284
|
+
* const sendWelcomeEmail = useAction(({ signal }) => api.sendEmail(email, { signal }));
|
|
285
|
+
*
|
|
286
|
+
* const handleSubmit = async () => {
|
|
287
|
+
* try {
|
|
288
|
+
* const user = await createUser();
|
|
289
|
+
* await sendWelcomeEmail();
|
|
290
|
+
* toast.success('User created and email sent!');
|
|
291
|
+
* } catch (error) {
|
|
292
|
+
* toast.error('Operation failed');
|
|
293
|
+
* }
|
|
294
|
+
* };
|
|
295
|
+
*
|
|
296
|
+
* const isLoading = createUser.status === 'loading' || sendWelcomeEmail.status === 'loading';
|
|
297
|
+
*
|
|
298
|
+
* return <button onClick={handleSubmit} disabled={isLoading}>Create User</button>;
|
|
299
|
+
* }
|
|
300
|
+
* ```
|
|
301
|
+
*
|
|
302
|
+
* @example Sync action (non-async function)
|
|
303
|
+
* ```tsx
|
|
304
|
+
* const compute = useAction(({ signal }) => {
|
|
305
|
+
* // Sync computation - still works!
|
|
306
|
+
* return computeExpensiveValue(data);
|
|
307
|
+
* });
|
|
308
|
+
* // compute() returns a promise that resolves immediately
|
|
309
|
+
* ```
|
|
310
|
+
*
|
|
311
|
+
* @example Form submission with validation
|
|
312
|
+
* ```tsx
|
|
313
|
+
* function ContactForm() {
|
|
314
|
+
* const [formData, setFormData] = useState({ name: '', email: '' });
|
|
315
|
+
*
|
|
316
|
+
* const submit = useAction(async ({ signal }) => {
|
|
317
|
+
* // Validate
|
|
318
|
+
* if (!formData.name) throw new Error('Name required');
|
|
319
|
+
*
|
|
320
|
+
* // Submit
|
|
321
|
+
* const response = await fetch('/api/contact', {
|
|
322
|
+
* method: 'POST',
|
|
323
|
+
* body: JSON.stringify(formData),
|
|
324
|
+
* signal,
|
|
325
|
+
* });
|
|
326
|
+
*
|
|
327
|
+
* if (!response.ok) throw new Error('Submission failed');
|
|
328
|
+
* return response.json();
|
|
329
|
+
* });
|
|
330
|
+
*
|
|
331
|
+
* return (
|
|
332
|
+
* <form onSubmit={(e) => { e.preventDefault(); submit(); }}>
|
|
333
|
+
* <input value={formData.name} onChange={...} />
|
|
334
|
+
* <input value={formData.email} onChange={...} />
|
|
335
|
+
* <button disabled={submit.status === 'loading'}>
|
|
336
|
+
* {submit.status === 'loading' ? 'Submitting...' : 'Submit'}
|
|
337
|
+
* </button>
|
|
338
|
+
* {submit.status === 'error' && <p className="error">{submit.error.message}</p>}
|
|
339
|
+
* {submit.status === 'success' && <p className="success">Submitted!</p>}
|
|
340
|
+
* </form>
|
|
341
|
+
* );
|
|
342
|
+
* }
|
|
343
|
+
* ```
|
|
344
|
+
*
|
|
345
|
+
* @example Reset after success or error
|
|
346
|
+
* ```tsx
|
|
347
|
+
* function SubmitForm() {
|
|
348
|
+
* const submit = useAction(async () => api.submit(data));
|
|
349
|
+
*
|
|
350
|
+
* if (submit.status === 'success') {
|
|
351
|
+
* return (
|
|
352
|
+
* <div>
|
|
353
|
+
* <p>Success!</p>
|
|
354
|
+
* <button onClick={submit.reset}>Submit Another</button>
|
|
355
|
+
* </div>
|
|
356
|
+
* );
|
|
357
|
+
* }
|
|
358
|
+
*
|
|
359
|
+
* if (submit.status === 'error') {
|
|
360
|
+
* return (
|
|
361
|
+
* <div>
|
|
362
|
+
* <p>Error: {submit.error.message}</p>
|
|
363
|
+
* <button onClick={submit.reset}>Dismiss</button>
|
|
364
|
+
* <button onClick={submit}>Retry</button>
|
|
365
|
+
* </div>
|
|
366
|
+
* );
|
|
367
|
+
* }
|
|
368
|
+
*
|
|
369
|
+
* return <button onClick={submit} disabled={submit.status === 'loading'}>Submit</button>;
|
|
370
|
+
* }
|
|
371
|
+
* ```
|
|
372
|
+
*/
|
|
373
|
+
export declare function useAction<TResult, TLazy extends boolean = true>(fn: (context: ActionContext) => TResult, options?: UseActionOptions & {
|
|
374
|
+
/**
|
|
375
|
+
* If true, waits for manual call to execute. If false, executes on mount and when deps change.
|
|
376
|
+
* - `lazy: true` (default) - Action starts in "idle" state, waits for you to call it
|
|
377
|
+
* - `lazy: false` - Action executes immediately on mount and re-executes when deps change
|
|
378
|
+
* @default true
|
|
379
|
+
*/
|
|
380
|
+
lazy?: TLazy;
|
|
381
|
+
}): Action<TResult, TLazy>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|