atomirx 0.0.2 → 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +866 -159
- package/dist/core/atom.d.ts +83 -6
- package/dist/core/batch.d.ts +3 -3
- package/dist/core/derived.d.ts +55 -21
- package/dist/core/effect.d.ts +47 -51
- package/dist/core/getAtomState.d.ts +29 -0
- package/dist/core/promiseCache.d.ts +23 -32
- package/dist/core/select.d.ts +208 -29
- package/dist/core/types.d.ts +55 -19
- package/dist/core/withReady.d.ts +69 -0
- package/dist/index-CqO6BDwj.cjs +1 -0
- package/dist/index-D8RDOTB_.js +1319 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +9 -7
- package/dist/index.js +12 -10
- package/dist/react/index.cjs +10 -10
- package/dist/react/index.d.ts +2 -1
- package/dist/react/index.js +423 -379
- package/dist/react/rx.d.ts +114 -25
- package/dist/react/useAction.d.ts +5 -4
- package/dist/react/{useValue.d.ts → useSelector.d.ts} +56 -25
- package/dist/react/useSelector.test.d.ts +1 -0
- package/package.json +1 -1
- package/src/core/atom.test.ts +307 -43
- package/src/core/atom.ts +143 -21
- package/src/core/batch.test.ts +10 -10
- package/src/core/batch.ts +3 -3
- package/src/core/derived.test.ts +727 -72
- package/src/core/derived.ts +141 -73
- package/src/core/effect.test.ts +259 -39
- package/src/core/effect.ts +62 -85
- package/src/core/getAtomState.ts +69 -0
- package/src/core/promiseCache.test.ts +5 -3
- package/src/core/promiseCache.ts +76 -71
- package/src/core/select.ts +405 -130
- package/src/core/selector.test.ts +574 -32
- package/src/core/types.ts +54 -26
- package/src/core/withReady.test.ts +360 -0
- package/src/core/withReady.ts +127 -0
- package/src/core/withUse.ts +1 -1
- package/src/index.test.ts +4 -4
- package/src/index.ts +11 -6
- package/src/react/index.ts +2 -1
- package/src/react/rx.test.tsx +173 -18
- package/src/react/rx.tsx +274 -43
- package/src/react/useAction.test.ts +12 -14
- package/src/react/useAction.ts +11 -9
- package/src/react/{useValue.test.ts → useSelector.test.ts} +16 -16
- package/src/react/{useValue.ts → useSelector.ts} +64 -33
- package/v2.md +44 -44
- package/dist/index-2ok7ilik.js +0 -1217
- package/dist/index-B_5SFzfl.cjs +0 -1
- /package/dist/{react/useValue.test.d.ts → core/withReady.test.d.ts} +0 -0
package/README.md
CHANGED
|
@@ -24,8 +24,6 @@ We can't solve every use case, but in the spirit of [`create-react-app`](https:/
|
|
|
24
24
|
- [Purpose](#purpose)
|
|
25
25
|
- [Table of Contents](#table-of-contents)
|
|
26
26
|
- [Installation](#installation)
|
|
27
|
-
- [Using Create React App](#using-create-react-app)
|
|
28
|
-
- [Adding to an Existing Project](#adding-to-an-existing-project)
|
|
29
27
|
- [Why atomirx?](#why-atomirx)
|
|
30
28
|
- [The Problem](#the-problem)
|
|
31
29
|
- [The Solution](#the-solution)
|
|
@@ -36,11 +34,23 @@ We can't solve every use case, but in the spirit of [`create-react-app`](https:/
|
|
|
36
34
|
- [Getting Started](#getting-started)
|
|
37
35
|
- [Basic Example: Counter](#basic-example-counter)
|
|
38
36
|
- [React Example: Todo App](#react-example-todo-app)
|
|
39
|
-
- [
|
|
37
|
+
- [Patterns \& Best Practices](#patterns--best-practices)
|
|
40
38
|
- [Naming: The `$` Suffix](#naming-the--suffix)
|
|
41
39
|
- [When to Use Each Primitive](#when-to-use-each-primitive)
|
|
42
40
|
- [Atom Storage: Stable Scopes Only](#atom-storage-stable-scopes-only)
|
|
41
|
+
- [Error Handling: Use `safe()` Not try/catch](#error-handling-use-safe-not-trycatch)
|
|
42
|
+
- [The Problem with try/catch](#the-problem-with-trycatch)
|
|
43
|
+
- [The Solution: safe()](#the-solution-safe)
|
|
44
|
+
- [Use Cases for safe()](#use-cases-for-safe)
|
|
45
|
+
- [SelectContext Methods: Synchronous Only](#selectcontext-methods-synchronous-only)
|
|
43
46
|
- [Complete Example: Todo App with Async](#complete-example-todo-app-with-async)
|
|
47
|
+
- [Deferred Entity Loading with `ready()`](#deferred-entity-loading-with-ready)
|
|
48
|
+
- [The Pattern](#the-pattern)
|
|
49
|
+
- [How It Works](#how-it-works)
|
|
50
|
+
- [Component Usage](#component-usage)
|
|
51
|
+
- [Benefits](#benefits)
|
|
52
|
+
- [When to Use `ready()`](#when-to-use-ready)
|
|
53
|
+
- [Important Notes](#important-notes)
|
|
44
54
|
- [Usage Guide](#usage-guide)
|
|
45
55
|
- [Atoms: The Foundation](#atoms-the-foundation)
|
|
46
56
|
- [Creating Atoms](#creating-atoms)
|
|
@@ -56,7 +66,6 @@ We can't solve every use case, but in the spirit of [`create-react-app`](https:/
|
|
|
56
66
|
- [Effects: Side Effect Management](#effects-side-effect-management)
|
|
57
67
|
- [Basic Effects](#basic-effects)
|
|
58
68
|
- [Effects with Cleanup](#effects-with-cleanup)
|
|
59
|
-
- [Effects with Error Handling](#effects-with-error-handling)
|
|
60
69
|
- [Effects with Multiple Dependencies](#effects-with-multiple-dependencies)
|
|
61
70
|
- [Async Patterns](#async-patterns)
|
|
62
71
|
- [`all()` - Wait for All (like Promise.all)](#all---wait-for-all-like-promiseall)
|
|
@@ -74,9 +83,12 @@ We can't solve every use case, but in the spirit of [`create-react-app`](https:/
|
|
|
74
83
|
- [Multiple Middleware Example](#multiple-middleware-example)
|
|
75
84
|
- [Hook Info Types](#hook-info-types)
|
|
76
85
|
- [React Integration](#react-integration)
|
|
77
|
-
- [
|
|
86
|
+
- [useSelector Hook](#useselector-hook)
|
|
78
87
|
- [Custom Equality](#custom-equality)
|
|
88
|
+
- [Why useSelector is Powerful](#why-useselector-is-powerful)
|
|
79
89
|
- [Reactive Components with rx](#reactive-components-with-rx)
|
|
90
|
+
- [Inline Loading and Error Handling](#inline-loading-and-error-handling)
|
|
91
|
+
- [Selector Memoization with `deps`](#selector-memoization-with-deps)
|
|
80
92
|
- [Async Actions with useAction](#async-actions-with-useaction)
|
|
81
93
|
- [Eager Execution](#eager-execution)
|
|
82
94
|
- [useAction API](#useaction-api)
|
|
@@ -93,8 +105,9 @@ We can't solve every use case, but in the spirit of [`create-react-app`](https:/
|
|
|
93
105
|
- [`define<T>(factory, options?)`](#definetfactory-options)
|
|
94
106
|
- [`isAtom(value)`](#isatomvalue)
|
|
95
107
|
- [SelectContext API](#selectcontext-api)
|
|
108
|
+
- [`state()` - Get Async State Without Throwing](#state---get-async-state-without-throwing)
|
|
96
109
|
- [React API](#react-api)
|
|
97
|
-
- [`
|
|
110
|
+
- [`useSelector`](#useselector)
|
|
98
111
|
- [`rx`](#rx)
|
|
99
112
|
- [`useAction`](#useaction)
|
|
100
113
|
- [`useStable`](#usestable)
|
|
@@ -109,16 +122,6 @@ We can't solve every use case, but in the spirit of [`create-react-app`](https:/
|
|
|
109
122
|
|
|
110
123
|
## Installation
|
|
111
124
|
|
|
112
|
-
### Using Create React App
|
|
113
|
-
|
|
114
|
-
The fastest way to get started is using our official template:
|
|
115
|
-
|
|
116
|
-
```bash
|
|
117
|
-
npx create-react-app my-app --template atomirx
|
|
118
|
-
```
|
|
119
|
-
|
|
120
|
-
### Adding to an Existing Project
|
|
121
|
-
|
|
122
125
|
atomirx is available as a package on NPM for use with a module bundler or in a Node application:
|
|
123
126
|
|
|
124
127
|
```bash
|
|
@@ -184,8 +187,8 @@ atomirx includes these APIs:
|
|
|
184
187
|
|
|
185
188
|
### React Bindings (`atomirx/react`)
|
|
186
189
|
|
|
187
|
-
- **`
|
|
188
|
-
- **`rx()`**: Inline reactive components
|
|
190
|
+
- **`useSelector()`**: Subscribe to atoms with automatic re-rendering (Suspense-based)
|
|
191
|
+
- **`rx()`**: Inline reactive components with optional loading/error handlers
|
|
189
192
|
- **`useAction()`**: Handle async operations with loading/error states
|
|
190
193
|
- **`useStable()`**: Stabilize object/array/callback references
|
|
191
194
|
|
|
@@ -200,16 +203,16 @@ import { atom, derived, effect } from "atomirx";
|
|
|
200
203
|
const count$ = atom(0);
|
|
201
204
|
|
|
202
205
|
// Step 2: Create derived state (computed values)
|
|
203
|
-
const doubled$ = derived(({
|
|
204
|
-
const message$ = derived(({
|
|
205
|
-
const count =
|
|
206
|
+
const doubled$ = derived(({ read }) => read(count$) * 2);
|
|
207
|
+
const message$ = derived(({ read }) => {
|
|
208
|
+
const count = read(count$);
|
|
206
209
|
return count === 0 ? "Click to start!" : `Count: ${count}`;
|
|
207
210
|
});
|
|
208
211
|
|
|
209
212
|
// Step 3: React to changes with effects
|
|
210
|
-
effect(({
|
|
211
|
-
console.log("Current count:",
|
|
212
|
-
console.log("Doubled value:",
|
|
213
|
+
effect(({ read }) => {
|
|
214
|
+
console.log("Current count:", read(count$));
|
|
215
|
+
console.log("Doubled value:", read(doubled$));
|
|
213
216
|
});
|
|
214
217
|
|
|
215
218
|
// Step 4: Update state
|
|
@@ -221,7 +224,7 @@ count$.set((n) => n + 1); // Logs: Current count: 6, Doubled value: 12
|
|
|
221
224
|
|
|
222
225
|
```tsx
|
|
223
226
|
import { atom, derived } from "atomirx";
|
|
224
|
-
import {
|
|
227
|
+
import { useSelector, rx } from "atomirx/react";
|
|
225
228
|
|
|
226
229
|
// Define your state
|
|
227
230
|
interface Todo {
|
|
@@ -234,9 +237,9 @@ const todos$ = atom<Todo[]>([]);
|
|
|
234
237
|
const filter$ = atom<"all" | "active" | "completed">("all");
|
|
235
238
|
|
|
236
239
|
// Derive computed state
|
|
237
|
-
const filteredTodos$ = derived(({
|
|
238
|
-
const todos =
|
|
239
|
-
const filter =
|
|
240
|
+
const filteredTodos$ = derived(({ read }) => {
|
|
241
|
+
const todos = read(todos$);
|
|
242
|
+
const filter = read(filter$);
|
|
240
243
|
|
|
241
244
|
switch (filter) {
|
|
242
245
|
case "active":
|
|
@@ -248,8 +251,8 @@ const filteredTodos$ = derived(({ get }) => {
|
|
|
248
251
|
}
|
|
249
252
|
});
|
|
250
253
|
|
|
251
|
-
const stats$ = derived(({
|
|
252
|
-
const todos =
|
|
254
|
+
const stats$ = derived(({ read }) => {
|
|
255
|
+
const todos = read(todos$);
|
|
253
256
|
return {
|
|
254
257
|
total: todos.length,
|
|
255
258
|
completed: todos.filter((t) => t.completed).length,
|
|
@@ -270,7 +273,7 @@ const toggleTodo = (id: number) => {
|
|
|
270
273
|
|
|
271
274
|
// Components
|
|
272
275
|
function TodoList() {
|
|
273
|
-
const todos =
|
|
276
|
+
const todos = useSelector(filteredTodos$);
|
|
274
277
|
|
|
275
278
|
return (
|
|
276
279
|
<ul>
|
|
@@ -287,8 +290,8 @@ function Stats() {
|
|
|
287
290
|
// Fine-grained updates: only re-renders when stats change
|
|
288
291
|
return (
|
|
289
292
|
<footer>
|
|
290
|
-
{rx(({
|
|
291
|
-
const { total, completed, remaining } =
|
|
293
|
+
{rx(({ read }) => {
|
|
294
|
+
const { total, completed, remaining } = read(stats$);
|
|
292
295
|
return (
|
|
293
296
|
<span>
|
|
294
297
|
{remaining} of {total} remaining
|
|
@@ -300,9 +303,9 @@ function Stats() {
|
|
|
300
303
|
}
|
|
301
304
|
```
|
|
302
305
|
|
|
303
|
-
##
|
|
306
|
+
## Patterns & Best Practices
|
|
304
307
|
|
|
305
|
-
Following consistent
|
|
308
|
+
Following consistent patterns and best practices makes atomirx code more readable and maintainable across your team.
|
|
306
309
|
|
|
307
310
|
### Naming: The `$` Suffix
|
|
308
311
|
|
|
@@ -316,7 +319,7 @@ All atoms (both `atom()` and `derived()`) should use the `$` suffix. This conven
|
|
|
316
319
|
// ✅ Good - clear that these are atoms
|
|
317
320
|
const count$ = atom(0);
|
|
318
321
|
const user$ = atom<User | null>(null);
|
|
319
|
-
const filteredItems$ = derived(({
|
|
322
|
+
const filteredItems$ = derived(({ read }) => /* ... */);
|
|
320
323
|
|
|
321
324
|
// ❌ Avoid - unclear what's reactive
|
|
322
325
|
const count = atom(0);
|
|
@@ -353,7 +356,7 @@ const todos$ = atom(() => fetchTodos());
|
|
|
353
356
|
const filter$ = atom("all");
|
|
354
357
|
|
|
355
358
|
function TodoList() {
|
|
356
|
-
const todos =
|
|
359
|
+
const todos = useSelector(filteredTodos$);
|
|
357
360
|
// ...
|
|
358
361
|
}
|
|
359
362
|
```
|
|
@@ -367,9 +370,9 @@ const TodoModule = define(() => {
|
|
|
367
370
|
const todos$ = atom(() => fetchTodos(), { meta: { key: "todos" } });
|
|
368
371
|
const filter$ = atom<"all" | "active" | "completed">("all");
|
|
369
372
|
|
|
370
|
-
const filteredTodos$ = derived(({
|
|
371
|
-
const filter =
|
|
372
|
-
const todos =
|
|
373
|
+
const filteredTodos$ = derived(({ read }) => {
|
|
374
|
+
const filter = read(filter$);
|
|
375
|
+
const todos = read(todos$);
|
|
373
376
|
return filter === "all" ? todos : todos.filter(/* ... */);
|
|
374
377
|
});
|
|
375
378
|
|
|
@@ -386,7 +389,7 @@ const TodoModule = define(() => {
|
|
|
386
389
|
// Usage in React
|
|
387
390
|
function TodoList() {
|
|
388
391
|
const { filteredTodos$, setFilter } = TodoModule();
|
|
389
|
-
const todos =
|
|
392
|
+
const todos = useSelector(filteredTodos$);
|
|
390
393
|
// ...
|
|
391
394
|
}
|
|
392
395
|
```
|
|
@@ -414,9 +417,9 @@ const userData$ = atom(fetchUser(userId)); // Fetch immediately
|
|
|
414
417
|
|
|
415
418
|
```typescript
|
|
416
419
|
// derived() automatically unwraps Promises from atoms
|
|
417
|
-
const filteredTodoList$ = derived(({
|
|
418
|
-
const filter =
|
|
419
|
-
const todoList =
|
|
420
|
+
const filteredTodoList$ = derived(({ read }) => {
|
|
421
|
+
const filter = read(filter$);
|
|
422
|
+
const todoList = read(todoList$); // Promise is unwrapped automatically!
|
|
420
423
|
|
|
421
424
|
switch (filter) {
|
|
422
425
|
case "active":
|
|
@@ -433,8 +436,8 @@ const filteredTodoList$ = derived(({ get }) => {
|
|
|
433
436
|
|
|
434
437
|
```typescript
|
|
435
438
|
// Sync local state to server when it changes
|
|
436
|
-
effect(({
|
|
437
|
-
const settings =
|
|
439
|
+
effect(({ read, onCleanup }) => {
|
|
440
|
+
const settings = read(settings$);
|
|
438
441
|
|
|
439
442
|
const controller = new AbortController();
|
|
440
443
|
saveSettingsToServer(settings, { signal: controller.signal });
|
|
@@ -443,8 +446,8 @@ effect(({ get, onCleanup }) => {
|
|
|
443
446
|
});
|
|
444
447
|
|
|
445
448
|
// Update multiple atoms based on another atom's change
|
|
446
|
-
effect(({
|
|
447
|
-
const user =
|
|
449
|
+
effect(({ read }) => {
|
|
450
|
+
const user = read(currentUser$);
|
|
448
451
|
|
|
449
452
|
if (user) {
|
|
450
453
|
// Trigger fetches for user-specific data
|
|
@@ -454,11 +457,204 @@ effect(({ get }) => {
|
|
|
454
457
|
});
|
|
455
458
|
```
|
|
456
459
|
|
|
460
|
+
### Error Handling: Use `safe()` Not try/catch
|
|
461
|
+
|
|
462
|
+
When working with reactive selectors in `derived()`, `effect()`, `useSelector()`, and `rx()`, you need to be careful about how you handle errors. The standard JavaScript `try/catch` pattern can break atomirx's Suspense mechanism.
|
|
463
|
+
|
|
464
|
+
#### The Problem with try/catch
|
|
465
|
+
|
|
466
|
+
The `read()` function in selectors uses a **Suspense-like pattern**: when an atom is loading (contains a pending Promise), `read()` throws that Promise. This is how atomirx signals to React's Suspense that it should show a fallback.
|
|
467
|
+
|
|
468
|
+
**If you wrap `read()` in a try/catch, you'll catch the Promise** along with any actual errors:
|
|
469
|
+
|
|
470
|
+
```typescript
|
|
471
|
+
// ❌ WRONG - This breaks Suspense!
|
|
472
|
+
const data$ = derived(({ read }) => {
|
|
473
|
+
try {
|
|
474
|
+
const user = read(asyncUser$); // Throws Promise when loading!
|
|
475
|
+
return processUser(user);
|
|
476
|
+
} catch (e) {
|
|
477
|
+
// This catches BOTH:
|
|
478
|
+
// 1. The Promise (when loading) - breaks Suspense!
|
|
479
|
+
// 2. Actual errors from processUser()
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
This causes several problems:
|
|
486
|
+
|
|
487
|
+
1. **Loading state is lost** - Instead of suspending, your derived atom immediately returns `null`
|
|
488
|
+
2. **No Suspense fallback** - React never sees the loading state
|
|
489
|
+
3. **Silent failures** - You can't distinguish between "loading" and "error"
|
|
490
|
+
|
|
491
|
+
#### The Solution: safe()
|
|
492
|
+
|
|
493
|
+
atomirx provides the `safe()` utility in all selector contexts. It catches actual errors but **re-throws Promises** to preserve Suspense:
|
|
494
|
+
|
|
495
|
+
```typescript
|
|
496
|
+
// ✅ CORRECT - Use safe() for error handling
|
|
497
|
+
const data$ = derived(({ read, safe }) => {
|
|
498
|
+
const [err, user] = safe(() => {
|
|
499
|
+
const raw = read(asyncUser$); // Can throw Promise (Suspense) ✓
|
|
500
|
+
return processUser(raw); // Can throw Error ✓
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
if (err) {
|
|
504
|
+
// Only actual errors reach here, not loading state
|
|
505
|
+
console.error("Processing failed:", err);
|
|
506
|
+
return { error: err.message };
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
return { user };
|
|
510
|
+
});
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
**How `safe()` works:**
|
|
514
|
+
|
|
515
|
+
| Scenario | `try/catch` | `safe()` |
|
|
516
|
+
| ----------------- | ------------------ | ------------------------------- |
|
|
517
|
+
| Loading (Promise) | ❌ Catches Promise | ✅ Re-throws → Suspense |
|
|
518
|
+
| Error | ✅ Catches error | ✅ Returns `[error, undefined]` |
|
|
519
|
+
| Success | ✅ Returns value | ✅ Returns `[undefined, value]` |
|
|
520
|
+
|
|
521
|
+
#### Use Cases for safe()
|
|
522
|
+
|
|
523
|
+
**1. Parsing/Validation that might fail:**
|
|
524
|
+
|
|
525
|
+
```typescript
|
|
526
|
+
const parsedConfig$ = derived(({ read, safe }) => {
|
|
527
|
+
const [err, config] = safe(() => {
|
|
528
|
+
const raw = read(rawConfig$);
|
|
529
|
+
return JSON.parse(raw); // Can throw SyntaxError
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
if (err) {
|
|
533
|
+
return { valid: false, error: "Invalid JSON" };
|
|
534
|
+
}
|
|
535
|
+
return { valid: true, config };
|
|
536
|
+
});
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
**2. Graceful degradation with multiple sources:**
|
|
540
|
+
|
|
541
|
+
```typescript
|
|
542
|
+
const dashboard$ = derived(({ read, safe }) => {
|
|
543
|
+
// Primary data - required
|
|
544
|
+
const user = read(user$);
|
|
545
|
+
|
|
546
|
+
// Optional data - graceful degradation
|
|
547
|
+
const [err1, analytics] = safe(() => read(analytics$));
|
|
548
|
+
const [err2, notifications] = safe(() => read(notifications$));
|
|
549
|
+
|
|
550
|
+
return {
|
|
551
|
+
user,
|
|
552
|
+
analytics: err1 ? null : analytics,
|
|
553
|
+
notifications: err2 ? [] : notifications,
|
|
554
|
+
errors: [err1, err2].filter(Boolean),
|
|
555
|
+
};
|
|
556
|
+
});
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
**3. Error handling in effects:**
|
|
560
|
+
|
|
561
|
+
```typescript
|
|
562
|
+
effect(({ read, safe }) => {
|
|
563
|
+
const [err, data] = safe(() => {
|
|
564
|
+
const raw = read(asyncData$);
|
|
565
|
+
return transformData(raw);
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
if (err) {
|
|
569
|
+
console.error("Effect failed:", err);
|
|
570
|
+
return; // Skip the rest of the effect
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
saveToLocalStorage(data);
|
|
574
|
+
});
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
**4. Error handling in React components:**
|
|
578
|
+
|
|
579
|
+
```tsx
|
|
580
|
+
function UserProfile() {
|
|
581
|
+
const result = useSelector(({ read, safe }) => {
|
|
582
|
+
const [err, user] = safe(() => read(user$));
|
|
583
|
+
return { err, user };
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
if (result.err) {
|
|
587
|
+
return <ErrorMessage error={result.err} />;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
return <Profile user={result.user} />;
|
|
591
|
+
}
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
**5. With rx() for inline error handling:**
|
|
595
|
+
|
|
596
|
+
```tsx
|
|
597
|
+
<Suspense fallback={<Loading />}>
|
|
598
|
+
{rx(({ read, safe }) => {
|
|
599
|
+
const [err, posts] = safe(() => read(posts$));
|
|
600
|
+
if (err) return <ErrorBanner message="Failed to load posts" />;
|
|
601
|
+
return posts.map((post) => <PostCard key={post.id} post={post} />);
|
|
602
|
+
})}
|
|
603
|
+
</Suspense>
|
|
604
|
+
```
|
|
605
|
+
|
|
606
|
+
### SelectContext Methods: Synchronous Only
|
|
607
|
+
|
|
608
|
+
All context methods (`read`, `all`, `race`, `any`, `settled`, `safe`) must be called **synchronously** during selector execution. They cannot be used in async callbacks like `setTimeout`, `Promise.then`, or event handlers.
|
|
609
|
+
|
|
610
|
+
```typescript
|
|
611
|
+
// ❌ WRONG - Calling read() in async callback
|
|
612
|
+
derived(({ read }) => {
|
|
613
|
+
setTimeout(() => {
|
|
614
|
+
read(atom$); // Error: called outside selection context
|
|
615
|
+
}, 100);
|
|
616
|
+
return "value";
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
// ❌ WRONG - Storing read() for later use
|
|
620
|
+
let savedRead;
|
|
621
|
+
select(({ read }) => {
|
|
622
|
+
savedRead = read; // Don't do this!
|
|
623
|
+
return read(atom$);
|
|
624
|
+
});
|
|
625
|
+
savedRead(atom$); // Error: called outside selection context
|
|
626
|
+
|
|
627
|
+
// ✅ CORRECT - For async access, use atom.get() directly
|
|
628
|
+
effect(({ read }) => {
|
|
629
|
+
const config = read(config$);
|
|
630
|
+
|
|
631
|
+
setTimeout(async () => {
|
|
632
|
+
// Use atom.get() for async access (not tracked as dependency)
|
|
633
|
+
const data = myMutableAtom$.get();
|
|
634
|
+
const asyncData = await myDerivedAtom$.get();
|
|
635
|
+
console.log(data, asyncData);
|
|
636
|
+
}, 100);
|
|
637
|
+
});
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
**Why this restriction?**
|
|
641
|
+
|
|
642
|
+
1. **Dependency tracking**: Context methods track which atoms are accessed to know when to recompute. This tracking only works during synchronous execution.
|
|
643
|
+
|
|
644
|
+
2. **Predictable behavior**: If `read()` could be called at any time, the reactive graph would be unpredictable and hard to debug.
|
|
645
|
+
|
|
646
|
+
3. **Clear error messages**: Rather than silently failing to track dependencies, atomirx throws a helpful error explaining the issue.
|
|
647
|
+
|
|
648
|
+
**For async access**, use `atom.get()` directly:
|
|
649
|
+
|
|
650
|
+
- `mutableAtom$.get()` - Returns the raw value (may be a Promise)
|
|
651
|
+
- `await derivedAtom$.get()` - Returns a Promise that resolves to the computed value
|
|
652
|
+
|
|
457
653
|
### Complete Example: Todo App with Async
|
|
458
654
|
|
|
459
655
|
```typescript
|
|
460
656
|
import { atom, derived } from "atomirx";
|
|
461
|
-
import {
|
|
657
|
+
import { useSelector, rx } from "atomirx/react";
|
|
462
658
|
import { Suspense } from "react";
|
|
463
659
|
|
|
464
660
|
// Atoms store values (including Promises)
|
|
@@ -466,9 +662,9 @@ const filter$ = atom<"all" | "active" | "completed">("all");
|
|
|
466
662
|
const todoList$ = atom(() => fetchAllTodos()); // Lazy init, re-runs on reset()
|
|
467
663
|
|
|
468
664
|
// Derived handles reactive transformations (auto-unwraps Promises)
|
|
469
|
-
const filteredTodoList$ = derived(({
|
|
470
|
-
const filter =
|
|
471
|
-
const todoList =
|
|
665
|
+
const filteredTodoList$ = derived(({ read }) => {
|
|
666
|
+
const filter = read(filter$);
|
|
667
|
+
const todoList = read(todoList$); // This is the resolved value, not a Promise!
|
|
472
668
|
|
|
473
669
|
switch (filter) {
|
|
474
670
|
case "active": return todoList.filter(t => !t.completed);
|
|
@@ -477,9 +673,9 @@ const filteredTodoList$ = derived(({ get }) => {
|
|
|
477
673
|
}
|
|
478
674
|
});
|
|
479
675
|
|
|
480
|
-
// In UI -
|
|
676
|
+
// In UI - useSelector suspends until data is ready
|
|
481
677
|
function TodoList() {
|
|
482
|
-
const filteredTodoList =
|
|
678
|
+
const filteredTodoList = useSelector(filteredTodoList$);
|
|
483
679
|
|
|
484
680
|
return (
|
|
485
681
|
<ul>
|
|
@@ -494,8 +690,8 @@ function TodoList() {
|
|
|
494
690
|
function App() {
|
|
495
691
|
return (
|
|
496
692
|
<Suspense fallback={<div>Loading todos...</div>}>
|
|
497
|
-
{rx(({
|
|
498
|
-
|
|
693
|
+
{rx(({ read }) =>
|
|
694
|
+
read(filteredTodoList$).map(todo => <Todo key={todo.id} todo={todo} />)
|
|
499
695
|
)}
|
|
500
696
|
</Suspense>
|
|
501
697
|
);
|
|
@@ -511,6 +707,229 @@ function RefreshButton() {
|
|
|
511
707
|
}
|
|
512
708
|
```
|
|
513
709
|
|
|
710
|
+
### Deferred Entity Loading with `ready()`
|
|
711
|
+
|
|
712
|
+
When building detail pages (e.g., `/article/:id`), you often need to:
|
|
713
|
+
|
|
714
|
+
1. Wait for a route parameter to be set
|
|
715
|
+
2. Fetch data based on that parameter
|
|
716
|
+
3. Share the loaded entity across multiple components
|
|
717
|
+
|
|
718
|
+
The `ready()` method in derived atoms provides an elegant solution for this pattern.
|
|
719
|
+
|
|
720
|
+
#### The Pattern
|
|
721
|
+
|
|
722
|
+
```typescript
|
|
723
|
+
import { atom, derived, effect, readonly, define } from "atomirx";
|
|
724
|
+
|
|
725
|
+
const articleModule = define(() => {
|
|
726
|
+
// Current article ID - set from route
|
|
727
|
+
const currentArticleId$ = atom<string | undefined>(undefined);
|
|
728
|
+
|
|
729
|
+
// Article cache - normalized storage
|
|
730
|
+
const articleCache$ = atom<Record<string, Article>>({});
|
|
731
|
+
|
|
732
|
+
// Current article - uses ready() to wait for both ID and cached data
|
|
733
|
+
const currentArticle$ = derived(({ ready }) => {
|
|
734
|
+
const id = ready(currentArticleId$); // Suspends if undefined
|
|
735
|
+
const article = ready(articleCache$, (cache) => cache[id]); // Suspends if not cached
|
|
736
|
+
return article;
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
// Fetch article when ID changes
|
|
740
|
+
effect(({ read }) => {
|
|
741
|
+
const id = read(currentArticleId$);
|
|
742
|
+
if (!id) return;
|
|
743
|
+
|
|
744
|
+
// Skip if already cached
|
|
745
|
+
const cache = read(articleCache$);
|
|
746
|
+
if (cache[id]) return;
|
|
747
|
+
|
|
748
|
+
// Fetch and cache
|
|
749
|
+
// ─────────────────────────────────────────────────────────────
|
|
750
|
+
// Optional: Track loading/error states in cache for more control
|
|
751
|
+
// type CacheEntry<T> =
|
|
752
|
+
// | { status: "loading" }
|
|
753
|
+
// | { status: "error"; error: Error }
|
|
754
|
+
// | { status: "success"; data: T };
|
|
755
|
+
// const articleCache$ = atom<Record<string, CacheEntry<Article>>>({});
|
|
756
|
+
//
|
|
757
|
+
// articleCache$.set((prev) => ({ ...prev, [id]: { status: "loading" } }));
|
|
758
|
+
// fetch(...)
|
|
759
|
+
// .then((article) => articleCache$.set((prev) => ({
|
|
760
|
+
// ...prev, [id]: { status: "success", data: article }
|
|
761
|
+
// })))
|
|
762
|
+
// .catch((error) => articleCache$.set((prev) => ({
|
|
763
|
+
// ...prev, [id]: { status: "error", error }
|
|
764
|
+
// })));
|
|
765
|
+
// ─────────────────────────────────────────────────────────────
|
|
766
|
+
fetch(`/api/articles/${id}`)
|
|
767
|
+
.then((r) => r.json())
|
|
768
|
+
.then((article) => {
|
|
769
|
+
articleCache$.set((prev) => ({ ...prev, [id]: article }));
|
|
770
|
+
});
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
return {
|
|
774
|
+
...readonly({ currentArticleId$, currentArticle$ }),
|
|
775
|
+
|
|
776
|
+
// ─────────────────────────────────────────────────────────────
|
|
777
|
+
// navigateTo(id) - Navigate to a new article
|
|
778
|
+
// ─────────────────────────────────────────────────────────────
|
|
779
|
+
// Flow:
|
|
780
|
+
// 1. Set currentArticleId$ to new ID
|
|
781
|
+
// 2. currentArticle$ recomputes:
|
|
782
|
+
// - If cached: ready() succeeds → UI shows article immediately
|
|
783
|
+
// - If not cached: ready() suspends → UI shows <Skeleton />
|
|
784
|
+
// 3. effect() runs in parallel:
|
|
785
|
+
// - If cached: early return (no fetch)
|
|
786
|
+
// - If not cached: fetch → update articleCache$
|
|
787
|
+
// 4. When cache updated, currentArticle$ recomputes → UI shows article
|
|
788
|
+
navigateTo: (id: string) => currentArticleId$.set(id),
|
|
789
|
+
|
|
790
|
+
// ─────────────────────────────────────────────────────────────
|
|
791
|
+
// invalidate(id) - Mark an article as stale (soft invalidation)
|
|
792
|
+
// ─────────────────────────────────────────────────────────────
|
|
793
|
+
// Flow:
|
|
794
|
+
// 1. Guard: Skip if id === currentArticleId (don't disrupt current view)
|
|
795
|
+
// 2. Remove article from cache
|
|
796
|
+
// 3. No immediate effect on UI (current article unchanged)
|
|
797
|
+
// 4. Next navigateTo(id) will trigger fresh fetch
|
|
798
|
+
// Use case: Background sync detected article was updated elsewhere
|
|
799
|
+
invalidate: (id: string) => {
|
|
800
|
+
if (id === currentArticleId$.get()) return;
|
|
801
|
+
articleCache$.set((prev) => {
|
|
802
|
+
const { [id]: _, ...rest } = prev;
|
|
803
|
+
return rest;
|
|
804
|
+
});
|
|
805
|
+
},
|
|
806
|
+
|
|
807
|
+
// ─────────────────────────────────────────────────────────────
|
|
808
|
+
// refresh() - Force refetch current article (hard refresh)
|
|
809
|
+
// ─────────────────────────────────────────────────────────────
|
|
810
|
+
// Flow:
|
|
811
|
+
// 1. Remove current article from cache
|
|
812
|
+
// 2. currentArticle$ recomputes:
|
|
813
|
+
// - ready() suspends (cache miss) → UI shows <Skeleton />
|
|
814
|
+
// 3. effect() runs:
|
|
815
|
+
// - Cache miss detected → fetch starts
|
|
816
|
+
// 4. Fetch completes → cache updated → UI shows fresh data
|
|
817
|
+
// Use case: Pull-to-refresh, retry after error
|
|
818
|
+
refresh() {
|
|
819
|
+
articleCache$.set((prev) => {
|
|
820
|
+
const { [currentArticleId$.get()]: _, ...rest } = prev;
|
|
821
|
+
return rest;
|
|
822
|
+
});
|
|
823
|
+
},
|
|
824
|
+
};
|
|
825
|
+
});
|
|
826
|
+
```
|
|
827
|
+
|
|
828
|
+
#### How It Works
|
|
829
|
+
|
|
830
|
+
```
|
|
831
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
832
|
+
│ Route: /article/:id │
|
|
833
|
+
│ → navigateTo(id) │
|
|
834
|
+
└─────────────────────────────────────────────────────────────┘
|
|
835
|
+
│
|
|
836
|
+
▼
|
|
837
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
838
|
+
│ effect() detects ID change │
|
|
839
|
+
│ → Checks cache, fetches if not cached │
|
|
840
|
+
│ → Updates articleCache$ │
|
|
841
|
+
└─────────────────────────────────────────────────────────────┘
|
|
842
|
+
│
|
|
843
|
+
▼
|
|
844
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
845
|
+
│ currentArticle$ (derived with ready()) │
|
|
846
|
+
│ → Suspends until ID is set AND article is in cache │
|
|
847
|
+
│ → Returns article when both conditions met │
|
|
848
|
+
└─────────────────────────────────────────────────────────────┘
|
|
849
|
+
│
|
|
850
|
+
▼
|
|
851
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
852
|
+
│ Components with Suspense │
|
|
853
|
+
│ → Show loading fallback while suspended │
|
|
854
|
+
│ → Render article when ready │
|
|
855
|
+
└─────────────────────────────────────────────────────────────┘
|
|
856
|
+
```
|
|
857
|
+
|
|
858
|
+
#### Component Usage
|
|
859
|
+
|
|
860
|
+
```tsx
|
|
861
|
+
// Page component - syncs route and provides boundaries
|
|
862
|
+
export const ArticlePage = () => {
|
|
863
|
+
const { id } = useParams();
|
|
864
|
+
const { navigateTo } = articleModule();
|
|
865
|
+
|
|
866
|
+
useEffect(() => {
|
|
867
|
+
navigateTo(id);
|
|
868
|
+
}, [id]);
|
|
869
|
+
|
|
870
|
+
return (
|
|
871
|
+
<ErrorBoundary fallback={<ErrorDialog />}>
|
|
872
|
+
<Suspense fallback={<ArticleSkeleton />}>
|
|
873
|
+
<ArticleHeader />
|
|
874
|
+
<ArticleContent />
|
|
875
|
+
<ArticleMeta />
|
|
876
|
+
</Suspense>
|
|
877
|
+
</ErrorBoundary>
|
|
878
|
+
);
|
|
879
|
+
};
|
|
880
|
+
|
|
881
|
+
// Child components - clean, no loading/error handling needed
|
|
882
|
+
export const ArticleHeader = () => {
|
|
883
|
+
const { currentArticle$ } = articleModule();
|
|
884
|
+
const article = useSelector(currentArticle$);
|
|
885
|
+
|
|
886
|
+
return <h1>{article.title}</h1>;
|
|
887
|
+
};
|
|
888
|
+
|
|
889
|
+
export const ArticleContent = () => {
|
|
890
|
+
const { currentArticle$ } = articleModule();
|
|
891
|
+
const article = useSelector(currentArticle$);
|
|
892
|
+
|
|
893
|
+
return <div className="content">{article.body}</div>;
|
|
894
|
+
};
|
|
895
|
+
|
|
896
|
+
export const ArticleMeta = () => {
|
|
897
|
+
const { currentArticle$ } = articleModule();
|
|
898
|
+
const article = useSelector(currentArticle$);
|
|
899
|
+
|
|
900
|
+
return (
|
|
901
|
+
<span>
|
|
902
|
+
By {article.author} • {article.date}
|
|
903
|
+
</span>
|
|
904
|
+
);
|
|
905
|
+
};
|
|
906
|
+
```
|
|
907
|
+
|
|
908
|
+
#### Benefits
|
|
909
|
+
|
|
910
|
+
| Benefit | Description |
|
|
911
|
+
| ------------------------ | ---------------------------------------------------------------- |
|
|
912
|
+
| **Clean components** | Child components just read the atom - no loading/error handling |
|
|
913
|
+
| **No prop drilling** | All components access `currentArticle$` directly from the module |
|
|
914
|
+
| **Automatic suspension** | `ready()` handles the "wait for data" logic declaratively |
|
|
915
|
+
| **Centralized fetching** | Effect handles when/how to fetch, components just consume |
|
|
916
|
+
| **Cache management** | Normalized cache enables invalidation and updates |
|
|
917
|
+
|
|
918
|
+
#### When to Use `ready()`
|
|
919
|
+
|
|
920
|
+
| Use Case | Example |
|
|
921
|
+
| -------------------- | ------------------------------------------------ |
|
|
922
|
+
| Route-based entities | `/article/:id`, `/user/:userId`, `/product/:sku` |
|
|
923
|
+
| Auth-gated content | Wait for `currentUser$` before showing dashboard |
|
|
924
|
+
| Dependent data | Wait for parent entity before fetching children |
|
|
925
|
+
| Multi-step forms | Wait for previous step data before showing next |
|
|
926
|
+
|
|
927
|
+
#### Important Notes
|
|
928
|
+
|
|
929
|
+
- **Only use in `derived()` or `effect()`** - `ready()` suspends computation, which only works in reactive contexts
|
|
930
|
+
- **Separate fetching from derivation** - Use `effect()` for side effects (fetching), `derived()` for computing values
|
|
931
|
+
- **Read from cache in effect** - Don't read `currentArticle$` in the effect that populates it; read `articleCache$` directly
|
|
932
|
+
|
|
514
933
|
## Usage Guide
|
|
515
934
|
|
|
516
935
|
### Atoms: The Foundation
|
|
@@ -543,7 +962,7 @@ const posts$ = atom(fetchPosts(), { fallback: [] });
|
|
|
543
962
|
import { getAtomState, isPending } from "atomirx";
|
|
544
963
|
|
|
545
964
|
// Direct access (outside reactive context)
|
|
546
|
-
console.log(count$.
|
|
965
|
+
console.log(count$.get()); // Current value (T or Promise<T>)
|
|
547
966
|
|
|
548
967
|
// Check atom state
|
|
549
968
|
const state = getAtomState(userData$);
|
|
@@ -556,7 +975,7 @@ if (state.status === "loading") {
|
|
|
556
975
|
}
|
|
557
976
|
|
|
558
977
|
// Quick loading check
|
|
559
|
-
console.log(isPending(userData$.
|
|
978
|
+
console.log(isPending(userData$.get())); // true while Promise is pending
|
|
560
979
|
```
|
|
561
980
|
|
|
562
981
|
#### Updating Atoms
|
|
@@ -585,7 +1004,7 @@ const unsubscribe = count$.on((newValue) => {
|
|
|
585
1004
|
|
|
586
1005
|
// Await async atoms
|
|
587
1006
|
await userData$;
|
|
588
|
-
console.log("User loaded:", userData$.
|
|
1007
|
+
console.log("User loaded:", userData$.get());
|
|
589
1008
|
|
|
590
1009
|
// Unsubscribe when done
|
|
591
1010
|
unsubscribe();
|
|
@@ -597,7 +1016,7 @@ unsubscribe();
|
|
|
597
1016
|
|
|
598
1017
|
| Property/Method | Type | Description |
|
|
599
1018
|
| --------------- | ------------ | -------------------------------------------------- |
|
|
600
|
-
| `
|
|
1019
|
+
| `get()` | `T` | Current value (may be a Promise for async atoms) |
|
|
601
1020
|
| `set(value)` | `void` | Update with value, Promise, or updater function |
|
|
602
1021
|
| `reset()` | `void` | Reset to initial value |
|
|
603
1022
|
| `on(listener)` | `() => void` | Subscribe to changes, returns unsubscribe function |
|
|
@@ -606,7 +1025,7 @@ unsubscribe();
|
|
|
606
1025
|
|
|
607
1026
|
| Property/Method | Type | Description |
|
|
608
1027
|
| --------------- | ---------------- | ---------------------------------------------- |
|
|
609
|
-
| `
|
|
1028
|
+
| `get()` | `Promise<T>` | Always returns a Promise |
|
|
610
1029
|
| `staleValue` | `T \| undefined` | Fallback or last resolved value during loading |
|
|
611
1030
|
| `state()` | `AtomState<T>` | Current state (ready/error/loading) |
|
|
612
1031
|
| `refresh()` | `void` | Re-run the computation |
|
|
@@ -625,6 +1044,8 @@ type AtomState<T> =
|
|
|
625
1044
|
|
|
626
1045
|
Derived atoms automatically compute values based on other atoms. They track dependencies at runtime and only recompute when those specific dependencies change.
|
|
627
1046
|
|
|
1047
|
+
> **Note:** For error handling in derived selectors, use `safe()` instead of try/catch. See [Error Handling: Use `safe()` Not try/catch](#error-handling-use-safe-not-trycatch).
|
|
1048
|
+
|
|
628
1049
|
#### Basic Derived State
|
|
629
1050
|
|
|
630
1051
|
```typescript
|
|
@@ -634,12 +1055,12 @@ const firstName$ = atom("John");
|
|
|
634
1055
|
const lastName$ = atom("Doe");
|
|
635
1056
|
|
|
636
1057
|
// Derived state with automatic dependency tracking
|
|
637
|
-
const fullName$ = derived(({
|
|
638
|
-
return `${
|
|
1058
|
+
const fullName$ = derived(({ read }) => {
|
|
1059
|
+
return `${read(firstName$)} ${read(lastName$)}`;
|
|
639
1060
|
});
|
|
640
1061
|
|
|
641
|
-
// Derived atoms always return Promise<T> for .
|
|
642
|
-
await fullName$.
|
|
1062
|
+
// Derived atoms always return Promise<T> for .get()
|
|
1063
|
+
await fullName$.get(); // "John Doe"
|
|
643
1064
|
|
|
644
1065
|
// Or use staleValue for synchronous access (after first resolution)
|
|
645
1066
|
fullName$.staleValue; // "John Doe" (or undefined before first resolution)
|
|
@@ -648,7 +1069,7 @@ fullName$.staleValue; // "John Doe" (or undefined before first resolution)
|
|
|
648
1069
|
fullName$.state(); // { status: "ready", value: "John Doe" }
|
|
649
1070
|
|
|
650
1071
|
firstName$.set("Jane");
|
|
651
|
-
await fullName$.
|
|
1072
|
+
await fullName$.get(); // "Jane Doe"
|
|
652
1073
|
```
|
|
653
1074
|
|
|
654
1075
|
#### Conditional Dependencies
|
|
@@ -660,13 +1081,13 @@ const showDetails$ = atom(false);
|
|
|
660
1081
|
const summary$ = atom("Brief overview");
|
|
661
1082
|
const details$ = atom("Detailed information...");
|
|
662
1083
|
|
|
663
|
-
const content$ = derived(({
|
|
1084
|
+
const content$ = derived(({ read }) => {
|
|
664
1085
|
// Only tracks showDetails$ initially
|
|
665
|
-
if (
|
|
1086
|
+
if (read(showDetails$)) {
|
|
666
1087
|
// details$ becomes a dependency only when showDetails$ is true
|
|
667
|
-
return
|
|
1088
|
+
return read(details$);
|
|
668
1089
|
}
|
|
669
|
-
return
|
|
1090
|
+
return read(summary$);
|
|
670
1091
|
});
|
|
671
1092
|
|
|
672
1093
|
// When showDetails$ is false:
|
|
@@ -676,9 +1097,9 @@ const content$ = derived(({ get }) => {
|
|
|
676
1097
|
|
|
677
1098
|
#### Suspense-Style Getters
|
|
678
1099
|
|
|
679
|
-
The `
|
|
1100
|
+
The `read()` function follows React Suspense semantics for async atoms:
|
|
680
1101
|
|
|
681
|
-
| Atom State | `
|
|
1102
|
+
| Atom State | `read()` Behavior |
|
|
682
1103
|
| ---------- | ----------------------------------------------- |
|
|
683
1104
|
| Loading | Throws the Promise (caught by derived/Suspense) |
|
|
684
1105
|
| Error | Throws the error |
|
|
@@ -687,9 +1108,9 @@ The `get()` function follows React Suspense semantics for async atoms:
|
|
|
687
1108
|
```typescript
|
|
688
1109
|
const user$ = atom(fetchUser());
|
|
689
1110
|
|
|
690
|
-
const userName$ = derived(({
|
|
1111
|
+
const userName$ = derived(({ read }) => {
|
|
691
1112
|
// Automatically handles loading/error states
|
|
692
|
-
const user =
|
|
1113
|
+
const user = read(user$);
|
|
693
1114
|
return user.name;
|
|
694
1115
|
});
|
|
695
1116
|
|
|
@@ -704,7 +1125,7 @@ if (state.status === "loading") {
|
|
|
704
1125
|
}
|
|
705
1126
|
|
|
706
1127
|
// Or use staleValue with a fallback
|
|
707
|
-
const userName = derived(({
|
|
1128
|
+
const userName = derived(({ read }) => read(user$).name, { fallback: "Guest" });
|
|
708
1129
|
userName.staleValue; // "Guest" during loading, then actual name
|
|
709
1130
|
```
|
|
710
1131
|
|
|
@@ -714,9 +1135,9 @@ userName.staleValue; // "Guest" during loading, then actual name
|
|
|
714
1135
|
const user$ = atom(fetchUser());
|
|
715
1136
|
const posts$ = atom(fetchPosts());
|
|
716
1137
|
|
|
717
|
-
const dashboard$ = derived(({
|
|
718
|
-
const user =
|
|
719
|
-
const posts =
|
|
1138
|
+
const dashboard$ = derived(({ read }) => {
|
|
1139
|
+
const user = read(user$); // Suspends if loading
|
|
1140
|
+
const posts = read(posts$); // Suspends if loading
|
|
720
1141
|
|
|
721
1142
|
return {
|
|
722
1143
|
userName: user.name,
|
|
@@ -730,7 +1151,7 @@ const state = dashboard$.state();
|
|
|
730
1151
|
|
|
731
1152
|
// Or use all() for explicit parallel loading
|
|
732
1153
|
const dashboard2$ = derived(({ all }) => {
|
|
733
|
-
const [user, posts] = all(user$, posts$);
|
|
1154
|
+
const [user, posts] = all([user$, posts$]);
|
|
734
1155
|
return { userName: user.name, postCount: posts.length };
|
|
735
1156
|
});
|
|
736
1157
|
```
|
|
@@ -739,6 +1160,8 @@ const dashboard2$ = derived(({ all }) => {
|
|
|
739
1160
|
|
|
740
1161
|
Effects run side effects whenever their dependencies change. They use the same reactive context as `derived()`.
|
|
741
1162
|
|
|
1163
|
+
> **Note:** For error handling in effects, use `safe()` instead of try/catch. See [Error Handling: Use `safe()` Not try/catch](#error-handling-use-safe-not-trycatch).
|
|
1164
|
+
|
|
742
1165
|
#### Basic Effects
|
|
743
1166
|
|
|
744
1167
|
```typescript
|
|
@@ -747,8 +1170,8 @@ import { atom, effect } from "atomirx";
|
|
|
747
1170
|
const count$ = atom(0);
|
|
748
1171
|
|
|
749
1172
|
// Effect runs immediately and on every change
|
|
750
|
-
const dispose = effect(({
|
|
751
|
-
console.log("Count is now:",
|
|
1173
|
+
const dispose = effect(({ read }) => {
|
|
1174
|
+
console.log("Count is now:", read(count$));
|
|
752
1175
|
});
|
|
753
1176
|
|
|
754
1177
|
count$.set(5); // Logs: "Count is now: 5"
|
|
@@ -764,8 +1187,8 @@ Use `onCleanup()` to register cleanup functions that run before the next executi
|
|
|
764
1187
|
```typescript
|
|
765
1188
|
const interval$ = atom(1000);
|
|
766
1189
|
|
|
767
|
-
const dispose = effect(({
|
|
768
|
-
const ms =
|
|
1190
|
+
const dispose = effect(({ read, onCleanup }) => {
|
|
1191
|
+
const ms = read(interval$);
|
|
769
1192
|
const id = setInterval(() => console.log("tick"), ms);
|
|
770
1193
|
|
|
771
1194
|
// Cleanup runs before next execution or on dispose
|
|
@@ -776,36 +1199,15 @@ interval$.set(500); // Clears old interval, starts new one
|
|
|
776
1199
|
dispose(); // Clears interval completely
|
|
777
1200
|
```
|
|
778
1201
|
|
|
779
|
-
#### Effects with Error Handling
|
|
780
|
-
|
|
781
|
-
Use `onError()` to handle errors within the effect:
|
|
782
|
-
|
|
783
|
-
```typescript
|
|
784
|
-
const dispose = effect(({ get, onError }) => {
|
|
785
|
-
onError((e) => console.error("Effect failed:", e));
|
|
786
|
-
|
|
787
|
-
const data = get(dataAtom$);
|
|
788
|
-
riskyOperation(data);
|
|
789
|
-
});
|
|
790
|
-
|
|
791
|
-
// Or use options.onError for unhandled errors
|
|
792
|
-
const dispose2 = effect(
|
|
793
|
-
({ get }) => {
|
|
794
|
-
riskyOperation(get(dataAtom$));
|
|
795
|
-
},
|
|
796
|
-
{ onError: (e) => console.error("Unhandled:", e) }
|
|
797
|
-
);
|
|
798
|
-
```
|
|
799
|
-
|
|
800
1202
|
#### Effects with Multiple Dependencies
|
|
801
1203
|
|
|
802
1204
|
```typescript
|
|
803
1205
|
const user$ = atom<User | null>(null);
|
|
804
1206
|
const settings$ = atom({ notifications: true });
|
|
805
1207
|
|
|
806
|
-
effect(({
|
|
807
|
-
const user =
|
|
808
|
-
const settings =
|
|
1208
|
+
effect(({ read }) => {
|
|
1209
|
+
const user = read(user$);
|
|
1210
|
+
const settings = read(settings$);
|
|
809
1211
|
|
|
810
1212
|
if (user && settings.notifications) {
|
|
811
1213
|
analytics.identify(user.id);
|
|
@@ -826,8 +1228,8 @@ const posts$ = atom(fetchPosts());
|
|
|
826
1228
|
const comments$ = atom(fetchComments());
|
|
827
1229
|
|
|
828
1230
|
const dashboard$ = derived(({ all }) => {
|
|
829
|
-
// Suspends until ALL atoms resolve (
|
|
830
|
-
const [user, posts, comments] = all(user$, posts$, comments$);
|
|
1231
|
+
// Suspends until ALL atoms resolve (array-based)
|
|
1232
|
+
const [user, posts, comments] = all([user$, posts$, comments$]);
|
|
831
1233
|
|
|
832
1234
|
return { user, posts, comments };
|
|
833
1235
|
});
|
|
@@ -840,8 +1242,9 @@ const primaryApi$ = atom(fetchFromPrimary());
|
|
|
840
1242
|
const fallbackApi$ = atom(fetchFromFallback());
|
|
841
1243
|
|
|
842
1244
|
const data$ = derived(({ any }) => {
|
|
843
|
-
// Returns first successfully resolved value (
|
|
844
|
-
|
|
1245
|
+
// Returns first successfully resolved value (object-based, returns { key, value })
|
|
1246
|
+
const result = any({ primary: primaryApi$, fallback: fallbackApi$ });
|
|
1247
|
+
return result.value;
|
|
845
1248
|
});
|
|
846
1249
|
```
|
|
847
1250
|
|
|
@@ -852,8 +1255,9 @@ const cache$ = atom(checkCache());
|
|
|
852
1255
|
const api$ = atom(fetchFromApi());
|
|
853
1256
|
|
|
854
1257
|
const data$ = derived(({ race }) => {
|
|
855
|
-
// Returns first settled (ready OR error) (
|
|
856
|
-
|
|
1258
|
+
// Returns first settled (ready OR error) (object-based, returns { key, value })
|
|
1259
|
+
const result = race({ cache: cache$, api: api$ });
|
|
1260
|
+
return result.value;
|
|
857
1261
|
});
|
|
858
1262
|
```
|
|
859
1263
|
|
|
@@ -864,8 +1268,8 @@ const user$ = atom(fetchUser());
|
|
|
864
1268
|
const posts$ = atom(fetchPosts());
|
|
865
1269
|
|
|
866
1270
|
const results$ = derived(({ settled }) => {
|
|
867
|
-
// Returns status for each atom (
|
|
868
|
-
const [userResult, postsResult] = settled(user$, posts$);
|
|
1271
|
+
// Returns status for each atom (array-based)
|
|
1272
|
+
const [userResult, postsResult] = settled([user$, posts$]);
|
|
869
1273
|
|
|
870
1274
|
return {
|
|
871
1275
|
user: userResult.status === "ready" ? userResult.value : null,
|
|
@@ -877,12 +1281,12 @@ const results$ = derived(({ settled }) => {
|
|
|
877
1281
|
|
|
878
1282
|
#### Async Utility Summary
|
|
879
1283
|
|
|
880
|
-
| Utility | Input
|
|
881
|
-
| ----------- |
|
|
882
|
-
| `all()` |
|
|
883
|
-
| `any()` |
|
|
884
|
-
| `race()` |
|
|
885
|
-
| `settled()` |
|
|
1284
|
+
| Utility | Input | Output | Behavior |
|
|
1285
|
+
| ----------- | --------------- | ------------------------ | ---------------------------------- |
|
|
1286
|
+
| `all()` | Array of atoms | Array of values | Suspends until all ready |
|
|
1287
|
+
| `any()` | Record of atoms | `{ key, value }` (first) | First to resolve wins |
|
|
1288
|
+
| `race()` | Record of atoms | `{ key, value }` (first) | First to settle (ready/error) wins |
|
|
1289
|
+
| `settled()` | Array of atoms | Array of SettledResult | Suspends until all settled |
|
|
886
1290
|
|
|
887
1291
|
**SettledResult type:**
|
|
888
1292
|
|
|
@@ -1039,7 +1443,7 @@ onCreateHook.override((prev) => (info) => {
|
|
|
1039
1443
|
|
|
1040
1444
|
// Save to localStorage on every change
|
|
1041
1445
|
info.atom.on(() => {
|
|
1042
|
-
localStorage.setItem(storageKey, JSON.stringify(info.atom.
|
|
1446
|
+
localStorage.setItem(storageKey, JSON.stringify(info.atom.get()));
|
|
1043
1447
|
});
|
|
1044
1448
|
}
|
|
1045
1449
|
});
|
|
@@ -1088,7 +1492,7 @@ onCreateHook.override((prev) => (info) => {
|
|
|
1088
1492
|
// Resolve the next value (handle both direct value and reducer)
|
|
1089
1493
|
const nextValue =
|
|
1090
1494
|
typeof valueOrReducer === "function"
|
|
1091
|
-
? (valueOrReducer as (prev: unknown) => unknown)(info.atom.
|
|
1495
|
+
? (valueOrReducer as (prev: unknown) => unknown)(info.atom.get())
|
|
1092
1496
|
: valueOrReducer;
|
|
1093
1497
|
|
|
1094
1498
|
// Validate before applying
|
|
@@ -1167,12 +1571,14 @@ interface ModuleCreateInfo {
|
|
|
1167
1571
|
|
|
1168
1572
|
atomirx provides first-class React integration through the `atomirx/react` package.
|
|
1169
1573
|
|
|
1170
|
-
###
|
|
1574
|
+
### useSelector Hook
|
|
1171
1575
|
|
|
1172
|
-
Subscribe to atom values with automatic re-rendering
|
|
1576
|
+
Subscribe to atom values with automatic re-rendering.
|
|
1577
|
+
|
|
1578
|
+
> **Note:** For error handling in selectors, use `safe()` instead of try/catch. See [Error Handling: Use `safe()` Not try/catch](#error-handling-use-safe-not-trycatch).
|
|
1173
1579
|
|
|
1174
1580
|
```tsx
|
|
1175
|
-
import {
|
|
1581
|
+
import { useSelector } from "atomirx/react";
|
|
1176
1582
|
import { atom } from "atomirx";
|
|
1177
1583
|
|
|
1178
1584
|
const count$ = atom(0);
|
|
@@ -1180,15 +1586,15 @@ const user$ = atom<User | null>(null);
|
|
|
1180
1586
|
|
|
1181
1587
|
function Counter() {
|
|
1182
1588
|
// Shorthand: pass atom directly
|
|
1183
|
-
const count =
|
|
1589
|
+
const count = useSelector(count$);
|
|
1184
1590
|
|
|
1185
1591
|
// Context selector: compute derived value
|
|
1186
|
-
const doubled =
|
|
1592
|
+
const doubled = useSelector(({ read }) => read(count$) * 2);
|
|
1187
1593
|
|
|
1188
1594
|
// Multiple atoms
|
|
1189
|
-
const display =
|
|
1190
|
-
const count =
|
|
1191
|
-
const user =
|
|
1595
|
+
const display = useSelector(({ read }) => {
|
|
1596
|
+
const count = read(count$);
|
|
1597
|
+
const user = read(user$);
|
|
1192
1598
|
return user ? `${user.name}: ${count}` : `Anonymous: ${count}`;
|
|
1193
1599
|
});
|
|
1194
1600
|
|
|
@@ -1200,15 +1606,100 @@ function Counter() {
|
|
|
1200
1606
|
|
|
1201
1607
|
```tsx
|
|
1202
1608
|
// Only re-render when specific fields change
|
|
1203
|
-
const userName =
|
|
1204
|
-
({
|
|
1609
|
+
const userName = useSelector(
|
|
1610
|
+
({ read }) => read(user$)?.name,
|
|
1205
1611
|
(prev, next) => prev === next
|
|
1206
1612
|
);
|
|
1207
1613
|
```
|
|
1208
1614
|
|
|
1615
|
+
#### Why useSelector is Powerful
|
|
1616
|
+
|
|
1617
|
+
`useSelector` provides a unified API that replaces multiple hooks from other libraries:
|
|
1618
|
+
|
|
1619
|
+
**One hook for all use cases:**
|
|
1620
|
+
|
|
1621
|
+
```tsx
|
|
1622
|
+
// 1. Single atom (shorthand)
|
|
1623
|
+
const count = useSelector(count$);
|
|
1624
|
+
|
|
1625
|
+
// 2. Derived value (selector)
|
|
1626
|
+
const doubled = useSelector(({ read }) => read(count$) * 2);
|
|
1627
|
+
|
|
1628
|
+
// 3. Multiple atoms (all) - array-based
|
|
1629
|
+
const [user, posts] = useSelector(({ all }) => all([user$, posts$]));
|
|
1630
|
+
|
|
1631
|
+
// 4. Loadable mode (state) - no Suspense needed
|
|
1632
|
+
const userState = useSelector(({ state }) => state(user$));
|
|
1633
|
+
// { status: "loading" | "ready" | "error", value, error }
|
|
1634
|
+
|
|
1635
|
+
// 5. Error handling (safe) - preserves Suspense
|
|
1636
|
+
const result = useSelector(({ read, safe }) => {
|
|
1637
|
+
const [err, data] = safe(() => JSON.parse(read(rawJson$)));
|
|
1638
|
+
return err ? { error: err.message } : { data };
|
|
1639
|
+
});
|
|
1640
|
+
|
|
1641
|
+
// 6. First ready (any), race, allSettled
|
|
1642
|
+
const fastest = useSelector(({ any }) => any({ cache: cache$, api: api$ }));
|
|
1643
|
+
const results = useSelector(({ settled }) => settled([a$, b$, c$]));
|
|
1644
|
+
```
|
|
1645
|
+
|
|
1646
|
+
**Comparison with other libraries:**
|
|
1647
|
+
|
|
1648
|
+
| Use Case | atomirx | Jotai | Recoil | Zustand |
|
|
1649
|
+
| ------------------- | ------------------------------------------ | ------------------------------ | ------------------------------- | -------------------------- |
|
|
1650
|
+
| Single atom | `useSelector(atom$)` | `useAtomValue(atom)` | `useRecoilValue(atom)` | `useStore(s => s.value)` |
|
|
1651
|
+
| Derived value | `useSelector(({ read }) => ...)` | `useAtomValue(derivedAtom)` | `useRecoilValue(selector)` | `useStore(s => derive(s))` |
|
|
1652
|
+
| Multiple atoms | `useSelector(({ all }) => all([a$, b$]))` | Multiple `useAtomValue` calls | Multiple `useRecoilValue` calls | Multiple selectors |
|
|
1653
|
+
| Suspense mode | Built-in (default) | Built-in | Built-in | Manual |
|
|
1654
|
+
| Loadable mode | `useSelector(({ state }) => state(atom$))` | `useAtomValue(loadable(atom))` | `useRecoilValueLoadable(atom)` | Manual |
|
|
1655
|
+
| Safe error handling | `safe()` in selector | Manual try/catch | Manual try/catch | Manual |
|
|
1656
|
+
| Custom equality | 2nd parameter | `selectAtom` | N/A | 2nd parameter |
|
|
1657
|
+
|
|
1658
|
+
**Key advantages:**
|
|
1659
|
+
|
|
1660
|
+
1. **Single unified hook** - No need to choose between `useAtomValue`, `useAtomValueLoadable`, etc.
|
|
1661
|
+
2. **Composable selectors** - Combine multiple atoms, derive values, handle errors in one selector
|
|
1662
|
+
3. **Flexible async modes** - Switch between Suspense and loadable without changing atoms
|
|
1663
|
+
4. **Built-in utilities** - `all()`, `any()`, `race()`, `settled()`, `safe()`, `state()` available in selector
|
|
1664
|
+
5. **Type-safe** - Full TypeScript inference across all patterns
|
|
1665
|
+
|
|
1666
|
+
**Boilerplate comparison - Loading multiple async atoms:**
|
|
1667
|
+
|
|
1668
|
+
```tsx
|
|
1669
|
+
// ❌ Jotai - Multiple hooks + loadable wrapper
|
|
1670
|
+
const userLoadable = useAtomValue(loadable(userAtom));
|
|
1671
|
+
const postsLoadable = useAtomValue(loadable(postsAtom));
|
|
1672
|
+
const isLoading =
|
|
1673
|
+
userLoadable.state === "loading" || postsLoadable.state === "loading";
|
|
1674
|
+
const user = userLoadable.state === "hasData" ? userLoadable.data : null;
|
|
1675
|
+
const posts = postsLoadable.state === "hasData" ? postsLoadable.data : [];
|
|
1676
|
+
|
|
1677
|
+
// ❌ Recoil - Multiple hooks
|
|
1678
|
+
const userLoadable = useRecoilValueLoadable(userAtom);
|
|
1679
|
+
const postsLoadable = useRecoilValueLoadable(postsAtom);
|
|
1680
|
+
const isLoading =
|
|
1681
|
+
userLoadable.state === "loading" || postsLoadable.state === "loading";
|
|
1682
|
+
const user = userLoadable.state === "hasValue" ? userLoadable.contents : null;
|
|
1683
|
+
const posts = postsLoadable.state === "hasValue" ? postsLoadable.contents : [];
|
|
1684
|
+
|
|
1685
|
+
// ✅ atomirx - One hook, one selector
|
|
1686
|
+
const { isLoading, user, posts } = useSelector(({ state }) => {
|
|
1687
|
+
const userState = state(user$);
|
|
1688
|
+
const postsState = state(posts$);
|
|
1689
|
+
return {
|
|
1690
|
+
isLoading:
|
|
1691
|
+
userState.status === "loading" || postsState.status === "loading",
|
|
1692
|
+
user: userState.value,
|
|
1693
|
+
posts: postsState.value ?? [],
|
|
1694
|
+
};
|
|
1695
|
+
});
|
|
1696
|
+
```
|
|
1697
|
+
|
|
1209
1698
|
### Reactive Components with rx
|
|
1210
1699
|
|
|
1211
|
-
The `rx()` function creates inline reactive components for fine-grained updates
|
|
1700
|
+
The `rx()` function creates inline reactive components for fine-grained updates.
|
|
1701
|
+
|
|
1702
|
+
> **Note:** For error handling in selectors, use `safe()` instead of try/catch. See [Error Handling: Use `safe()` Not try/catch](#error-handling-use-safe-not-trycatch).
|
|
1212
1703
|
|
|
1213
1704
|
```tsx
|
|
1214
1705
|
import { rx } from "atomirx/react";
|
|
@@ -1220,17 +1711,17 @@ function Dashboard() {
|
|
|
1220
1711
|
<span>Count: {rx(count$)}</span>
|
|
1221
1712
|
|
|
1222
1713
|
{/* Derived value */}
|
|
1223
|
-
<span>Doubled: {rx(({
|
|
1714
|
+
<span>Doubled: {rx(({ read }) => read(count$) * 2)}</span>
|
|
1224
1715
|
|
|
1225
1716
|
{/* Complex rendering */}
|
|
1226
|
-
{rx(({
|
|
1227
|
-
const user =
|
|
1717
|
+
{rx(({ read }) => {
|
|
1718
|
+
const user = read(user$);
|
|
1228
1719
|
return user ? <UserCard user={user} /> : <LoginPrompt />;
|
|
1229
1720
|
})}
|
|
1230
1721
|
|
|
1231
1722
|
{/* Async with utilities */}
|
|
1232
1723
|
{rx(({ all }) => {
|
|
1233
|
-
const [user, posts] = all(user$, posts$);
|
|
1724
|
+
const [user, posts] = all([user$, posts$]);
|
|
1234
1725
|
return <Feed user={user} posts={posts} />;
|
|
1235
1726
|
})}
|
|
1236
1727
|
</div>
|
|
@@ -1240,6 +1731,79 @@ function Dashboard() {
|
|
|
1240
1731
|
|
|
1241
1732
|
**Key benefit**: The parent component doesn't re-render when atoms change - only the `rx` components do.
|
|
1242
1733
|
|
|
1734
|
+
#### Inline Loading and Error Handling
|
|
1735
|
+
|
|
1736
|
+
`rx()` supports optional `loading` and `error` handlers for inline async state handling without Suspense/ErrorBoundary:
|
|
1737
|
+
|
|
1738
|
+
```tsx
|
|
1739
|
+
function Dashboard() {
|
|
1740
|
+
return (
|
|
1741
|
+
<div>
|
|
1742
|
+
{/* With loading handler - no Suspense needed */}
|
|
1743
|
+
{rx(userData$, {
|
|
1744
|
+
loading: () => <Spinner />,
|
|
1745
|
+
})}
|
|
1746
|
+
|
|
1747
|
+
{/* With error handler - no ErrorBoundary needed */}
|
|
1748
|
+
{rx(userData$, {
|
|
1749
|
+
error: ({ error }) => <Alert>{String(error)}</Alert>,
|
|
1750
|
+
})}
|
|
1751
|
+
|
|
1752
|
+
{/* Both handlers - fully self-contained */}
|
|
1753
|
+
{rx(userData$, {
|
|
1754
|
+
loading: () => <UserSkeleton />,
|
|
1755
|
+
error: ({ error }) => <UserError error={error} />,
|
|
1756
|
+
})}
|
|
1757
|
+
|
|
1758
|
+
{/* With selector and options */}
|
|
1759
|
+
{rx(({ read }) => read(posts$).slice(0, 5), {
|
|
1760
|
+
loading: () => <PostsSkeleton count={5} />,
|
|
1761
|
+
error: ({ error }) => <PostsError onRetry={() => posts$.refresh()} />,
|
|
1762
|
+
equals: "shallow",
|
|
1763
|
+
})}
|
|
1764
|
+
</div>
|
|
1765
|
+
);
|
|
1766
|
+
}
|
|
1767
|
+
```
|
|
1768
|
+
|
|
1769
|
+
**When to use each approach:**
|
|
1770
|
+
|
|
1771
|
+
| Approach | Use When |
|
|
1772
|
+
| ----------------------------- | ------------------------------------------------------ |
|
|
1773
|
+
| `rx()` with Suspense | Shared loading UI across multiple components |
|
|
1774
|
+
| `rx()` with `loading`/`error` | Self-contained component with custom inline UI |
|
|
1775
|
+
| `rx()` with `state()` | Complex conditional rendering based on multiple states |
|
|
1776
|
+
|
|
1777
|
+
#### Selector Memoization with `deps`
|
|
1778
|
+
|
|
1779
|
+
By default, function selectors are recreated on every render. Use `deps` to control memoization:
|
|
1780
|
+
|
|
1781
|
+
```tsx
|
|
1782
|
+
function Component({ multiplier }: { multiplier: number }) {
|
|
1783
|
+
return (
|
|
1784
|
+
<div>
|
|
1785
|
+
{/* No memoization (default) - selector recreated every render */}
|
|
1786
|
+
{rx(({ read }) => read(count$) * 2)}
|
|
1787
|
+
{/* Stable forever - selector never recreated */}
|
|
1788
|
+
{rx(({ read }) => read(count$) * 2, { deps: [] })}
|
|
1789
|
+
{/* Recreate when multiplier changes */}
|
|
1790
|
+
{rx(({ read }) => read(count$) * multiplier, { deps: [multiplier] })}
|
|
1791
|
+
{/* Atom shorthand is always stable by reference */}
|
|
1792
|
+
{rx(count$)} {/* No deps needed */}
|
|
1793
|
+
</div>
|
|
1794
|
+
);
|
|
1795
|
+
}
|
|
1796
|
+
```
|
|
1797
|
+
|
|
1798
|
+
**Memoization behavior:**
|
|
1799
|
+
|
|
1800
|
+
| Input | `deps` | Behavior |
|
|
1801
|
+
| ------------------------------- | ----------- | --------------------------------- |
|
|
1802
|
+
| Atom (`rx(atom$)`) | (ignored) | Always memoized by atom reference |
|
|
1803
|
+
| Function (`rx(({ read }) => …`) | `undefined` | No memoization (recreated always) |
|
|
1804
|
+
| Function | `[]` | Stable forever (never recreated) |
|
|
1805
|
+
| Function | `[a, b]` | Recreated when deps change |
|
|
1806
|
+
|
|
1243
1807
|
### Async Actions with useAction
|
|
1244
1808
|
|
|
1245
1809
|
Handle async operations with built-in loading/error states:
|
|
@@ -1285,7 +1849,7 @@ const fetchUser = useAction(
|
|
|
1285
1849
|
|
|
1286
1850
|
// Re-execute when atom changes
|
|
1287
1851
|
const fetchPosts = useAction(
|
|
1288
|
-
async ({ signal }) => fetchPostsApi(filter$.
|
|
1852
|
+
async ({ signal }) => fetchPostsApi(filter$.get(), { signal }),
|
|
1289
1853
|
{ lazy: false, deps: [filter$] }
|
|
1290
1854
|
);
|
|
1291
1855
|
```
|
|
@@ -1340,13 +1904,13 @@ atomirx is designed to work seamlessly with React Suspense:
|
|
|
1340
1904
|
import { Suspense } from "react";
|
|
1341
1905
|
import { ErrorBoundary } from "react-error-boundary";
|
|
1342
1906
|
import { atom } from "atomirx";
|
|
1343
|
-
import {
|
|
1907
|
+
import { useSelector } from "atomirx/react";
|
|
1344
1908
|
|
|
1345
1909
|
const user$ = atom(fetchUser());
|
|
1346
1910
|
|
|
1347
1911
|
function UserProfile() {
|
|
1348
1912
|
// Suspends until user$ resolves
|
|
1349
|
-
const user =
|
|
1913
|
+
const user = useSelector(user$);
|
|
1350
1914
|
return <div>{user.name}</div>;
|
|
1351
1915
|
}
|
|
1352
1916
|
|
|
@@ -1475,34 +2039,104 @@ function isAtom<T>(value: unknown): value is Atom<T>;
|
|
|
1475
2039
|
|
|
1476
2040
|
### SelectContext API
|
|
1477
2041
|
|
|
1478
|
-
Available in `derived()`, `effect()`, `
|
|
2042
|
+
Available in `derived()`, `effect()`, `useSelector()`, and `rx()`:
|
|
1479
2043
|
|
|
1480
|
-
| Method | Signature
|
|
1481
|
-
| --------- |
|
|
1482
|
-
| `
|
|
1483
|
-
| `
|
|
1484
|
-
| `
|
|
1485
|
-
| `
|
|
1486
|
-
| `
|
|
2044
|
+
| Method | Signature | Description |
|
|
2045
|
+
| --------- | ----------------------------------------- | ---------------------------------------------------- |
|
|
2046
|
+
| `read` | `<T>(atom: Atom<T>) => Awaited<T>` | Read atom value with dependency tracking |
|
|
2047
|
+
| `ready` | `<T>(atom: Atom<T>) => NonNullable<T>` | Wait for non-null value (only in derived/effect) |
|
|
2048
|
+
| `all` | `(atoms[]) => values[]` | Wait for all atoms (Promise.all semantics) |
|
|
2049
|
+
| `any` | `({ key: atom }) => { key, value }` | First ready value (Promise.any semantics) |
|
|
2050
|
+
| `race` | `({ key: atom }) => { key, value }` | First settled value (Promise.race semantics) |
|
|
2051
|
+
| `settled` | `(atoms[]) => SettledResult[]` | All results (Promise.allSettled semantics) |
|
|
2052
|
+
| `state` | `(atom \| selector) => SelectStateResult` | Get async state without throwing (equality-friendly) |
|
|
2053
|
+
| `safe` | `(fn) => [error, result]` | Catch errors, preserve Suspense |
|
|
1487
2054
|
|
|
1488
2055
|
**Behavior:**
|
|
1489
2056
|
|
|
1490
|
-
- `
|
|
2057
|
+
- `read()`: Returns value if ready, throws error if error, throws Promise if loading
|
|
2058
|
+
- `ready()`: Returns non-null value, suspends if null/undefined (only in derived/effect)
|
|
1491
2059
|
- `all()`: Suspends until all atoms are ready, throws on first error
|
|
1492
2060
|
- `any()`: Returns first ready value, throws AggregateError if all error
|
|
1493
2061
|
- `race()`: Returns first settled (ready or error)
|
|
1494
2062
|
- `settled()`: Returns `{ status: "ready", value }` or `{ status: "error", error }` for each atom
|
|
2063
|
+
- `state()`: Returns `AtomState<T>` without throwing - useful for inline state handling
|
|
2064
|
+
- `safe()`: Catches errors but re-throws Promises to preserve Suspense
|
|
2065
|
+
|
|
2066
|
+
#### `state()` - Get Async State Without Throwing
|
|
2067
|
+
|
|
2068
|
+
The `state()` method returns an `AtomState<T>` object instead of throwing. This is useful when you want to handle loading/error states inline without Suspense.
|
|
2069
|
+
|
|
2070
|
+
**Available at every level** - derived, rx, useSelector, effect:
|
|
2071
|
+
|
|
2072
|
+
```typescript
|
|
2073
|
+
// 1. In derived() - Build dashboard with partial loading
|
|
2074
|
+
const dashboard$ = derived(({ state }) => {
|
|
2075
|
+
const userState = state(user$);
|
|
2076
|
+
const postsState = state(posts$);
|
|
2077
|
+
|
|
2078
|
+
return {
|
|
2079
|
+
user: userState.status === 'ready' ? userState.value : null,
|
|
2080
|
+
posts: postsState.status === 'ready' ? postsState.value : [],
|
|
2081
|
+
isLoading: userState.status === 'loading' || postsState.status === 'loading',
|
|
2082
|
+
};
|
|
2083
|
+
});
|
|
2084
|
+
|
|
2085
|
+
// 2. In rx() - Inline loading/error UI
|
|
2086
|
+
{rx(({ state }) => {
|
|
2087
|
+
const dataState = state(data$);
|
|
2088
|
+
|
|
2089
|
+
if (dataState.status === 'loading') return <Spinner />;
|
|
2090
|
+
if (dataState.status === 'error') return <Error error={dataState.error} />;
|
|
2091
|
+
return <Content data={dataState.value} />;
|
|
2092
|
+
})}
|
|
2093
|
+
|
|
2094
|
+
// 3. In useSelector() - Get state object in component
|
|
2095
|
+
function Component() {
|
|
2096
|
+
const dataState = useSelector(({ state }) => state(asyncAtom$));
|
|
2097
|
+
|
|
2098
|
+
if (dataState.status === 'loading') return <Spinner />;
|
|
2099
|
+
// ...
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
// 4. In effect() - React to state changes
|
|
2103
|
+
effect(({ state }) => {
|
|
2104
|
+
const connState = state(connection$);
|
|
2105
|
+
|
|
2106
|
+
if (connState.status === 'ready') {
|
|
2107
|
+
console.log('Connected:', connState.value);
|
|
2108
|
+
}
|
|
2109
|
+
});
|
|
2110
|
+
|
|
2111
|
+
// 5. With combined operations
|
|
2112
|
+
const allData$ = derived(({ state, all }) => {
|
|
2113
|
+
const result = state(() => all([a$, b$, c$]));
|
|
2114
|
+
|
|
2115
|
+
if (result.status === 'loading') return { loading: true };
|
|
2116
|
+
if (result.status === 'error') return { error: result.error };
|
|
2117
|
+
return { data: result.value };
|
|
2118
|
+
});
|
|
2119
|
+
```
|
|
2120
|
+
|
|
2121
|
+
**`state()` vs `read()`:**
|
|
2122
|
+
|
|
2123
|
+
| Method | On Loading | On Error | On Ready |
|
|
2124
|
+
| --------- | ------------------------------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------ |
|
|
2125
|
+
| `read()` | Throws Promise (Suspense) | Throws Error | Returns value |
|
|
2126
|
+
| `state()` | Returns `{ status: "loading", value: undefined, error: undefined }` | Returns `{ status: "error", value: undefined, error }` | Returns `{ status: "ready", value, error: undefined }` |
|
|
2127
|
+
|
|
2128
|
+
**Note:** `state()` returns `SelectStateResult<T>` with all properties always present (`status`, `value`, `error`). This enables easy destructuring and equality comparisons (no promise reference issues).
|
|
1495
2129
|
|
|
1496
2130
|
### React API
|
|
1497
2131
|
|
|
1498
|
-
#### `
|
|
2132
|
+
#### `useSelector`
|
|
1499
2133
|
|
|
1500
2134
|
```typescript
|
|
1501
2135
|
// Shorthand
|
|
1502
|
-
function
|
|
2136
|
+
function useSelector<T>(atom: Atom<T>): T;
|
|
1503
2137
|
|
|
1504
2138
|
// Context selector
|
|
1505
|
-
function
|
|
2139
|
+
function useSelector<T>(
|
|
1506
2140
|
selector: (context: SelectContext) => T,
|
|
1507
2141
|
equals?: (prev: T, next: T) => boolean
|
|
1508
2142
|
): T;
|
|
@@ -1513,14 +2147,87 @@ function useValue<T>(
|
|
|
1513
2147
|
```typescript
|
|
1514
2148
|
// Shorthand
|
|
1515
2149
|
function rx<T>(atom: Atom<T>): ReactNode;
|
|
2150
|
+
function rx<T>(atom: Atom<T>, options?: RxOptions<T>): ReactNode;
|
|
1516
2151
|
|
|
1517
2152
|
// Context selector
|
|
2153
|
+
function rx<T>(selector: (context: SelectContext) => T): ReactNode;
|
|
1518
2154
|
function rx<T>(
|
|
1519
2155
|
selector: (context: SelectContext) => T,
|
|
1520
|
-
|
|
2156
|
+
options?: Equality<T> | RxOptions<T>
|
|
1521
2157
|
): ReactNode;
|
|
2158
|
+
|
|
2159
|
+
interface RxOptions<T> {
|
|
2160
|
+
/** Equality function for value comparison */
|
|
2161
|
+
equals?: Equality<T>;
|
|
2162
|
+
/** Render function for loading state (wraps with Suspense) */
|
|
2163
|
+
loading?: () => ReactNode;
|
|
2164
|
+
/** Render function for error state (wraps with ErrorBoundary) */
|
|
2165
|
+
error?: (props: { error: unknown }) => ReactNode;
|
|
2166
|
+
/** Dependencies for selector memoization */
|
|
2167
|
+
deps?: unknown[];
|
|
2168
|
+
}
|
|
2169
|
+
```
|
|
2170
|
+
|
|
2171
|
+
**Selector memoization with `deps`:**
|
|
2172
|
+
|
|
2173
|
+
```tsx
|
|
2174
|
+
// No memoization (default) - selector recreated every render
|
|
2175
|
+
rx(({ read }) => read(count$) * multiplier);
|
|
2176
|
+
|
|
2177
|
+
// Stable forever - never recreated
|
|
2178
|
+
rx(({ read }) => read(count$) * 2, { deps: [] });
|
|
2179
|
+
|
|
2180
|
+
// Recreate when multiplier changes
|
|
2181
|
+
rx(({ read }) => read(count$) * multiplier, { deps: [multiplier] });
|
|
2182
|
+
|
|
2183
|
+
// Atom shorthand is always stable (deps ignored)
|
|
2184
|
+
rx(count$);
|
|
2185
|
+
```
|
|
2186
|
+
|
|
2187
|
+
**With inline loading/error handlers:**
|
|
2188
|
+
|
|
2189
|
+
```tsx
|
|
2190
|
+
// Loading handler only
|
|
2191
|
+
{
|
|
2192
|
+
rx(asyncAtom$, {
|
|
2193
|
+
loading: () => <Spinner />,
|
|
2194
|
+
});
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
// Error handler only
|
|
2198
|
+
{
|
|
2199
|
+
rx(asyncAtom$, {
|
|
2200
|
+
error: ({ error }) => <Alert>{String(error)}</Alert>,
|
|
2201
|
+
});
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
// Both handlers
|
|
2205
|
+
{
|
|
2206
|
+
rx(asyncAtom$, {
|
|
2207
|
+
loading: () => <Skeleton />,
|
|
2208
|
+
error: ({ error }) => <ErrorMessage error={error} />,
|
|
2209
|
+
});
|
|
2210
|
+
}
|
|
2211
|
+
|
|
2212
|
+
// With selector and options
|
|
2213
|
+
{
|
|
2214
|
+
rx(({ read }) => read(user$).profile, {
|
|
2215
|
+
loading: () => <ProfileSkeleton />,
|
|
2216
|
+
error: ({ error }) => <ProfileError error={error} />,
|
|
2217
|
+
equals: "shallow",
|
|
2218
|
+
});
|
|
2219
|
+
}
|
|
1522
2220
|
```
|
|
1523
2221
|
|
|
2222
|
+
**Behavior with options:**
|
|
2223
|
+
|
|
2224
|
+
| Options | Loading State | Error State | Ready State |
|
|
2225
|
+
| -------------------- | ------------------ | ---------------- | ------------ |
|
|
2226
|
+
| No options | Suspense | ErrorBoundary | Render value |
|
|
2227
|
+
| `{ loading }` | Render `loading()` | ErrorBoundary | Render value |
|
|
2228
|
+
| `{ error }` | Suspense | Render `error()` | Render value |
|
|
2229
|
+
| `{ loading, error }` | Render `loading()` | Render `error()` | Render value |
|
|
2230
|
+
|
|
1524
2231
|
#### `useAction`
|
|
1525
2232
|
|
|
1526
2233
|
```typescript
|
|
@@ -1559,7 +2266,7 @@ import { atom, derived } from "atomirx";
|
|
|
1559
2266
|
// Types are automatically inferred
|
|
1560
2267
|
const count$ = atom(0); // MutableAtom<number>
|
|
1561
2268
|
const name$ = atom("John"); // MutableAtom<string>
|
|
1562
|
-
const doubled$ = derived(({
|
|
2269
|
+
const doubled$ = derived(({ read }) => read(count$) * 2); // Atom<number>
|
|
1563
2270
|
|
|
1564
2271
|
// Explicit typing when needed
|
|
1565
2272
|
interface User {
|
|
@@ -1572,8 +2279,8 @@ const user$ = atom<User | null>(null); // MutableAtom<User | null>
|
|
|
1572
2279
|
const userData$ = atom<User>(fetchUser()); // MutableAtom<User>
|
|
1573
2280
|
|
|
1574
2281
|
// Type-safe selectors
|
|
1575
|
-
const userName$ = derived(({
|
|
1576
|
-
const user =
|
|
2282
|
+
const userName$ = derived(({ read }) => {
|
|
2283
|
+
const user = read(user$);
|
|
1577
2284
|
return user?.name ?? "Anonymous"; // Atom<string>
|
|
1578
2285
|
});
|
|
1579
2286
|
|
|
@@ -1638,8 +2345,8 @@ const todoList = createListAtom<Todo>();
|
|
|
1638
2345
|
|
|
1639
2346
|
### Community
|
|
1640
2347
|
|
|
1641
|
-
- [GitHub Issues](https://github.com/
|
|
1642
|
-
- [Discussions](https://github.com/
|
|
2348
|
+
- [GitHub Issues](https://github.com/linq2js/atomirx/issues) - Bug reports and feature requests
|
|
2349
|
+
- [Discussions](https://github.com/linq2js/atomirx/discussions) - Questions and community support
|
|
1643
2350
|
|
|
1644
2351
|
## License
|
|
1645
2352
|
|