rexfect 0.0.7

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 (190) hide show
  1. package/README.md +1756 -0
  2. package/dist/abortableContext.d.ts +3 -0
  3. package/dist/abortableContext.d.ts.map +1 -0
  4. package/dist/abortableContext.js +48 -0
  5. package/dist/abortableContext.js.map +1 -0
  6. package/dist/action.d.ts +64 -0
  7. package/dist/action.d.ts.map +1 -0
  8. package/dist/action.js +208 -0
  9. package/dist/action.js.map +1 -0
  10. package/dist/action.test.d.ts +2 -0
  11. package/dist/action.test.d.ts.map +1 -0
  12. package/dist/action.test.js +189 -0
  13. package/dist/action.test.js.map +1 -0
  14. package/dist/async/abortable-guard.d.ts +25 -0
  15. package/dist/async/abortable-guard.d.ts.map +1 -0
  16. package/dist/async/abortable-guard.js +33 -0
  17. package/dist/async/abortable-guard.js.map +1 -0
  18. package/dist/async/abortable.d.ts +331 -0
  19. package/dist/async/abortable.d.ts.map +1 -0
  20. package/dist/async/abortable.js +410 -0
  21. package/dist/async/abortable.js.map +1 -0
  22. package/dist/async/abortable.test.d.ts +2 -0
  23. package/dist/async/abortable.test.d.ts.map +1 -0
  24. package/dist/async/abortable.test.js +535 -0
  25. package/dist/async/abortable.test.js.map +1 -0
  26. package/dist/async/abortable.typeCheck.d.ts +8 -0
  27. package/dist/async/abortable.typeCheck.d.ts.map +1 -0
  28. package/dist/async/abortable.typeCheck.js +138 -0
  29. package/dist/async/abortable.typeCheck.js.map +1 -0
  30. package/dist/async/async.d.ts +18 -0
  31. package/dist/async/async.d.ts.map +1 -0
  32. package/dist/async/async.js +20 -0
  33. package/dist/async/async.js.map +1 -0
  34. package/dist/async/index.d.ts +15 -0
  35. package/dist/async/index.d.ts.map +1 -0
  36. package/dist/async/index.js +13 -0
  37. package/dist/async/index.js.map +1 -0
  38. package/dist/async/loadable.d.ts +7 -0
  39. package/dist/async/loadable.d.ts.map +1 -0
  40. package/dist/async/loadable.js +52 -0
  41. package/dist/async/loadable.js.map +1 -0
  42. package/dist/async/loadable.test.d.ts +2 -0
  43. package/dist/async/loadable.test.d.ts.map +1 -0
  44. package/dist/async/loadable.test.js +322 -0
  45. package/dist/async/loadable.test.js.map +1 -0
  46. package/dist/async/promiseCache.d.ts +14 -0
  47. package/dist/async/promiseCache.d.ts.map +1 -0
  48. package/dist/async/promiseCache.js +29 -0
  49. package/dist/async/promiseCache.js.map +1 -0
  50. package/dist/async/read.d.ts +120 -0
  51. package/dist/async/read.d.ts.map +1 -0
  52. package/dist/async/read.js +286 -0
  53. package/dist/async/read.js.map +1 -0
  54. package/dist/async/read.test.d.ts +2 -0
  55. package/dist/async/read.test.d.ts.map +1 -0
  56. package/dist/async/read.test.js +419 -0
  57. package/dist/async/read.test.js.map +1 -0
  58. package/dist/async/read.typeCheck.d.ts +6 -0
  59. package/dist/async/read.typeCheck.d.ts.map +1 -0
  60. package/dist/async/read.typeCheck.js +101 -0
  61. package/dist/async/read.typeCheck.js.map +1 -0
  62. package/dist/async/safe.d.ts +230 -0
  63. package/dist/async/safe.d.ts.map +1 -0
  64. package/dist/async/safe.js +247 -0
  65. package/dist/async/safe.js.map +1 -0
  66. package/dist/async/safe.test.d.ts +2 -0
  67. package/dist/async/safe.test.d.ts.map +1 -0
  68. package/dist/async/safe.test.js +447 -0
  69. package/dist/async/safe.test.js.map +1 -0
  70. package/dist/async/utils.d.ts +17 -0
  71. package/dist/async/utils.d.ts.map +1 -0
  72. package/dist/async/utils.js +38 -0
  73. package/dist/async/utils.js.map +1 -0
  74. package/dist/async/wait.d.ts +120 -0
  75. package/dist/async/wait.d.ts.map +1 -0
  76. package/dist/async/wait.js +112 -0
  77. package/dist/async/wait.js.map +1 -0
  78. package/dist/async/wait.test.d.ts +2 -0
  79. package/dist/async/wait.test.d.ts.map +1 -0
  80. package/dist/async/wait.test.js +122 -0
  81. package/dist/async/wait.test.js.map +1 -0
  82. package/dist/async/wait.typeCheck.d.ts +6 -0
  83. package/dist/async/wait.typeCheck.d.ts.map +1 -0
  84. package/dist/async/wait.typeCheck.js +104 -0
  85. package/dist/async/wait.typeCheck.js.map +1 -0
  86. package/dist/atom.d.ts +46 -0
  87. package/dist/atom.d.ts.map +1 -0
  88. package/dist/atom.js +86 -0
  89. package/dist/atom.js.map +1 -0
  90. package/dist/atom.test.d.ts +2 -0
  91. package/dist/atom.test.d.ts.map +1 -0
  92. package/dist/atom.test.js +75 -0
  93. package/dist/atom.test.js.map +1 -0
  94. package/dist/batch.d.ts +15 -0
  95. package/dist/batch.d.ts.map +1 -0
  96. package/dist/batch.js +45 -0
  97. package/dist/batch.js.map +1 -0
  98. package/dist/defer.d.ts +56 -0
  99. package/dist/defer.d.ts.map +1 -0
  100. package/dist/defer.js +49 -0
  101. package/dist/defer.js.map +1 -0
  102. package/dist/effect.d.ts +91 -0
  103. package/dist/effect.d.ts.map +1 -0
  104. package/dist/effect.js +311 -0
  105. package/dist/effect.js.map +1 -0
  106. package/dist/effect.test.d.ts +2 -0
  107. package/dist/effect.test.d.ts.map +1 -0
  108. package/dist/effect.test.js +123 -0
  109. package/dist/effect.test.js.map +1 -0
  110. package/dist/emitter.d.ts +129 -0
  111. package/dist/emitter.d.ts.map +1 -0
  112. package/dist/emitter.js +164 -0
  113. package/dist/emitter.js.map +1 -0
  114. package/dist/emitter.test.d.ts +2 -0
  115. package/dist/emitter.test.d.ts.map +1 -0
  116. package/dist/emitter.test.js +259 -0
  117. package/dist/emitter.test.js.map +1 -0
  118. package/dist/equality.d.ts +66 -0
  119. package/dist/equality.d.ts.map +1 -0
  120. package/dist/equality.js +145 -0
  121. package/dist/equality.js.map +1 -0
  122. package/dist/event.d.ts +18 -0
  123. package/dist/event.d.ts.map +1 -0
  124. package/dist/event.js +166 -0
  125. package/dist/event.js.map +1 -0
  126. package/dist/event.test.d.ts +2 -0
  127. package/dist/event.test.d.ts.map +1 -0
  128. package/dist/event.test.js +167 -0
  129. package/dist/event.test.js.map +1 -0
  130. package/dist/hooks.d.ts +152 -0
  131. package/dist/hooks.d.ts.map +1 -0
  132. package/dist/hooks.js +122 -0
  133. package/dist/hooks.js.map +1 -0
  134. package/dist/hooks.test.d.ts +2 -0
  135. package/dist/hooks.test.d.ts.map +1 -0
  136. package/dist/hooks.test.js +99 -0
  137. package/dist/hooks.test.js.map +1 -0
  138. package/dist/index.d.ts +33 -0
  139. package/dist/index.d.ts.map +1 -0
  140. package/dist/index.js +35 -0
  141. package/dist/index.js.map +1 -0
  142. package/dist/isPromiseLike.d.ts +10 -0
  143. package/dist/isPromiseLike.d.ts.map +1 -0
  144. package/dist/isPromiseLike.js +15 -0
  145. package/dist/isPromiseLike.js.map +1 -0
  146. package/dist/pick.d.ts +22 -0
  147. package/dist/pick.d.ts.map +1 -0
  148. package/dist/pick.js +46 -0
  149. package/dist/pick.js.map +1 -0
  150. package/dist/react/index.d.ts +8 -0
  151. package/dist/react/index.d.ts.map +1 -0
  152. package/dist/react/index.js +8 -0
  153. package/dist/react/index.js.map +1 -0
  154. package/dist/react/useRx.d.ts +14 -0
  155. package/dist/react/useRx.d.ts.map +1 -0
  156. package/dist/react/useRx.js +110 -0
  157. package/dist/react/useRx.js.map +1 -0
  158. package/dist/react/useRx.test.d.ts +2 -0
  159. package/dist/react/useRx.test.d.ts.map +1 -0
  160. package/dist/react/useRx.test.js +457 -0
  161. package/dist/react/useRx.test.js.map +1 -0
  162. package/dist/strictModeTest.d.ts +11 -0
  163. package/dist/strictModeTest.d.ts.map +1 -0
  164. package/dist/strictModeTest.js +41 -0
  165. package/dist/strictModeTest.js.map +1 -0
  166. package/dist/types.d.ts +606 -0
  167. package/dist/types.d.ts.map +1 -0
  168. package/dist/types.js +5 -0
  169. package/dist/types.js.map +1 -0
  170. package/dist/untrack.d.ts +14 -0
  171. package/dist/untrack.d.ts.map +1 -0
  172. package/dist/untrack.js +17 -0
  173. package/dist/untrack.js.map +1 -0
  174. package/dist/utils/withUse.d.ts +10 -0
  175. package/dist/utils/withUse.d.ts.map +1 -0
  176. package/dist/utils/withUse.js +21 -0
  177. package/dist/utils/withUse.js.map +1 -0
  178. package/dist/utils/withUse.test.d.ts +2 -0
  179. package/dist/utils/withUse.test.d.ts.map +1 -0
  180. package/dist/utils/withUse.test.js +233 -0
  181. package/dist/utils/withUse.test.js.map +1 -0
  182. package/dist/utils.d.ts +7 -0
  183. package/dist/utils.d.ts.map +1 -0
  184. package/dist/utils.js +7 -0
  185. package/dist/utils.js.map +1 -0
  186. package/dist/utils.test.d.ts +2 -0
  187. package/dist/utils.test.d.ts.map +1 -0
  188. package/dist/utils.test.js +119 -0
  189. package/dist/utils.test.js.map +1 -0
  190. package/package.json +64 -0
package/README.md ADDED
@@ -0,0 +1,1756 @@
1
+ # rexfect
2
+
3
+ Minimal reactive state management for JavaScript/TypeScript.
4
+
5
+ ## Introduction
6
+
7
+ rexfect provides simple, predictable reactive state management with a minimal API surface. Unlike larger state management solutions, rexfect gives you just a few core primitives that compose together to handle any state management scenario:
8
+
9
+ - **Atoms** for reactive state storage
10
+ - **Effects** for synchronous reactions to state changes
11
+ - **Actions** for async operations and business logic workflows
12
+
13
+ This guide covers the most common use cases and patterns. Whether you're building a small widget or a large application, these patterns will help you write maintainable, reactive code.
14
+
15
+ ## Comparison with Other Tools
16
+
17
+ ### Boilerplate Comparison
18
+
19
+ rexfect is designed to minimize boilerplate while maintaining type safety. Here's how a simple counter compares across libraries:
20
+
21
+ **rexfect:**
22
+
23
+ ```typescript
24
+ import { atom } from "rexfect";
25
+ import { useRx } from "rexfect/react";
26
+
27
+ const [count, setCount] = atom(0);
28
+
29
+ function Counter() {
30
+ const value = useRx(() => count());
31
+ return <button onClick={() => setCount(count() + 1)}>{value}</button>;
32
+ }
33
+ ```
34
+
35
+ **Redux Toolkit:**
36
+
37
+ ```typescript
38
+ import { createSlice, configureStore } from "@reduxjs/toolkit";
39
+ import { Provider, useSelector, useDispatch } from "react-redux";
40
+
41
+ const counterSlice = createSlice({
42
+ name: "counter",
43
+ initialState: { value: 0 },
44
+ reducers: {
45
+ increment: (state) => {
46
+ state.value += 1;
47
+ },
48
+ },
49
+ });
50
+
51
+ const store = configureStore({ reducer: { counter: counterSlice.reducer } });
52
+ const { increment } = counterSlice.actions;
53
+
54
+ function Counter() {
55
+ const value = useSelector((state) => state.counter.value);
56
+ const dispatch = useDispatch();
57
+ return <button onClick={() => dispatch(increment())}>{value}</button>;
58
+ }
59
+
60
+ // Must wrap app in <Provider store={store}>
61
+ ```
62
+
63
+ **Zustand:**
64
+
65
+ ```typescript
66
+ import { create } from "zustand";
67
+
68
+ const useStore = create((set) => ({
69
+ count: 0,
70
+ increment: () => set((state) => ({ count: state.count + 1 })),
71
+ }));
72
+
73
+ function Counter() {
74
+ const count = useStore((state) => state.count);
75
+ const increment = useStore((state) => state.increment);
76
+ return <button onClick={increment}>{count}</button>;
77
+ }
78
+ ```
79
+
80
+ **Jotai:**
81
+
82
+ ```typescript
83
+ import { atom, useAtom } from "jotai";
84
+
85
+ const countAtom = atom(0);
86
+
87
+ function Counter() {
88
+ const [count, setCount] = useAtom(countAtom);
89
+ return <button onClick={() => setCount((c) => c + 1)}>{count}</button>;
90
+ }
91
+ ```
92
+
93
+ ### Feature Comparison
94
+
95
+ | Feature | rexfect | Redux | Zustand | Jotai | MobX |
96
+ | -------------------------- | -------------- | ---------- | ------- | ------- | ------- |
97
+ | **Boilerplate** | Minimal | High | Low | Minimal | Low |
98
+ | **Bundle Size** | ~3KB | ~10KB+ | ~2KB | ~5KB | ~15KB |
99
+ | **TypeScript** | First-class | Good | Good | Good | Good |
100
+ | **Conditional Reactivity** | ✅ | ❌ | ❌ | ❌ | ✅ |
101
+ | **React Suspense** | ✅ Built-in | ❌ | ❌ | ✅ | ❌ |
102
+ | **Async Cancellation** | ✅ Built-in | Manual | Manual | Manual | Manual |
103
+ | **Actions System** | ✅ First-class | Middleware | Manual | Manual | Actions |
104
+ | **DevTools** | Planned | ✅ | ✅ | ✅ | ✅ |
105
+
106
+ ### Conditional Reactivity
107
+
108
+ Most libraries require you to select all dependencies upfront. rexfect tracks dependencies dynamically, so conditional reads just work:
109
+
110
+ **rexfect - Conditional reads are automatic:**
111
+
112
+ ```typescript
113
+ function UserDisplay() {
114
+ const display = useRx(() => {
115
+ // Only subscribes to lastName when showFull is true
116
+ // When showFull becomes false, lastName is automatically untracked
117
+ if (showFull()) {
118
+ return `${firstName()} ${lastName()}`;
119
+ }
120
+ return firstName();
121
+ });
122
+ return <span>{display}</span>;
123
+ }
124
+ ```
125
+
126
+ **Other libraries - Must handle manually:**
127
+
128
+ ```typescript
129
+ // Zustand: Selector always subscribes to all selected state
130
+ const useUserStore = create((set) => ({
131
+ showFull: true,
132
+ firstName: "Ada",
133
+ lastName: "Lovelace",
134
+ }));
135
+
136
+ function UserDisplay() {
137
+ // Option 1: Single selector - re-renders on ANY of these changes
138
+ const { showFull, firstName, lastName } = useUserStore((state) => ({
139
+ showFull: state.showFull,
140
+ firstName: state.firstName,
141
+ lastName: state.lastName, // Always tracked, even when showFull is false!
142
+ }));
143
+
144
+ return <span>{showFull ? `${firstName} ${lastName}` : firstName}</span>;
145
+ }
146
+
147
+ // Redux: Same issue with useSelector
148
+ function UserDisplay() {
149
+ const { showFull, firstName, lastName } = useSelector((state) => ({
150
+ showFull: state.user.showFull,
151
+ firstName: state.user.firstName,
152
+ lastName: state.user.lastName, // Always tracked!
153
+ }));
154
+
155
+ return <span>{showFull ? `${firstName} ${lastName}` : firstName}</span>;
156
+ }
157
+
158
+ // Jotai: Derived atom reads all dependencies upfront
159
+ const displayAtom = atom((get) => {
160
+ const showFull = get(showFullAtom);
161
+ const first = get(firstNameAtom);
162
+ const last = get(lastNameAtom); // Always read, even when showFull is false
163
+ return showFull ? `${first} ${last}` : first;
164
+ });
165
+ ```
166
+
167
+ ### Suspense Support
168
+
169
+ rexfect has first-class Suspense support with `read()` and `loadable()`:
170
+
171
+ **rexfect:**
172
+
173
+ ```typescript
174
+ import { read } from "rexfect/async";
175
+
176
+ function UserProfile() {
177
+ // Automatically suspends until resolved
178
+ // Works with any Signal<Promise<T>>
179
+ const user = useRx(() => read(userPromise));
180
+ return <h1>{user.name}</h1>;
181
+ }
182
+
183
+ // Or use loadable() for manual loading states
184
+ function UserProfileManual() {
185
+ const state = useRx(() => loadable(userPromise));
186
+ if (state?.loading) return <Spinner />;
187
+ if (state?.error) return <Error error={state.error} />;
188
+ return <h1>{state?.data?.name}</h1>;
189
+ }
190
+ ```
191
+
192
+ **Redux/Zustand:** No built-in Suspense support. Requires manual loading state management or additional libraries.
193
+
194
+ **Jotai:** Has Suspense support but requires specific async atoms:
195
+
196
+ ```typescript
197
+ // Must use special async atom pattern
198
+ const userAtom = atom(async () => {
199
+ const response = await fetch("/api/user");
200
+ return response.json();
201
+ });
202
+ ```
203
+
204
+ ### The Power of Actions
205
+
206
+ rexfect's `action()` is a unique feature that handles async workflows elegantly.
207
+
208
+ #### Key Characteristics
209
+
210
+ Actions are more powerful than traditional functions because they are:
211
+
212
+ 1. **Listenable** - Subscribe to dispatches with `.on()`
213
+
214
+ - Multiple listeners can react to the same dispatch
215
+ - Perfect for side effects like analytics, logging, or state updates
216
+ - Supports async listeners
217
+
218
+ 2. **Awaitable** - Actions implement `PromiseLike` interface
219
+
220
+ - `await action` waits for the next dispatch
221
+ - `.latest()` gets the last value immediately or waits for next
222
+ - Enables elegant async coordination patterns
223
+
224
+ 3. **Simple** - No context, no lifecycle complexity
225
+ - Handlers are simple functions that receive the payload
226
+ - For complex cancellation/lifecycle needs, use `abortable()` utility
227
+
228
+ #### Actions vs Traditional Functions
229
+
230
+ | Feature | Action | Traditional Function |
231
+ | ---------------------- | ------------------------------------ | ---------------------------------------- |
232
+ | **Multiple Listeners** | ✅ `.on()` for side effects | ❌ Must call multiple functions manually |
233
+ | **Awaitable** | ✅ `await action` waits for dispatch | ❌ Must wrap in Promise manually |
234
+ | **Sealed (One-time)** | ✅ `{ sealed: true }` option | ❌ Manual flag checking |
235
+ | **Late Subscribers** | ✅ Auto-called with last value | ❌ Manual synchronization |
236
+ | **Type Safety** | ✅ Full TypeScript inference | ✅ Same (functions are typed) |
237
+ | **Cancellation** | ✅ Use `abortable()` wrapper | ❌ Manual `AbortController` setup |
238
+
239
+ **Example Comparison:**
240
+
241
+ ```typescript
242
+ // ❌ Traditional function - manual cancellation, no listeners, not awaitable
243
+ let abortController: AbortController | null = null;
244
+
245
+ function search(query: string) {
246
+ abortController?.abort();
247
+ abortController = new AbortController();
248
+
249
+ return fetch(`/api/search?q=${query}`, {
250
+ signal: abortController.signal,
251
+ }).then((r) => r.json());
252
+ }
253
+
254
+ // Must manually track listeners
255
+ const listeners: Array<(query: string) => void> = [];
256
+ function onSearch(callback: (query: string) => void) {
257
+ listeners.push(callback);
258
+ }
259
+
260
+ // Must wrap in Promise to await
261
+ const promise = new Promise((resolve) => {
262
+ const result = search("hello");
263
+ listeners.forEach((cb) => cb("hello"));
264
+ resolve(result);
265
+ });
266
+
267
+ // ✅ Action - simple and powerful
268
+ const search = action(async (query: string) => {
269
+ const res = await fetch(`/api/search?q=${query}`);
270
+ return res.json();
271
+ });
272
+
273
+ // Listeners subscribe easily
274
+ search.on((query) => {
275
+ analytics.track("search", { query });
276
+ });
277
+
278
+ // Await directly
279
+ const results = await search("hello");
280
+
281
+ // For cancellation, wrap with abortable()
282
+ import { abortable } from "rexfect/async";
283
+ const cancellableSearch = abortable(async ({ signal }, query: string) => {
284
+ const res = await fetch(`/api/search?q=${query}`, { signal });
285
+ return res.json();
286
+ });
287
+ ```
288
+
289
+ **Naming Convention:**
290
+
291
+ - **Void actions** (notifications): Use `on` prefix → `onClick`, `onLogin`, `onSearch`
292
+ - **Actions with handlers** (commands): Use verbs → `addToCart`, `submitForm`, `fetchUser`
293
+
294
+ **Thenable Actions - Await the next dispatch:**
295
+
296
+ ```typescript
297
+ const onUserLogin = action<User>();
298
+
299
+ // In one part of your app
300
+ async function waitForLogin() {
301
+ const user = await onUserLogin; // Waits for next dispatch
302
+ console.log("User logged in:", user);
303
+ }
304
+
305
+ // In another part
306
+ onUserLogin(currentUser); // Resolves the waiting promise
307
+ ```
308
+
309
+ **Actions with Handlers - Return results:**
310
+
311
+ ```typescript
312
+ // Simple sync handler
313
+ const addToCart = action((item: CartItem) => {
314
+ const newCart = [...cart(), item];
315
+ setCart(newCart);
316
+ return newCart.length;
317
+ });
318
+
319
+ const count = addToCart({ id: "123", qty: 1 }); // count = new cart length
320
+ ```
321
+
322
+ **Note:** Action handlers are automatically wrapped in `batch()`, so multiple atom updates in sync handlers are automatically batched. Only use explicit `batch()` if you need to batch updates that happen in async operations (after `await`).
323
+
324
+ **Async Handlers:**
325
+
326
+ ```typescript
327
+ // Simple async handler - no context needed
328
+ const search = action(async (query: string) => {
329
+ const res = await fetch(`/api/search?q=${query}`);
330
+ return res.json();
331
+ });
332
+
333
+ const results = await search("hello");
334
+ ```
335
+
336
+ **For cancellation, use `abortable()`:**
337
+
338
+ ```typescript
339
+ import { abortable } from "rexfect/async";
340
+
341
+ // Wrap with abortable for cancellation support
342
+ const cancellableSearch = abortable(
343
+ async ({ signal, abortPrev }, query: string) => {
344
+ abortPrev(); // Cancel previous search
345
+
346
+ const res = await fetch(`/api/search?q=${query}`, {
347
+ signal, // Pass signal to fetch
348
+ });
349
+
350
+ return res.json();
351
+ }
352
+ );
353
+
354
+ // Rapid calls - only last one completes
355
+ cancellableSearch("h");
356
+ cancellableSearch("he");
357
+ cancellableSearch("hel");
358
+ const results = await cancellableSearch("hello"); // Only this completes
359
+ ```
360
+
361
+ **Or use listeners for side effects:**
362
+
363
+ ```typescript
364
+ // Void action with listener for side effects
365
+ const onSearch = action<string>();
366
+
367
+ onSearch.on(async (query) => {
368
+ const results = await fetch(`/api/search?q=${query}`);
369
+ const data = await results.json();
370
+ setResults(data);
371
+ });
372
+
373
+ onSearch("react");
374
+ ```
375
+
376
+ **Sealed Actions - One-time initialization:**
377
+
378
+ ```typescript
379
+ const onAppReady = action<Config>({ sealed: true });
380
+
381
+ // Automatically seals after first dispatch
382
+ onAppReady(config);
383
+ onAppReady(newConfig); // No-op, safely ignored
384
+
385
+ // Late subscribers get the sealed value immediately
386
+ onAppReady.on((config) => {
387
+ // Called immediately with original config
388
+ });
389
+ ```
390
+
391
+ **Compare to other libraries:**
392
+
393
+ | Pattern | rexfect | Redux | Zustand | Jotai |
394
+ | --------------------- | ------------------ | ---------------- | ---------------------- | ----------- |
395
+ | Await action dispatch | `await action` | N/A | N/A | N/A |
396
+ | Cancel previous async | `abortable()` | Write middleware | Manual AbortController | Manual |
397
+ | One-time init | `{ sealed: true }` | Check flags | Check flags | Check flags |
398
+ | Late subscribers | Auto-called | Manual sync | Manual sync | Manual sync |
399
+
400
+ ### When to Choose rexfect
401
+
402
+ **Choose rexfect if you want:**
403
+
404
+ - Minimal boilerplate with maximum type safety
405
+ - Conditional reactivity without selector gymnastics
406
+ - First-class async handling with cancellation
407
+ - Built-in Suspense support
408
+ - Action-driven architecture for complex workflows
409
+ - Fine-grained reactivity with `pick()`
410
+
411
+ **Consider Redux if you need:**
412
+
413
+ - Time-travel debugging
414
+ - Large ecosystem of middleware
415
+ - Strict unidirectional data flow enforcement
416
+ - Team familiarity with Redux patterns
417
+
418
+ **Consider Zustand if you need:**
419
+
420
+ - Minimal API similar to rexfect but more established
421
+ - Easy migration from Redux
422
+ - DevTools support today
423
+
424
+ **Consider Jotai if you need:**
425
+
426
+ - Atom-based model with React-first design
427
+ - Strong Suspense integration
428
+ - Atom-in-atom composition patterns
429
+
430
+ ## Mental Model: How It All Fits Together
431
+
432
+ Understanding the relationship between atoms, effects, and events is key to using rexfect effectively.
433
+
434
+ ### The Data Flow
435
+
436
+ ```
437
+ ┌─────────────────────────────────────────────────────────────────────┐
438
+ │ APPLICATION │
439
+ ├─────────────────────────────────────────────────────────────────────┤
440
+ │ │
441
+ │ ┌─────────┐ ┌─────────┐ ┌─────────────────┐ │
442
+ │ │ ATOMS │◄────────│ EVENTS │◄────────│ COMPONENTS │ │
443
+ │ │ (State) │ │ (Async) │ │ (UI Layer) │ │
444
+ │ └────┬────┘ └────▲────┘ └────────┬────────┘ │
445
+ │ │ │ │ │
446
+ │ │ ┌─────────┐ │ │ │
447
+ │ └───►│ EFFECTS │────┘ │ │
448
+ │ │ (Sync) │ │ │
449
+ │ └─────────┘ │ │
450
+ │ │ │
451
+ │ ┌─────────┐ │ │
452
+ │ │ ATOMS │◄─────────────────────────────────────┘ │
453
+ │ │ (Read) │ Components read atoms via useRx() │
454
+ │ └─────────┘ │
455
+ │ │
456
+ └─────────────────────────────────────────────────────────────────────┘
457
+ ```
458
+
459
+ ### The Three Primitives
460
+
461
+ | Primitive | Purpose | Sync/Async | Typical Use |
462
+ | ----------- | ---------------- | ------------------- | -------------------------------------------------------- |
463
+ | **Atoms** | Store state | - | Hold your application data |
464
+ | **Effects** | React to changes | **Sync only** | Logging, localStorage, derived updates, dispatch actions |
465
+ | **Actions** | Handle workflows | **Async supported** | API calls, business logic, user interactions |
466
+
467
+ ### How They Work Together
468
+
469
+ **1. Atoms hold your state:**
470
+
471
+ ```typescript
472
+ const [user, setUser] = atom<User | null>(null);
473
+ const [isLoading, setIsLoading] = atom(false);
474
+ const [error, setError] = atom<Error | null>(null);
475
+ ```
476
+
477
+ **2. Actions handle async operations and business logic:**
478
+
479
+ ```typescript
480
+ // Action with handler for login
481
+ const login = action(
482
+ async (credentials: { email: string; password: string }) => {
483
+ setIsLoading(true);
484
+ setError(null);
485
+
486
+ try {
487
+ const user = await api.login(credentials);
488
+ setUser(user);
489
+ return user;
490
+ } catch (err) {
491
+ setError(err as Error);
492
+ throw err;
493
+ } finally {
494
+ setIsLoading(false);
495
+ }
496
+ }
497
+ );
498
+ ```
499
+
500
+ **3. Effects react to atom changes (sync only):**
501
+
502
+ ```typescript
503
+ // Log when user changes
504
+ effect(() => {
505
+ const currentUser = user();
506
+ if (currentUser) {
507
+ console.log("User logged in:", currentUser.email);
508
+ }
509
+ });
510
+
511
+ // Sync to localStorage
512
+ effect((ctx) => {
513
+ const currentUser = user();
514
+ if (ctx.nth > 0) {
515
+ // Skip first run
516
+ localStorage.setItem("user", JSON.stringify(currentUser));
517
+ }
518
+ });
519
+
520
+ // Dispatch actions based on state changes
521
+ effect(() => {
522
+ const currentUser = user();
523
+ if (currentUser && !currentUser.profileComplete) {
524
+ onShowProfileWizard(); // Dispatch an action
525
+ }
526
+ });
527
+ ```
528
+
529
+ **4. Components read atoms and dispatch actions:**
530
+
531
+ ```typescript
532
+ function LoginForm() {
533
+ const loading = useRx(() => isLoading());
534
+ const loginError = useRx(() => error());
535
+
536
+ const handleSubmit = (e: FormEvent) => {
537
+ e.preventDefault();
538
+ login({ email, password }); // Dispatch action
539
+ };
540
+
541
+ return (
542
+ <form onSubmit={handleSubmit}>
543
+ {loginError && <p className="error">{loginError.message}</p>}
544
+ {/* ... form fields ... */}
545
+ <button disabled={loading}>{loading ? "Logging in..." : "Login"}</button>
546
+ </form>
547
+ );
548
+ }
549
+
550
+ function UserProfile() {
551
+ const currentUser = useRx(() => user());
552
+
553
+ if (!currentUser) return <LoginForm />;
554
+
555
+ return <h1>Welcome, {currentUser.name}!</h1>;
556
+ }
557
+ ```
558
+
559
+ ### The Key Insight
560
+
561
+ Think of it as a unidirectional flow:
562
+
563
+ 1. **User interactions** → dispatch **Actions**
564
+ 2. **Actions** → perform async work → update **Atoms**
565
+ 3. **Atoms** change → trigger **Effects** (sync reactions)
566
+ 4. **Effects** can → dispatch more **Actions** (for async follow-ups)
567
+ 5. **Components** → read **Atoms** → render UI
568
+
569
+ ```
570
+ User Interaction ──► Action ──► Atom Update ──► Effect ──► Action (optional)
571
+
572
+
573
+ Component Re-render
574
+ ```
575
+
576
+ ### Why This Separation Matters
577
+
578
+ | Concern | Handled By | Why |
579
+ | -------------------- | ------------------ | ----------------------------------------------- |
580
+ | **State storage** | Atoms | Single source of truth, fine-grained reactivity |
581
+ | **Sync reactions** | Effects | Predictable, debuggable, no race conditions |
582
+ | **Async operations** | Actions | Cancellation, error handling, workflow control |
583
+ | **UI rendering** | Components + useRx | Automatic subscriptions, conditional tracking |
584
+
585
+ This separation keeps your code organized:
586
+
587
+ - **Atoms** are just data - no logic
588
+ - **Effects** are synchronous and predictable - easy to debug
589
+ - **Actions** handle all the messy async stuff - with proper cancellation
590
+ - **Components** just render - no business logic
591
+
592
+ ## Installation
593
+
594
+ ```bash
595
+ # npm
596
+ npm install rexfect
597
+
598
+ # yarn
599
+ yarn add rexfect
600
+
601
+ # pnpm
602
+ pnpm add rexfect
603
+ ```
604
+
605
+ ## Store Setup
606
+
607
+ Every rexfect application needs to set up state using atoms. This is simpler than Redux - no store configuration, no reducers, just atoms.
608
+
609
+ ### Creating Your First Store
610
+
611
+ The simplest way to create state is with the `atom()` function. Unlike Redux, there's no central store to configure:
612
+
613
+ ```typescript
614
+ import { atom } from "rexfect";
615
+
616
+ // Create reactive state - returns [getter, setter] tuple
617
+ // Similar to React's useState, but works outside of components
618
+ const [count, setCount] = atom(0);
619
+
620
+ // Read the current value by calling the signal
621
+ console.log(count()); // 0
622
+
623
+ // Update the value with the setter
624
+ setCount(5);
625
+ console.log(count()); // 5
626
+ ```
627
+
628
+ ### Organizing State in Modules
629
+
630
+ For larger applications, organize related atoms together in modules:
631
+
632
+ ```typescript
633
+ // store/user.ts
634
+ import { atom } from "rexfect";
635
+
636
+ // User state
637
+ export const [currentUser, setCurrentUser] = atom<User | null>(null);
638
+ export const [isAuthenticated, setIsAuthenticated] = atom(false);
639
+
640
+ // Preferences state
641
+ export const [theme, setTheme] = atom<"light" | "dark">("light");
642
+ export const [language, setLanguage] = atom("en");
643
+ ```
644
+
645
+ ```typescript
646
+ // store/cart.ts
647
+ import { atom } from "rexfect";
648
+
649
+ interface CartItem {
650
+ productId: string;
651
+ quantity: number;
652
+ price: number;
653
+ }
654
+
655
+ // Shopping cart state
656
+ export const [cartItems, setCartItems] = atom<CartItem[]>([]);
657
+ export const [isCartOpen, setIsCartOpen] = atom(false);
658
+
659
+ // Helper functions that work with the atoms
660
+ export function addToCart(item: CartItem) {
661
+ setCartItems([...cartItems(), item]);
662
+ }
663
+
664
+ export function removeFromCart(productId: string) {
665
+ setCartItems(cartItems().filter((item) => item.productId !== productId));
666
+ }
667
+
668
+ export function getCartTotal(): number {
669
+ return cartItems().reduce((sum, item) => sum + item.price * item.quantity, 0);
670
+ }
671
+ ```
672
+
673
+ ### Configuring Equality for Objects
674
+
675
+ When atoms hold objects or arrays, you'll want to configure equality to avoid unnecessary updates:
676
+
677
+ ```typescript
678
+ import { atom } from "rexfect";
679
+
680
+ // Problem: Without equality config, setting the same object triggers updates
681
+ const [user, setUser] = atom({ name: "Ada", age: 30 });
682
+ setUser({ name: "Ada", age: 30 }); // This triggers subscribers (different reference)
683
+
684
+ // Solution: Use shallow equality for simple objects
685
+ const [user, setUser] = atom(
686
+ { name: "Ada", age: 30 },
687
+ { equals: "shallow" } // Only notifies if properties actually changed
688
+ );
689
+ setUser({ name: "Ada", age: 30 }); // No notification (same values)
690
+
691
+ // For nested objects, use deeper equality
692
+ const [config, setConfig] = atom(
693
+ {
694
+ ui: { theme: "dark", fontSize: 14 },
695
+ api: { baseUrl: "https://api.example.com", timeout: 5000 },
696
+ },
697
+ { equals: "shallow2" } // Compares 2 levels deep
698
+ );
699
+
700
+ // For complex nested structures, use deep equality
701
+ const [appState, setAppState] = atom(deeplyNestedObject, { equals: "deep" });
702
+ ```
703
+
704
+ **Available equality strategies:**
705
+
706
+ | Strategy | Use Case | Performance |
707
+ | ------------ | ------------------------------------- | ----------- |
708
+ | `"strict"` | Primitives, immutable references | Fastest |
709
+ | `"shallow"` | Simple objects, arrays | Fast |
710
+ | `"shallow2"` | Objects containing simple objects | Medium |
711
+ | `"shallow3"` | 3 levels of nesting | Medium |
712
+ | `"deep"` | Deeply nested structures | Slower |
713
+ | `(a, b) =>…` | Custom comparison (e.g., by ID field) | Depends |
714
+
715
+ ## Writing Effects
716
+
717
+ Effects are synchronous reactions that run whenever their dependencies change. They're perfect for:
718
+
719
+ - Logging and debugging
720
+ - Syncing state to localStorage
721
+ - Updating DOM elements
722
+ - Triggering side effects
723
+
724
+ ### Basic Effect Pattern
725
+
726
+ ```typescript
727
+ import { atom, effect } from "rexfect";
728
+
729
+ const [firstName, setFirstName] = atom("Ada");
730
+ const [lastName, setLastName] = atom("Lovelace");
731
+
732
+ // Create an effect - it runs immediately, then re-runs when dependencies change
733
+ // Dependencies are automatically tracked: any atom() called inside is a dependency
734
+ const dispose = effect(() => {
735
+ // Reading firstName() and lastName() creates dependencies
736
+ console.log(`Hello, ${firstName()} ${lastName()}!`);
737
+ });
738
+ // Output: "Hello, Ada Lovelace!"
739
+
740
+ // Update firstName - effect re-runs automatically
741
+ setFirstName("Grace");
742
+ // Output: "Hello, Grace Lovelace!"
743
+
744
+ // When done, dispose the effect to stop it from running
745
+ dispose();
746
+
747
+ // Now updates won't trigger the effect
748
+ setFirstName("Margaret"); // No output
749
+ ```
750
+
751
+ ### Effect Context and Error Handling
752
+
753
+ Effects receive a context object with useful utilities:
754
+
755
+ ```typescript
756
+ import { atom, effect } from "rexfect";
757
+
758
+ const [data, setData] = atom<string | null>(null);
759
+
760
+ const dispose = effect(
761
+ (ctx) => {
762
+ // ctx.nth: How many times the effect has run (0 on first run)
763
+ console.log(`Effect run #${ctx.nth}`);
764
+
765
+ // ctx.name: Effect name (from options) for debugging
766
+ console.log(`Effect name: ${ctx.name}`);
767
+
768
+ const value = data();
769
+
770
+ // ctx.onError: Register error handler for this effect run
771
+ ctx.onError((error) => {
772
+ console.error("Effect failed:", error);
773
+ // Handle error gracefully instead of crashing
774
+ });
775
+
776
+ if (value === "bad") {
777
+ throw new Error("Invalid data!");
778
+ }
779
+
780
+ console.log("Data:", value);
781
+ },
782
+ { name: "dataLogger" }
783
+ );
784
+ ```
785
+
786
+ ### Syncing State to localStorage
787
+
788
+ A common pattern is persisting state to localStorage:
789
+
790
+ ```typescript
791
+ import { atom, effect } from "rexfect";
792
+
793
+ // Create the atom with initial value from localStorage
794
+ const [theme, setTheme] = atom<"light" | "dark">(
795
+ (localStorage.getItem("theme") as "light" | "dark") || "light"
796
+ );
797
+
798
+ // Sync to localStorage whenever theme changes
799
+ effect((ctx) => {
800
+ const currentTheme = theme();
801
+
802
+ // Skip localStorage on first run (we just read from it)
803
+ if (ctx.nth === 0) return;
804
+
805
+ localStorage.setItem("theme", currentTheme);
806
+ console.log(`Theme saved: ${currentTheme}`);
807
+ });
808
+
809
+ // Now theme changes are automatically persisted
810
+ setTheme("dark"); // Saves to localStorage
811
+ ```
812
+
813
+ ### ⚠️ Important: Effects Must Be Synchronous
814
+
815
+ Effects are designed for synchronous operations only. If you need async operations, use Actions instead:
816
+
817
+ ```typescript
818
+ import { atom, effect, action } from "rexfect";
819
+
820
+ // ❌ WRONG: Don't use async in effects
821
+ effect(async () => {
822
+ const data = await fetchData(); // This will throw an error!
823
+ setData(data);
824
+ });
825
+
826
+ // ✅ CORRECT: Use actions for async operations
827
+ const fetchData = action(async (_, ctx) => {
828
+ ctx.abortPrev();
829
+ const data = await fetchDataFromApi();
830
+ setData(data);
831
+ return data;
832
+ });
833
+
834
+ // Dispatch the action to trigger the async operation
835
+ fetchData();
836
+ ```
837
+
838
+ ## Batching Updates
839
+
840
+ When updating multiple atoms, use `batch()` to ensure effects only run once.
841
+
842
+ **Important:** Action handlers are automatically wrapped in `batch()`, so you don't need to wrap sync code inside action handlers. Only use explicit `batch()` for:
843
+
844
+ - Updates outside of actions
845
+ - Updates that happen after `await` in async action handlers
846
+
847
+ ```typescript
848
+ import { atom, effect, batch, action } from "rexfect";
849
+
850
+ const [x, setX] = atom(0);
851
+ const [y, setY] = atom(0);
852
+ const [z, setZ] = atom(0);
853
+
854
+ // This effect depends on all three atoms
855
+ effect(() => {
856
+ console.log(`Position: (${x()}, ${y()}, ${z()})`);
857
+ });
858
+ // Output: "Position: (0, 0, 0)"
859
+
860
+ // ❌ WITHOUT batching - effect runs 3 times
861
+ setX(1); // Output: "Position: (1, 0, 0)"
862
+ setY(2); // Output: "Position: (1, 2, 0)"
863
+ setZ(3); // Output: "Position: (1, 2, 3)"
864
+
865
+ // ✅ WITH batching - effect runs once with final values
866
+ batch(() => {
867
+ setX(10);
868
+ setY(20);
869
+ setZ(30);
870
+ });
871
+ // Output: "Position: (10, 20, 30)" (just once!)
872
+
873
+ // ✅ Action handlers are automatically batched - no need for explicit batch()
874
+ const updatePosition = action((coords: { x: number; y: number; z: number }) => {
875
+ setX(coords.x); // These are automatically batched
876
+ setY(coords.y);
877
+ setZ(coords.z);
878
+ });
879
+ updatePosition({ x: 100, y: 200, z: 300 });
880
+ // Output: "Position: (100, 200, 300)" (just once!)
881
+
882
+ // ✅ For async handlers, only wrap updates after await
883
+ const fetchAndUpdate = action(async (_, ctx) => {
884
+ const data = await fetchData(); // Async operation
885
+
886
+ // Wrap updates after await in batch()
887
+ batch(() => {
888
+ setX(data.x);
889
+ setY(data.y);
890
+ setZ(data.z);
891
+ });
892
+ });
893
+ ```
894
+
895
+ ## Handling Async Logic with Actions
896
+
897
+ Actions are the primary way to handle async operations in rexfect. Unlike effects, actions support async handlers and provide cancellation utilities.
898
+
899
+ ### Basic Action Pattern
900
+
901
+ ```typescript
902
+ import { action } from "rexfect";
903
+
904
+ // Action with handler - executes logic and returns result
905
+ const loginUser = action(
906
+ async (payload: { userId: string; timestamp: Date }) => {
907
+ console.log(`User ${payload.userId} logging in...`);
908
+
909
+ // Perform async operations
910
+ const profile = await fetchUserProfile(payload.userId);
911
+ console.log("Profile loaded:", profile);
912
+
913
+ return profile;
914
+ }
915
+ );
916
+
917
+ // Dispatch the action and get result
918
+ const profile = await loginUser({ userId: "123", timestamp: new Date() });
919
+ ```
920
+
921
+ ### Actions with Handlers
922
+
923
+ Actions can have a handler that returns a result. Handlers are simple functions:
924
+
925
+ ```typescript
926
+ import { action, atom } from "rexfect";
927
+
928
+ const [cart, setCart] = atom<CartItem[]>([]);
929
+
930
+ // Simple sync handler
931
+ const addToCart = action((item: CartItem) => {
932
+ const newCart = [...cart(), item];
933
+ setCart(newCart);
934
+ return newCart.length;
935
+ });
936
+
937
+ const cartCount = addToCart({ id: "123", qty: 1 });
938
+
939
+ // Async handler
940
+ const fetchProduct = action(async (id: string) => {
941
+ const res = await fetch(`/api/products/${id}`);
942
+ return res.json();
943
+ });
944
+
945
+ const product = await fetchProduct("123");
946
+ ```
947
+
948
+ ### Cancellation with `abortable()`
949
+
950
+ For cancellation support, wrap your handler with `abortable()`:
951
+
952
+ ```typescript
953
+ import { action } from "rexfect";
954
+ import { abortable } from "rexfect/async";
955
+
956
+ // Wrap with abortable for cancellation support
957
+ const search = abortable(async ({ signal, abortPrev }, query: string) => {
958
+ // abortPrev(): Cancel the previous dispatch
959
+ // Call at start for "latest only" patterns
960
+ abortPrev();
961
+
962
+ // signal: AbortSignal for cancellation
963
+ // Pass to fetch, axios, etc.
964
+ const response = await fetch(`/api/search?q=${query}`, {
965
+ signal,
966
+ });
967
+
968
+ return response.json();
969
+ });
970
+
971
+ // Rapid dispatches: only the last one completes
972
+ search("r");
973
+ search("re");
974
+ search("rea");
975
+ search("reac");
976
+ const results = await search("react"); // Only this one completes
977
+ ```
978
+
979
+ ### Actions are Thenable
980
+
981
+ Actions implement the Promise interface, so you can await them:
982
+
983
+ ```typescript
984
+ import { action } from "rexfect";
985
+
986
+ const onUserReady = action<User>();
987
+
988
+ // Await the NEXT dispatch
989
+ async function waitForUser() {
990
+ const user = await onUserReady;
991
+ console.log("User is ready:", user);
992
+ }
993
+
994
+ // Or use .latest() to get the last value OR wait for next
995
+ async function getUser() {
996
+ const user = await onUserReady.latest();
997
+ return user;
998
+ }
999
+ ```
1000
+
1001
+ ### Sealed Actions for One-Time Initialization
1002
+
1003
+ Use sealed actions for initialization that should only happen once:
1004
+
1005
+ ```typescript
1006
+ import { action } from "rexfect";
1007
+
1008
+ // Sealed action with handler - auto-seals after first dispatch
1009
+ const initApp = action(
1010
+ async (config: { version: string }) => {
1011
+ console.log(`App v${config.version} initializing...`);
1012
+ await initializeServices();
1013
+ return { initialized: true, version: config.version };
1014
+ },
1015
+ { sealed: true }
1016
+ );
1017
+
1018
+ // First call executes handler and seals
1019
+ const result = await initApp({ version: "1.0.0" });
1020
+ // Output: "App v1.0.0 initializing..."
1021
+
1022
+ // Subsequent calls still execute handler but listeners don't fire
1023
+ await initApp({ version: "2.0.0" }); // Handler runs, but sealed
1024
+
1025
+ // Check action state
1026
+ console.log(initApp.sealed()); // true
1027
+ console.log(initApp.fired()); // true
1028
+
1029
+ // Late subscribers on sealed actions fire immediately with last payload
1030
+ initApp.on((config) => {
1031
+ console.log("Late subscriber:", config.version);
1032
+ });
1033
+ // Output: "Late subscriber: 1.0.0"
1034
+ ```
1035
+
1036
+ ### Real-World Example: Search with Cancellation
1037
+
1038
+ ```typescript
1039
+ import { atom, action } from "rexfect";
1040
+ import { abortable } from "rexfect/async";
1041
+
1042
+ // State
1043
+ const [searchQuery, setSearchQuery] = atom("");
1044
+ const [searchResults, setSearchResults] = atom<Result[]>([]);
1045
+ const [isSearching, setIsSearching] = atom(false);
1046
+ const [searchError, setSearchError] = atom<Error | null>(null);
1047
+
1048
+ // Wrap with abortable for cancellation support
1049
+ const search = abortable(async ({ signal, abortPrev }, query: string) => {
1050
+ // Cancel any in-flight search
1051
+ abortPrev();
1052
+
1053
+ // Reset error state
1054
+ setSearchError(null);
1055
+ setIsSearching(true);
1056
+
1057
+ try {
1058
+ // Make API call with cancellation support
1059
+ const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
1060
+ signal,
1061
+ });
1062
+
1063
+ const data = await response.json();
1064
+
1065
+ // Only update state if not aborted
1066
+ setSearchResults(data.results);
1067
+ return data.results;
1068
+ } catch (error) {
1069
+ // Don't set error if it was just an abort
1070
+ if (error instanceof Error && error.name !== "AbortError") {
1071
+ setSearchError(error);
1072
+ }
1073
+ throw error;
1074
+ } finally {
1075
+ setIsSearching(false);
1076
+ }
1077
+ });
1078
+
1079
+ // UI handler
1080
+ function handleSearchInput(query: string) {
1081
+ setSearchQuery(query);
1082
+ search(query);
1083
+ }
1084
+ ```
1085
+
1086
+ ## Async UI Patterns
1087
+
1088
+ For React and other UI frameworks, rexfect provides utilities for handling async state in the UI layer.
1089
+
1090
+ ### Loading States with `loadable()`
1091
+
1092
+ Extract loading, data, and error states from Promise-valued atoms:
1093
+
1094
+ ```typescript
1095
+ import { atom, effect } from "rexfect";
1096
+ import { loadable } from "rexfect/async";
1097
+
1098
+ // Create an atom that holds a Promise (or null for "not started")
1099
+ const [userPromise, setUserPromise] = atom<Promise<User> | null>(null);
1100
+
1101
+ // React to loading states
1102
+ effect(() => {
1103
+ const state = loadable(userPromise);
1104
+
1105
+ // null means we haven't started loading yet
1106
+ if (state === null) {
1107
+ console.log("Idle - click to load");
1108
+ return;
1109
+ }
1110
+
1111
+ // state.loading: true while Promise is pending
1112
+ if (state.loading) {
1113
+ console.log("Loading user...");
1114
+ return;
1115
+ }
1116
+
1117
+ // state.error: set if Promise rejected
1118
+ if (state.error) {
1119
+ console.error("Failed to load:", state.error);
1120
+ return;
1121
+ }
1122
+
1123
+ // state.data: the resolved value
1124
+ console.log("User loaded:", state.data);
1125
+ });
1126
+
1127
+ // Start loading by setting a Promise
1128
+ function loadUser(id: string) {
1129
+ setUserPromise(fetch(`/api/users/${id}`).then((r) => r.json()));
1130
+ }
1131
+
1132
+ // Example usage
1133
+ loadUser("123");
1134
+ ```
1135
+
1136
+ ### React Suspense with `read()`
1137
+
1138
+ For React Suspense integration, use `read()` to suspend until data is ready:
1139
+
1140
+ ```typescript
1141
+ import { atom } from "rexfect";
1142
+ import { useRx } from "rexfect/react";
1143
+ import { read } from "rexfect/async";
1144
+ import { Suspense } from "react";
1145
+
1146
+ // Create atom holding a Promise
1147
+ const [userPromise] = atom(fetchUser("123"));
1148
+
1149
+ function UserProfile() {
1150
+ // read() throws the Promise (suspends) until resolved
1151
+ // When resolved, returns the value directly
1152
+ const user = useRx(() => read(userPromise));
1153
+
1154
+ // This only renders after user is loaded!
1155
+ return (
1156
+ <div>
1157
+ <h1>{user.name}</h1>
1158
+ <p>{user.email}</p>
1159
+ </div>
1160
+ );
1161
+ }
1162
+
1163
+ // Wrap in Suspense boundary to handle loading state
1164
+ function App() {
1165
+ return (
1166
+ <Suspense fallback={<div>Loading user...</div>}>
1167
+ <UserProfile />
1168
+ </Suspense>
1169
+ );
1170
+ }
1171
+ ```
1172
+
1173
+ ### Multiple Async Dependencies
1174
+
1175
+ Use `read.all()` for components that depend on multiple async values:
1176
+
1177
+ ```typescript
1178
+ import { read } from "rexfect/async";
1179
+ import { useRx } from "rexfect/react";
1180
+
1181
+ function Dashboard() {
1182
+ // Suspends until BOTH promises resolve
1183
+ const { user, settings, notifications } = useRx(() =>
1184
+ read.all({
1185
+ user: userSignal,
1186
+ settings: settingsSignal,
1187
+ notifications: notificationsSignal,
1188
+ })
1189
+ );
1190
+
1191
+ return (
1192
+ <div>
1193
+ <h1>Welcome, {user.name}!</h1>
1194
+ <p>Theme: {settings.theme}</p>
1195
+ <p>Notifications: {notifications.length}</p>
1196
+ </div>
1197
+ );
1198
+ }
1199
+ ```
1200
+
1201
+ ### Using `wait` for Async Workflows
1202
+
1203
+ The `wait` utilities are for use with `await` in action handlers:
1204
+
1205
+ ```typescript
1206
+ import { action } from "rexfect";
1207
+ import { wait } from "rexfect/async";
1208
+
1209
+ // Initialize with multiple async dependencies
1210
+ const initialize = action(async (_, ctx) => {
1211
+ ctx.abortPrev();
1212
+
1213
+ // wait.all(): Wait for multiple promises/actions
1214
+ const { user, config } = await wait.all({
1215
+ user: fetchUser(),
1216
+ config: fetchConfig(),
1217
+ });
1218
+
1219
+ console.log("Initialized with:", { user, config });
1220
+ return { user, config };
1221
+ });
1222
+
1223
+ // Racing multiple operations with timeout
1224
+ const fetchWithTimeout = action(async (_, ctx) => {
1225
+ ctx.abortPrev();
1226
+
1227
+ // wait.race(): Returns [winner, value] for objects
1228
+ const [winner, value] = await wait.race({
1229
+ data: fetchData(),
1230
+ timeout: delay(5000),
1231
+ });
1232
+
1233
+ if (winner === "timeout") {
1234
+ throw new Error("Request timed out!");
1235
+ }
1236
+
1237
+ return value;
1238
+ });
1239
+
1240
+ // Get first success, ignore failures
1241
+ const fetchWithFallback = action(async (_, ctx) => {
1242
+ ctx.abortPrev();
1243
+
1244
+ // wait.any(): Returns first successful result
1245
+ const [winner, value] = await wait.any({
1246
+ primary: fetchFromPrimary(),
1247
+ backup: fetchFromBackup(),
1248
+ });
1249
+
1250
+ console.log(`Got data from ${winner}`);
1251
+ return value;
1252
+ });
1253
+ ```
1254
+
1255
+ ## React Integration
1256
+
1257
+ ### The `useRx` Hook
1258
+
1259
+ The `useRx` hook lets you use reactive values in React components. Unlike other libraries:
1260
+
1261
+ - Works with **multiple atoms** in a single call
1262
+ - Supports **conditional** atom reads
1263
+ - Detects changes on **atoms themselves**, not selector results
1264
+
1265
+ ```typescript
1266
+ import { atom } from "rexfect";
1267
+ import { useRx } from "rexfect/react";
1268
+
1269
+ // Define atoms at module level (outside components)
1270
+ const [firstName, setFirstName] = atom("Ada");
1271
+ const [lastName, setLastName] = atom("Lovelace");
1272
+ const [showFullName, setShowFullName] = atom(true);
1273
+
1274
+ function UserGreeting() {
1275
+ // useRx tracks ALL atoms read inside the callback
1276
+ // Component re-renders when ANY of them change
1277
+ const greeting = useRx(() => {
1278
+ const first = firstName();
1279
+
1280
+ // Conditional reads are fully supported!
1281
+ // lastName is only tracked when showFullName is true
1282
+ if (showFullName()) {
1283
+ return `Hello, ${first} ${lastName()}!`;
1284
+ }
1285
+
1286
+ return `Hello, ${first}!`;
1287
+ });
1288
+
1289
+ return <h1>{greeting}</h1>;
1290
+ }
1291
+ ```
1292
+
1293
+ ### Working with Complex State
1294
+
1295
+ ```typescript
1296
+ import { atom } from "rexfect";
1297
+ import { useRx } from "rexfect/react";
1298
+
1299
+ interface Todo {
1300
+ id: string;
1301
+ text: string;
1302
+ completed: boolean;
1303
+ }
1304
+
1305
+ const [todos, setTodos] = atom<Todo[]>([]);
1306
+ const [filter, setFilter] = atom<"all" | "active" | "completed">("all");
1307
+
1308
+ function TodoList() {
1309
+ // Computed values work naturally
1310
+ const filteredTodos = useRx(() => {
1311
+ const all = todos();
1312
+ const currentFilter = filter();
1313
+
1314
+ switch (currentFilter) {
1315
+ case "active":
1316
+ return all.filter((t) => !t.completed);
1317
+ case "completed":
1318
+ return all.filter((t) => t.completed);
1319
+ default:
1320
+ return all;
1321
+ }
1322
+ });
1323
+
1324
+ const stats = useRx(() => ({
1325
+ total: todos().length,
1326
+ completed: todos().filter((t) => t.completed).length,
1327
+ active: todos().filter((t) => !t.completed).length,
1328
+ }));
1329
+
1330
+ return (
1331
+ <div>
1332
+ <p>
1333
+ {stats.active} active, {stats.completed} completed
1334
+ </p>
1335
+ <ul>
1336
+ {filteredTodos.map((todo) => (
1337
+ <li key={todo.id}>{todo.text}</li>
1338
+ ))}
1339
+ </ul>
1340
+ </div>
1341
+ );
1342
+ }
1343
+
1344
+ function FilterButtons() {
1345
+ // Each component subscribes to just what it needs
1346
+ const currentFilter = useRx(() => filter());
1347
+
1348
+ return (
1349
+ <div>
1350
+ <button
1351
+ className={currentFilter === "all" ? "active" : ""}
1352
+ onClick={() => setFilter("all")}
1353
+ >
1354
+ All
1355
+ </button>
1356
+ <button
1357
+ className={currentFilter === "active" ? "active" : ""}
1358
+ onClick={() => setFilter("active")}
1359
+ >
1360
+ Active
1361
+ </button>
1362
+ <button
1363
+ className={currentFilter === "completed" ? "active" : ""}
1364
+ onClick={() => setFilter("completed")}
1365
+ >
1366
+ Completed
1367
+ </button>
1368
+ </div>
1369
+ );
1370
+ }
1371
+ ```
1372
+
1373
+ ### StrictMode Compatibility
1374
+
1375
+ `useRx` is fully compatible with React StrictMode. It properly handles the double-invocation of effects that StrictMode uses to detect side effects.
1376
+
1377
+ ## Fine-Grained Reactivity with `pick()`
1378
+
1379
+ The `pick()` function enables fine-grained subscriptions to specific properties. This prevents unnecessary re-renders when unrelated properties change.
1380
+
1381
+ ### Basic Usage
1382
+
1383
+ ```typescript
1384
+ import { atom, effect, pick } from "rexfect";
1385
+
1386
+ const [user, setUser] = atom({
1387
+ name: "Ada Lovelace",
1388
+ email: "ada@example.com",
1389
+ visits: 0,
1390
+ lastActive: new Date(),
1391
+ });
1392
+
1393
+ // ❌ WITHOUT pick() - re-runs on ANY property change
1394
+ effect(() => {
1395
+ const u = user(); // Tracks entire object
1396
+ console.log("User name:", u.name);
1397
+ });
1398
+
1399
+ // ✅ WITH pick() - only re-runs when name changes
1400
+ effect(() => {
1401
+ // pick() creates a fine-grained subscription to just the selected value
1402
+ const name = pick(() => user().name);
1403
+ console.log("User name (optimized):", name);
1404
+ });
1405
+
1406
+ // This triggers the first effect but NOT the second
1407
+ setUser({ ...user(), visits: user().visits + 1 });
1408
+
1409
+ // This triggers BOTH effects
1410
+ setUser({ ...user(), name: "Grace Hopper" });
1411
+ ```
1412
+
1413
+ ### Using pick() with React
1414
+
1415
+ ```typescript
1416
+ import { atom } from "rexfect";
1417
+ import { useRx, pick } from "rexfect/react";
1418
+
1419
+ const [user, setUser] = atom({
1420
+ name: "Ada Lovelace",
1421
+ email: "ada@example.com",
1422
+ avatar: "/avatars/ada.png",
1423
+ stats: { posts: 42, followers: 1000 },
1424
+ });
1425
+
1426
+ // This component only re-renders when name changes
1427
+ function UserName() {
1428
+ const name = useRx(() => pick(() => user().name));
1429
+ return <h1>{name}</h1>;
1430
+ }
1431
+
1432
+ // This component only re-renders when email changes
1433
+ function UserEmail() {
1434
+ const email = useRx(() => pick(() => user().email));
1435
+ return <p>{email}</p>;
1436
+ }
1437
+
1438
+ // This component only re-renders when stats changes
1439
+ // Using shallow equality for the stats object
1440
+ function UserStats() {
1441
+ const stats = useRx(() => pick(() => user().stats, "shallow"));
1442
+ return (
1443
+ <p>
1444
+ {stats.posts} posts, {stats.followers} followers
1445
+ </p>
1446
+ );
1447
+ }
1448
+ ```
1449
+
1450
+ ### Custom Equality with pick()
1451
+
1452
+ For object properties, you can specify how to compare values:
1453
+
1454
+ ```typescript
1455
+ import { atom, effect, pick } from "rexfect";
1456
+
1457
+ const [state, setState] = atom({
1458
+ user: {
1459
+ profile: { name: "Ada", avatar: "👩‍💻" },
1460
+ settings: { theme: "dark", notifications: true },
1461
+ },
1462
+ stats: { pageViews: 0 },
1463
+ });
1464
+
1465
+ effect(() => {
1466
+ // Use "shallow" equality for the profile object
1467
+ // Only re-runs if profile properties actually changed
1468
+ const profile = pick(() => state().user.profile, "shallow");
1469
+ console.log("Profile:", profile);
1470
+ });
1471
+
1472
+ effect(() => {
1473
+ // Use custom equality - compare by specific field
1474
+ const theme = pick(
1475
+ () => state().user.settings,
1476
+ (a, b) => a.theme === b.theme // Only care about theme changes
1477
+ );
1478
+ console.log("Settings (theme only):", theme);
1479
+ });
1480
+ ```
1481
+
1482
+ ## Reading Without Tracking
1483
+
1484
+ Use `untrack()` to read atoms without creating dependencies:
1485
+
1486
+ ```typescript
1487
+ import { atom, effect, untrack } from "rexfect";
1488
+
1489
+ const [count, setCount] = atom(0);
1490
+ const [multiplier, setMultiplier] = atom(2);
1491
+
1492
+ effect(() => {
1493
+ const c = count(); // Tracked - changes trigger re-run
1494
+
1495
+ // multiplier is read but NOT tracked
1496
+ // Changes to multiplier won't trigger this effect
1497
+ const m = untrack(() => multiplier());
1498
+
1499
+ console.log(`${c} x ${m} = ${c * m}`);
1500
+ });
1501
+
1502
+ setCount(5); // Effect runs: "5 x 2 = 10"
1503
+ setMultiplier(3); // Effect does NOT run (untracked)
1504
+ setCount(6); // Effect runs: "6 x 3 = 18" (uses current multiplier)
1505
+ ```
1506
+
1507
+ ## TypeScript Support
1508
+
1509
+ rexfect is written in TypeScript and provides full type inference:
1510
+
1511
+ ```typescript
1512
+ import { atom, effect, action, pick } from "rexfect";
1513
+
1514
+ // Types are inferred from initial value
1515
+ const [count, setCount] = atom(0);
1516
+ // count: Signal<number>
1517
+ // setCount: Setter<number>
1518
+
1519
+ // Explicit generic for complex types
1520
+ const [user, setUser] = atom<User | null>(null);
1521
+ // user: Signal<User | null>
1522
+ // setUser: Setter<User | null>
1523
+
1524
+ // Actions with payload types (use "on" prefix for void actions)
1525
+ const onClick = action<MouseEvent>();
1526
+ // onClick: Action<MouseEvent, void>
1527
+
1528
+ // Void actions (no payload needed)
1529
+ const onReset = action();
1530
+ // onReset: Action<void, void>
1531
+
1532
+ // Actions with handlers (use verb naming)
1533
+ const addItem = action((item: Item) => items.push(item));
1534
+ // addItem: Action<Item, number>
1535
+
1536
+ // pick() infers the selected type
1537
+ const [state] = atom({ user: { name: "Ada" }, count: 0 });
1538
+ effect(() => {
1539
+ const name = pick(() => state().user.name);
1540
+ // name: string (inferred!)
1541
+ });
1542
+ ```
1543
+
1544
+ ## API Reference
1545
+
1546
+ ### Core Functions
1547
+
1548
+ #### `atom(initialValue, options?)`
1549
+
1550
+ Creates a reactive state container.
1551
+
1552
+ ```typescript
1553
+ function atom<T>(
1554
+ initialValue: T,
1555
+ options?: {
1556
+ equals?: Equality<T>; // "strict" | "shallow" | "shallow2" | "shallow3" | "deep" | (a, b) => boolean
1557
+ key?: string; // For debugging
1558
+ }
1559
+ ): [Signal<T>, Setter<T>];
1560
+ ```
1561
+
1562
+ **Returns:** `[signal, setter]` tuple
1563
+
1564
+ - `signal()` - Call to get current value (tracks in reactive contexts)
1565
+ - `signal.on(listener)` - Subscribe to changes, returns unsubscribe function
1566
+ - `setter(value)` - Update the value
1567
+
1568
+ #### `effect(fn, options?)`
1569
+
1570
+ Creates a synchronous reactive effect that re-runs when dependencies change.
1571
+
1572
+ ```typescript
1573
+ function effect(
1574
+ fn: (ctx: EffectContext) => void,
1575
+ options?: { name?: string }
1576
+ ): VoidFunction; // Returns dispose function
1577
+
1578
+ interface EffectContext {
1579
+ nth: number; // Run count (0 on first run)
1580
+ name?: string; // Effect name from options
1581
+ onError(handler: (error: unknown) => void): void;
1582
+ use<R>(plugin: (ctx: EffectContext) => R): R;
1583
+ }
1584
+ ```
1585
+
1586
+ #### `action(options?)` or `action(handler, options?)`
1587
+
1588
+ Creates an action dispatcher for async operations and workflows.
1589
+
1590
+ **Naming Convention:**
1591
+
1592
+ - Void actions (no handler): Use `on` prefix → `onClick`, `onSubmit`
1593
+ - Actions with handlers: Use verbs → `addItem`, `fetchUser`
1594
+
1595
+ ```typescript
1596
+ // Void action (event-like)
1597
+ function action<T = void>(options?: ActionOptions): Action<T, void>;
1598
+
1599
+ // Action with handler (receives ActionContext)
1600
+ function action<T, R>(
1601
+ handler: (payload: T, ctx: ActionContext) => R,
1602
+ options?: ActionOptions
1603
+ ): Action<T, R>;
1604
+
1605
+ interface ActionOptions {
1606
+ sealed?: boolean; // Auto-seal after first dispatch
1607
+ key?: string; // For debugging
1608
+ }
1609
+
1610
+ interface Action<T, R = void> {
1611
+ (payload: T): R; // Dispatch and return result
1612
+ on(listener: (payload: T) => void | Promise<void>): VoidFunction;
1613
+ sealed(): boolean; // Is the action sealed?
1614
+ fired(): boolean; // Has the action been dispatched?
1615
+ latest(): Promise<T>; // Get last payload or wait for next
1616
+ then(...): Promise<T>; // Thenable - await next dispatch
1617
+ }
1618
+ ```
1619
+
1620
+ #### `batch(fn)`
1621
+
1622
+ Groups multiple state updates into a single reactive cycle.
1623
+
1624
+ ```typescript
1625
+ function batch<T>(fn: () => T): T;
1626
+ ```
1627
+
1628
+ #### `untrack(fn)`
1629
+
1630
+ Reads atoms without creating dependencies.
1631
+
1632
+ ```typescript
1633
+ function untrack<T>(fn: () => T): T;
1634
+ ```
1635
+
1636
+ #### `pick(fn, equals?)`
1637
+
1638
+ Fine-grained selector for subscribing to specific properties.
1639
+
1640
+ ```typescript
1641
+ function pick<T>(
1642
+ fn: () => T,
1643
+ equals?: Equality<T> // Default: "strict"
1644
+ ): T;
1645
+ ```
1646
+
1647
+ **Note:** Only works inside reactive contexts (`effect()`, `useRx()`).
1648
+
1649
+ ---
1650
+
1651
+ ### Async Utilities (`rexfect/async`)
1652
+
1653
+ #### `loadable(signal)`
1654
+
1655
+ Extracts loading state from a Promise-valued signal.
1656
+
1657
+ ```typescript
1658
+ function loadable<T>(
1659
+ signal: Signal<Promise<T> | null | undefined>
1660
+ ): LoadableResult<T> | null;
1661
+
1662
+ interface LoadableResult<T> {
1663
+ loading: boolean;
1664
+ data: T | undefined;
1665
+ error: unknown | undefined;
1666
+ }
1667
+ ```
1668
+
1669
+ **Returns:** `null` if signal value is null/undefined, otherwise `LoadableResult`.
1670
+
1671
+ #### `read(signal)`
1672
+
1673
+ Suspense-compatible reading from a Promise signal. Throws the Promise (suspends) until resolved.
1674
+
1675
+ ```typescript
1676
+ function read<T>(signal: Signal<Promise<T>>): T;
1677
+
1678
+ // Combinators
1679
+ read.all(signals): { ... } // Read all, suspend until done
1680
+ read.race(signals): ... // Read first to settle
1681
+ read.any(signals): ... // Read first to succeed
1682
+ read.settled(signals): ... // Read all (no throw on rejection)
1683
+ ```
1684
+
1685
+ **Note:** For use in React components with Suspense only.
1686
+
1687
+ #### `wait(input)`
1688
+
1689
+ Promise wrapper for any PromiseLike (including Actions).
1690
+
1691
+ ```typescript
1692
+ function wait<T>(input: PromiseLike<T>): Promise<T>;
1693
+
1694
+ // Combinators - work with arrays or objects
1695
+ wait.all([p1, p2]): Promise<[T1, T2]>
1696
+ wait.all({ a: p1, b: p2 }): Promise<{ a: T1, b: T2 }>
1697
+
1698
+ wait.race([p1, p2]): Promise<T1 | T2>
1699
+ wait.race({ a: p1, b: p2 }): Promise<["a" | "b", T]> // [winner, value]
1700
+
1701
+ wait.any([p1, p2]): Promise<T1 | T2>
1702
+ wait.any({ a: p1, b: p2 }): Promise<["a" | "b", T]>
1703
+
1704
+ wait.settled([p1, p2]): Promise<PromiseSettledResult[]>
1705
+ wait.settled({ a: p1, b: p2 }): Promise<{ a: PromiseSettledResult, ... }>
1706
+ ```
1707
+
1708
+ ---
1709
+
1710
+ ### React Bindings (`rexfect/react`)
1711
+
1712
+ #### `useRx(fn)`
1713
+
1714
+ React hook for reactive computations. Tracks all atoms read during the callback and re-renders when they change.
1715
+
1716
+ ```typescript
1717
+ function useRx<T>(fn: () => T): T;
1718
+ ```
1719
+
1720
+ **Features:**
1721
+
1722
+ - Works with multiple atoms
1723
+ - Supports conditional reads
1724
+ - StrictMode compatible
1725
+ - Works with `pick()` for fine-grained subscriptions
1726
+
1727
+ ---
1728
+
1729
+ ### Equality Utilities
1730
+
1731
+ Pre-built equality functions for atom configuration:
1732
+
1733
+ ```typescript
1734
+ import {
1735
+ strictEqual, // Object.is (default)
1736
+ shallowEqual, // 1-level object/array compare
1737
+ shallow2Equal, // 2-level compare
1738
+ shallow3Equal, // 3-level compare
1739
+ deepEqual, // Full recursive compare (lodash isEqual)
1740
+ } from "rexfect";
1741
+
1742
+ // Usage
1743
+ const [user, setUser] = atom(userData, { equals: shallowEqual });
1744
+ ```
1745
+
1746
+ ## Documentation
1747
+
1748
+ For more detailed information, see:
1749
+
1750
+ - [API Reference](./docs/API.md) - Complete API documentation
1751
+ - [Core Concepts](./docs/CONCEPTS.md) - Deep dive into how rexfect works
1752
+ - [Design Document](./docs/DESIGN.md) - Architecture and design decisions
1753
+
1754
+ ## License
1755
+
1756
+ MIT