atomirx 0.0.8 → 0.1.0
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 +198 -2234
- package/bin/cli.js +90 -0
- package/dist/core/derived.d.ts +2 -2
- package/dist/core/effect.d.ts +3 -2
- package/dist/core/onCreateHook.d.ts +15 -2
- package/dist/core/onErrorHook.d.ts +4 -1
- package/dist/core/pool.d.ts +78 -0
- package/dist/core/pool.test.d.ts +1 -0
- package/dist/core/select-boolean.test.d.ts +1 -0
- package/dist/core/select-pool.test.d.ts +1 -0
- package/dist/core/select.d.ts +278 -86
- package/dist/core/types.d.ts +233 -1
- package/dist/core/withAbort.d.ts +95 -0
- package/dist/core/withReady.d.ts +3 -3
- package/dist/devtools/constants.d.ts +41 -0
- package/dist/devtools/index.cjs +1 -0
- package/dist/devtools/index.d.ts +29 -0
- package/dist/devtools/index.js +429 -0
- package/dist/devtools/registry.d.ts +98 -0
- package/dist/devtools/registry.test.d.ts +1 -0
- package/dist/devtools/setup.d.ts +61 -0
- package/dist/devtools/types.d.ts +311 -0
- package/dist/index-BZEnfIcB.cjs +1 -0
- package/dist/index-BbPZhsDl.js +1653 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.js +18 -14
- package/dist/onDispatchHook-C8yLzr-o.cjs +1 -0
- package/dist/onDispatchHook-SKbiIUaJ.js +5 -0
- package/dist/onErrorHook-BGGy3tqK.js +38 -0
- package/dist/onErrorHook-DHBASmYw.cjs +1 -0
- package/dist/react/index.cjs +1 -1
- package/dist/react/index.js +191 -151
- package/dist/react/onDispatchHook.d.ts +106 -0
- package/dist/react/useAction.d.ts +4 -1
- package/dist/react-devtools/DevToolsPanel.d.ts +93 -0
- package/dist/react-devtools/EntityDetails.d.ts +10 -0
- package/dist/react-devtools/EntityList.d.ts +15 -0
- package/dist/react-devtools/LogList.d.ts +12 -0
- package/dist/react-devtools/hooks.d.ts +50 -0
- package/dist/react-devtools/index.cjs +1 -0
- package/dist/react-devtools/index.d.ts +31 -0
- package/dist/react-devtools/index.js +1589 -0
- package/dist/react-devtools/styles.d.ts +148 -0
- package/package.json +26 -2
- package/skills/atomirx/SKILL.md +456 -0
- package/skills/atomirx/references/async-patterns.md +188 -0
- package/skills/atomirx/references/atom-patterns.md +238 -0
- package/skills/atomirx/references/deferred-loading.md +191 -0
- package/skills/atomirx/references/derived-patterns.md +428 -0
- package/skills/atomirx/references/effect-patterns.md +426 -0
- package/skills/atomirx/references/error-handling.md +140 -0
- package/skills/atomirx/references/hooks.md +322 -0
- package/skills/atomirx/references/pool-patterns.md +229 -0
- package/skills/atomirx/references/react-integration.md +411 -0
- package/skills/atomirx/references/rules.md +407 -0
- package/skills/atomirx/references/select-context.md +309 -0
- package/skills/atomirx/references/service-template.md +172 -0
- package/skills/atomirx/references/store-template.md +205 -0
- package/skills/atomirx/references/testing-patterns.md +431 -0
- package/coverage/base.css +0 -224
- package/coverage/block-navigation.js +0 -87
- package/coverage/clover.xml +0 -1440
- package/coverage/coverage-final.json +0 -14
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +0 -131
- package/coverage/prettify.css +0 -1
- package/coverage/prettify.js +0 -2
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +0 -210
- package/coverage/src/core/atom.ts.html +0 -889
- package/coverage/src/core/batch.ts.html +0 -223
- package/coverage/src/core/define.ts.html +0 -805
- package/coverage/src/core/emitter.ts.html +0 -919
- package/coverage/src/core/equality.ts.html +0 -631
- package/coverage/src/core/hook.ts.html +0 -460
- package/coverage/src/core/index.html +0 -281
- package/coverage/src/core/isAtom.ts.html +0 -100
- package/coverage/src/core/isPromiseLike.ts.html +0 -133
- package/coverage/src/core/onCreateHook.ts.html +0 -138
- package/coverage/src/core/scheduleNotifyHook.ts.html +0 -94
- package/coverage/src/core/types.ts.html +0 -523
- package/coverage/src/core/withUse.ts.html +0 -253
- package/coverage/src/index.html +0 -116
- package/coverage/src/index.ts.html +0 -106
- package/dist/index-CBVj1kSj.js +0 -1350
- package/dist/index-Cxk9v0um.cjs +0 -1
- package/scripts/publish.js +0 -198
- package/src/core/atom.test.ts +0 -633
- package/src/core/atom.ts +0 -311
- package/src/core/atomState.test.ts +0 -342
- package/src/core/atomState.ts +0 -256
- package/src/core/batch.test.ts +0 -257
- package/src/core/batch.ts +0 -172
- package/src/core/define.test.ts +0 -343
- package/src/core/define.ts +0 -243
- package/src/core/derived.test.ts +0 -1215
- package/src/core/derived.ts +0 -450
- package/src/core/effect.test.ts +0 -802
- package/src/core/effect.ts +0 -188
- package/src/core/emitter.test.ts +0 -364
- package/src/core/emitter.ts +0 -392
- package/src/core/equality.test.ts +0 -392
- package/src/core/equality.ts +0 -182
- package/src/core/getAtomState.ts +0 -69
- package/src/core/hook.test.ts +0 -227
- package/src/core/hook.ts +0 -177
- package/src/core/isAtom.ts +0 -27
- package/src/core/isPromiseLike.test.ts +0 -72
- package/src/core/isPromiseLike.ts +0 -16
- package/src/core/onCreateHook.ts +0 -107
- package/src/core/onErrorHook.test.ts +0 -350
- package/src/core/onErrorHook.ts +0 -52
- package/src/core/promiseCache.test.ts +0 -241
- package/src/core/promiseCache.ts +0 -284
- package/src/core/scheduleNotifyHook.ts +0 -53
- package/src/core/select.ts +0 -729
- package/src/core/selector.test.ts +0 -799
- package/src/core/types.ts +0 -389
- package/src/core/withReady.test.ts +0 -534
- package/src/core/withReady.ts +0 -191
- package/src/core/withUse.test.ts +0 -249
- package/src/core/withUse.ts +0 -56
- package/src/index.test.ts +0 -80
- package/src/index.ts +0 -65
- package/src/react/index.ts +0 -21
- package/src/react/rx.test.tsx +0 -571
- package/src/react/rx.tsx +0 -531
- package/src/react/strictModeTest.tsx +0 -71
- package/src/react/useAction.test.ts +0 -987
- package/src/react/useAction.ts +0 -607
- package/src/react/useSelector.test.ts +0 -182
- package/src/react/useSelector.ts +0 -292
- package/src/react/useStable.test.ts +0 -553
- package/src/react/useStable.ts +0 -288
- package/tsconfig.json +0 -9
- package/v2.md +0 -725
- package/vite.config.ts +0 -42
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
# Effect Patterns
|
|
2
|
+
|
|
3
|
+
Effects run side effects when atoms change. Handles sync/async atoms, executes synchronously.
|
|
4
|
+
|
|
5
|
+
```typescript
|
|
6
|
+
const user$ = atom(fetchUser());
|
|
7
|
+
|
|
8
|
+
effect(
|
|
9
|
+
({ read }) => {
|
|
10
|
+
const user = read(user$); // Suspends until resolved
|
|
11
|
+
console.log(`User: ${user.name}`);
|
|
12
|
+
localStorage.setItem("lastUser", user.id);
|
|
13
|
+
},
|
|
14
|
+
{ meta: { key: "log.user" } }
|
|
15
|
+
);
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## When to Use
|
|
19
|
+
|
|
20
|
+
**Use `effect()` for:**
|
|
21
|
+
|
|
22
|
+
- Logging, persisting, syncing
|
|
23
|
+
- Triggering events, updating atoms
|
|
24
|
+
- Reacting to state changes with side effects
|
|
25
|
+
- Kicking off async work (fire-and-forget)
|
|
26
|
+
|
|
27
|
+
**NEVER use for:**
|
|
28
|
+
|
|
29
|
+
- User-triggered actions → plain function with `.set()`
|
|
30
|
+
- Computed values → `derived()`
|
|
31
|
+
- Operations needing return value → `derived()`
|
|
32
|
+
|
|
33
|
+
## Async in Effects
|
|
34
|
+
|
|
35
|
+
Effects are **sync** but CAN trigger async work:
|
|
36
|
+
|
|
37
|
+
```typescript
|
|
38
|
+
// ✅ Fire-and-forget async call
|
|
39
|
+
effect(
|
|
40
|
+
({ read }) => {
|
|
41
|
+
const productId = read(currentProductId$);
|
|
42
|
+
fetchAnalytics(productId); // No await, just trigger
|
|
43
|
+
},
|
|
44
|
+
{ meta: { key: "analytics.product" } }
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
// ✅ Assign Promise to atom (atom stores the Promise)
|
|
48
|
+
effect(
|
|
49
|
+
({ read }) => {
|
|
50
|
+
const productId = read(currentProductId$);
|
|
51
|
+
productDetails$.set(fetchProductDetails(productId)); // Promise assigned
|
|
52
|
+
},
|
|
53
|
+
{ meta: { key: "fetch.productDetails" } }
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// ✅ Async with signal for cancellation
|
|
57
|
+
effect(
|
|
58
|
+
({ read, signal }) => {
|
|
59
|
+
const userId = read(userId$);
|
|
60
|
+
fetch(`/api/user/${userId}`, { signal })
|
|
61
|
+
.then((r) => r.json())
|
|
62
|
+
.then((data) => userDetails$.set(data))
|
|
63
|
+
.catch((err) => {
|
|
64
|
+
if (err.name !== "AbortError") console.error(err);
|
|
65
|
+
});
|
|
66
|
+
},
|
|
67
|
+
{ meta: { key: "fetch.user" } }
|
|
68
|
+
);
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
| Pattern | Description |
|
|
72
|
+
| ------- | ----------- |
|
|
73
|
+
| `fetchSomething()` | Fire-and-forget, no await |
|
|
74
|
+
| `atom$.set(fetchX())` | Store Promise in atom |
|
|
75
|
+
| `fetch(..., { signal })` | Cancelable with AbortSignal |
|
|
76
|
+
|
|
77
|
+
## Features
|
|
78
|
+
|
|
79
|
+
| Feature | Description |
|
|
80
|
+
| ---------------- | --------------------------------- |
|
|
81
|
+
| Auto cleanup | Previous cleanup runs first |
|
|
82
|
+
| Suspense-aware | Waits for async atoms |
|
|
83
|
+
| Batched updates | Atom updates batched |
|
|
84
|
+
| Conditional deps | Only tracks accessed atoms |
|
|
85
|
+
| Eager execution | Runs immediately (unlike derived) |
|
|
86
|
+
|
|
87
|
+
## Core API
|
|
88
|
+
|
|
89
|
+
```typescript
|
|
90
|
+
interface Effect {
|
|
91
|
+
dispose: VoidFunction;
|
|
92
|
+
meta?: EffectMeta;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface EffectContext extends SelectContext {
|
|
96
|
+
onCleanup: (fn: VoidFunction) => void;
|
|
97
|
+
signal: AbortSignal;
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## CRITICAL Rules
|
|
102
|
+
|
|
103
|
+
### MUST Be Sync
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
// ❌ FORBIDDEN
|
|
107
|
+
effect(async ({ read }) => {
|
|
108
|
+
const data = await fetch("/api");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ✅ REQUIRED
|
|
112
|
+
const data$ = atom(fetch("/api").then((r) => r.json()));
|
|
113
|
+
effect(({ read }) => console.log(read(data$)));
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### NEVER try/catch — Use safe()
|
|
117
|
+
|
|
118
|
+
```typescript
|
|
119
|
+
// ❌ FORBIDDEN
|
|
120
|
+
effect(({ read }) => {
|
|
121
|
+
try {
|
|
122
|
+
riskyOp(read(asyncAtom$));
|
|
123
|
+
} catch (e) {
|
|
124
|
+
console.error(e);
|
|
125
|
+
} // Catches Promise!
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// ✅ REQUIRED
|
|
129
|
+
effect(({ read, safe }) => {
|
|
130
|
+
const [err, data] = safe(() => riskyOp(read(asyncAtom$)));
|
|
131
|
+
if (err) console.error("Failed:", err);
|
|
132
|
+
});
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### MUST Define meta.key
|
|
136
|
+
|
|
137
|
+
```typescript
|
|
138
|
+
// ✅ REQUIRED
|
|
139
|
+
effect(({ read }) => localStorage.setItem("count", String(read(count$))), {
|
|
140
|
+
meta: { key: "persist.count" },
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// ❌ FORBIDDEN
|
|
144
|
+
effect(({ read }) => localStorage.setItem("count", String(read(count$))));
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Single Workflow
|
|
148
|
+
|
|
149
|
+
```typescript
|
|
150
|
+
// ❌ FORBIDDEN — multiple workflows
|
|
151
|
+
effect(({ read }) => {
|
|
152
|
+
localStorage.setItem("count", String(read(count$)));
|
|
153
|
+
syncToServer(read(count$));
|
|
154
|
+
trackEvent("count_changed", read(count$));
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// ✅ REQUIRED — separate effects
|
|
158
|
+
effect(({ read }) => localStorage.setItem("count", String(read(count$))), {
|
|
159
|
+
meta: { key: "persist.count" },
|
|
160
|
+
});
|
|
161
|
+
effect(({ read }) => syncToServer(read(count$)), {
|
|
162
|
+
meta: { key: "sync.count" },
|
|
163
|
+
});
|
|
164
|
+
effect(({ read }) => trackEvent("count_changed", read(count$)), {
|
|
165
|
+
meta: { key: "analytics.count" },
|
|
166
|
+
});
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Cleanup Patterns
|
|
170
|
+
|
|
171
|
+
### Basic
|
|
172
|
+
|
|
173
|
+
```typescript
|
|
174
|
+
effect(({ read, onCleanup }) => {
|
|
175
|
+
const id = setInterval(() => console.log(read(count$)), 1000);
|
|
176
|
+
onCleanup(() => clearInterval(id));
|
|
177
|
+
});
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### Multiple (FIFO)
|
|
181
|
+
|
|
182
|
+
```typescript
|
|
183
|
+
effect(({ read, onCleanup }) => {
|
|
184
|
+
const sub1 = eventBus.subscribe("a", handler1);
|
|
185
|
+
const sub2 = eventBus.subscribe("b", handler2);
|
|
186
|
+
onCleanup(() => sub1.unsubscribe()); // First
|
|
187
|
+
onCleanup(() => sub2.unsubscribe()); // Second
|
|
188
|
+
});
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### AbortSignal
|
|
192
|
+
|
|
193
|
+
```typescript
|
|
194
|
+
effect(({ read, signal }) => {
|
|
195
|
+
const userId = read(userId$);
|
|
196
|
+
fetch(`/api/user/${userId}`, { signal })
|
|
197
|
+
.then((r) => r.json())
|
|
198
|
+
.then((data) => userDetails$.set(data))
|
|
199
|
+
.catch((err) => {
|
|
200
|
+
if (err.name !== "AbortError") console.error(err);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### WebSocket
|
|
206
|
+
|
|
207
|
+
```typescript
|
|
208
|
+
effect(
|
|
209
|
+
({ read, onCleanup }) => {
|
|
210
|
+
const socket = new WebSocket(read(wsUrl$));
|
|
211
|
+
socket.onmessage = (e) => messages$.set((p) => [...p, e.data]);
|
|
212
|
+
onCleanup(() => socket.close());
|
|
213
|
+
},
|
|
214
|
+
{ meta: { key: "ws.connection" } }
|
|
215
|
+
);
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
## Common Patterns
|
|
219
|
+
|
|
220
|
+
### LocalStorage
|
|
221
|
+
|
|
222
|
+
```typescript
|
|
223
|
+
effect(
|
|
224
|
+
({ read }) => {
|
|
225
|
+
localStorage.setItem("settings", JSON.stringify(read(settings$)));
|
|
226
|
+
},
|
|
227
|
+
{ meta: { key: "persist.settings" } }
|
|
228
|
+
);
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
### Analytics
|
|
232
|
+
|
|
233
|
+
```typescript
|
|
234
|
+
effect(
|
|
235
|
+
({ read }) => {
|
|
236
|
+
analytics.track("page_view", { page: read(currentPage$) });
|
|
237
|
+
},
|
|
238
|
+
{ meta: { key: "analytics.pageView" } }
|
|
239
|
+
);
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
### Debug (Dev Only)
|
|
243
|
+
|
|
244
|
+
```typescript
|
|
245
|
+
if (process.env.NODE_ENV === "development") {
|
|
246
|
+
effect(
|
|
247
|
+
({ read }) => {
|
|
248
|
+
console.log("[DEBUG]", { user: read(user$), cart: read(cart$) });
|
|
249
|
+
},
|
|
250
|
+
{ meta: { key: "debug.state" } }
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
### Conditional
|
|
256
|
+
|
|
257
|
+
```typescript
|
|
258
|
+
effect(
|
|
259
|
+
({ read }) => {
|
|
260
|
+
if (!read(featureFlag$)) return;
|
|
261
|
+
syncToExternalService(read(data$));
|
|
262
|
+
},
|
|
263
|
+
{ meta: { key: "sync.external" } }
|
|
264
|
+
);
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
### Debounced
|
|
268
|
+
|
|
269
|
+
```typescript
|
|
270
|
+
effect(
|
|
271
|
+
({ read, onCleanup }) => {
|
|
272
|
+
const data = read(formData$);
|
|
273
|
+
const id = setTimeout(
|
|
274
|
+
() => localStorage.setItem("draft", JSON.stringify(data)),
|
|
275
|
+
500
|
|
276
|
+
);
|
|
277
|
+
onCleanup(() => clearTimeout(id));
|
|
278
|
+
},
|
|
279
|
+
{ meta: { key: "persist.formDraft" } }
|
|
280
|
+
);
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
### Cross-Tab Sync
|
|
284
|
+
|
|
285
|
+
```typescript
|
|
286
|
+
effect(
|
|
287
|
+
({ read, onCleanup }) => {
|
|
288
|
+
const settings = read(settings$);
|
|
289
|
+
const channel = new BroadcastChannel("settings");
|
|
290
|
+
channel.postMessage(settings);
|
|
291
|
+
|
|
292
|
+
const handler = (e: MessageEvent) => settings$.set(e.data);
|
|
293
|
+
channel.addEventListener("message", handler);
|
|
294
|
+
onCleanup(() => {
|
|
295
|
+
channel.removeEventListener("message", handler);
|
|
296
|
+
channel.close();
|
|
297
|
+
});
|
|
298
|
+
},
|
|
299
|
+
{ meta: { key: "sync.crossTab" } }
|
|
300
|
+
);
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
### Document Title
|
|
304
|
+
|
|
305
|
+
```typescript
|
|
306
|
+
effect(
|
|
307
|
+
({ read }) => {
|
|
308
|
+
const count = read(unreadCount$);
|
|
309
|
+
document.title = count > 0 ? `(${count}) My App` : "My App";
|
|
310
|
+
},
|
|
311
|
+
{ meta: { key: "ui.documentTitle" } }
|
|
312
|
+
);
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
### Non-Reactive Config (untrack)
|
|
316
|
+
|
|
317
|
+
Read config/settings without triggering re-run when they change:
|
|
318
|
+
|
|
319
|
+
```typescript
|
|
320
|
+
// Effect only re-runs when userId$ changes, NOT when apiConfig$ changes
|
|
321
|
+
effect(
|
|
322
|
+
({ read, untrack }) => {
|
|
323
|
+
const userId = read(userId$); // Tracked — triggers re-run
|
|
324
|
+
const config = untrack(apiConfig$); // NOT tracked — no re-run
|
|
325
|
+
fetch(`${config.baseUrl}/users/${userId}`);
|
|
326
|
+
},
|
|
327
|
+
{ meta: { key: "fetch.user" } }
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
// Snapshot multiple atoms for logging without tracking all of them
|
|
331
|
+
effect(
|
|
332
|
+
({ read, untrack }) => {
|
|
333
|
+
const currentPage = read(currentPage$); // Only track page changes
|
|
334
|
+
const snapshot = untrack(() => ({
|
|
335
|
+
user: read(user$),
|
|
336
|
+
settings: read(settings$),
|
|
337
|
+
cart: read(cart$),
|
|
338
|
+
}));
|
|
339
|
+
analytics.track("page_view", { page: currentPage, ...snapshot });
|
|
340
|
+
},
|
|
341
|
+
{ meta: { key: "analytics.pageView" } }
|
|
342
|
+
);
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
| Use Case | Method |
|
|
346
|
+
| -------- | ------ |
|
|
347
|
+
| Value that triggers effect | `read()` |
|
|
348
|
+
| Config/reference values | `untrack()` |
|
|
349
|
+
| Snapshot for logging | `untrack(() => ...)` |
|
|
350
|
+
|
|
351
|
+
## Options
|
|
352
|
+
|
|
353
|
+
```typescript
|
|
354
|
+
interface EffectOptions {
|
|
355
|
+
meta?: { key?: string };
|
|
356
|
+
onError?: (error: unknown) => void;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
effect(({ read }) => riskyOp(read(data$)), {
|
|
360
|
+
meta: { key: "risky.op" },
|
|
361
|
+
onError: (err) => Sentry.captureException(err),
|
|
362
|
+
});
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
## Effect vs Derived
|
|
366
|
+
|
|
367
|
+
| Aspect | effect() | derived() |
|
|
368
|
+
| ------------ | ------------- | -------------- |
|
|
369
|
+
| Returns | void | Computed value |
|
|
370
|
+
| Execution | Eager | Lazy |
|
|
371
|
+
| Purpose | Side effects | Transform data |
|
|
372
|
+
| **Can set** | **✅ Yes** | **❌ NEVER** |
|
|
373
|
+
| Subscription | Always active | When accessed |
|
|
374
|
+
|
|
375
|
+
## Lifecycle
|
|
376
|
+
|
|
377
|
+
```
|
|
378
|
+
Created → Initial run → Dep changed → Cleanup → Re-run → ... → dispose() → Final cleanup → Stopped
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
## Testing
|
|
382
|
+
|
|
383
|
+
```typescript
|
|
384
|
+
it("should persist to localStorage", () => {
|
|
385
|
+
const count$ = atom(0, { meta: { key: "test.count" } });
|
|
386
|
+
const e = effect(
|
|
387
|
+
({ read }) => localStorage.setItem("count", String(read(count$))),
|
|
388
|
+
{
|
|
389
|
+
meta: { key: "test.persist" },
|
|
390
|
+
}
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
expect(localStorage.getItem("count")).toBe("0");
|
|
394
|
+
count$.set(5);
|
|
395
|
+
expect(localStorage.getItem("count")).toBe("5");
|
|
396
|
+
e.dispose();
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("should cleanup on dispose", () => {
|
|
400
|
+
const cleanup = vi.fn();
|
|
401
|
+
const count$ = atom(0);
|
|
402
|
+
const e = effect(({ read, onCleanup }) => {
|
|
403
|
+
read(count$);
|
|
404
|
+
onCleanup(cleanup);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
expect(cleanup).not.toHaveBeenCalled();
|
|
408
|
+
e.dispose();
|
|
409
|
+
expect(cleanup).toHaveBeenCalledOnce();
|
|
410
|
+
});
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
## When to Use
|
|
414
|
+
|
|
415
|
+
✅ **Use effect:**
|
|
416
|
+
|
|
417
|
+
- Sync to storage, analytics, logging
|
|
418
|
+
- Manage subscriptions (WS, events)
|
|
419
|
+
- Update DOM properties
|
|
420
|
+
- Mutate atoms based on computed
|
|
421
|
+
|
|
422
|
+
❌ **NEVER use effect:**
|
|
423
|
+
|
|
424
|
+
- User triggers action → plain function
|
|
425
|
+
- Computing derived values → `derived()`
|
|
426
|
+
- Need return value → `derived()`
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# Error Handling: safe() Not try/catch
|
|
2
|
+
|
|
3
|
+
## The Problem
|
|
4
|
+
|
|
5
|
+
`read()` uses **Suspense**: loading atoms throw Promise. try/catch catches the Promise:
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
// ❌ WRONG — breaks Suspense
|
|
9
|
+
const data$ = derived(({ read }) => {
|
|
10
|
+
try {
|
|
11
|
+
const user = read(asyncUser$); // Throws Promise when loading
|
|
12
|
+
return processUser(user);
|
|
13
|
+
} catch (e) {
|
|
14
|
+
// Catches BOTH:
|
|
15
|
+
// 1. Promise (loading) — breaks Suspense
|
|
16
|
+
// 2. Actual errors
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
**Problems:**
|
|
23
|
+
- Loading state lost
|
|
24
|
+
- No Suspense fallback
|
|
25
|
+
- Can't distinguish loading from error
|
|
26
|
+
|
|
27
|
+
## The Solution: safe()
|
|
28
|
+
|
|
29
|
+
`safe()` catches errors, **re-throws Promises**:
|
|
30
|
+
|
|
31
|
+
```typescript
|
|
32
|
+
// ✅ CORRECT
|
|
33
|
+
const data$ = derived(({ read, safe }) => {
|
|
34
|
+
const [err, user] = safe(() => {
|
|
35
|
+
const raw = read(asyncUser$); // Can throw Promise ✓
|
|
36
|
+
return processUser(raw); // Can throw Error ✓
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
if (err) return { error: err.message };
|
|
40
|
+
return { user };
|
|
41
|
+
});
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## How safe() Works
|
|
45
|
+
|
|
46
|
+
| Scenario | `try/catch` | `safe()` |
|
|
47
|
+
| ---------- | ------------------ | --------------------------- |
|
|
48
|
+
| Loading | ❌ Catches Promise | ✅ Re-throws → Suspense |
|
|
49
|
+
| Error | ✅ Catches | ✅ Returns `[error, undef]` |
|
|
50
|
+
| Success | ✅ Returns | ✅ Returns `[undef, value]` |
|
|
51
|
+
|
|
52
|
+
## Use Cases
|
|
53
|
+
|
|
54
|
+
### Parsing/Validation
|
|
55
|
+
|
|
56
|
+
```typescript
|
|
57
|
+
const parsed$ = derived(({ read, safe }) => {
|
|
58
|
+
const [err, config] = safe(() => {
|
|
59
|
+
const raw = read(rawConfig$);
|
|
60
|
+
return JSON.parse(raw);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
if (err) return { valid: false, error: "Invalid JSON" };
|
|
64
|
+
return { valid: true, config };
|
|
65
|
+
});
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Graceful Degradation
|
|
69
|
+
|
|
70
|
+
```typescript
|
|
71
|
+
const dashboard$ = derived(({ read, safe }) => {
|
|
72
|
+
const user = read(user$); // Required
|
|
73
|
+
|
|
74
|
+
const [err1, analytics] = safe(() => read(analytics$));
|
|
75
|
+
const [err2, notifications] = safe(() => read(notifications$));
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
user,
|
|
79
|
+
analytics: err1 ? null : analytics,
|
|
80
|
+
notifications: err2 ? [] : notifications,
|
|
81
|
+
errors: [err1, err2].filter(Boolean),
|
|
82
|
+
};
|
|
83
|
+
});
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Effects
|
|
87
|
+
|
|
88
|
+
```typescript
|
|
89
|
+
effect(({ read, safe }) => {
|
|
90
|
+
const [err, data] = safe(() => {
|
|
91
|
+
const raw = read(asyncData$);
|
|
92
|
+
return transformData(raw);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
if (err) {
|
|
96
|
+
console.error("Failed:", err);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
saveToLocalStorage(data);
|
|
100
|
+
});
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### React Components
|
|
104
|
+
|
|
105
|
+
```tsx
|
|
106
|
+
function UserProfile() {
|
|
107
|
+
const result = useSelector(({ read, safe }) => {
|
|
108
|
+
const [err, user] = safe(() => read(user$));
|
|
109
|
+
return { err, user };
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
if (result.err) return <ErrorMessage error={result.err} />;
|
|
113
|
+
return <Profile user={result.user} />;
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### With rx()
|
|
118
|
+
|
|
119
|
+
```tsx
|
|
120
|
+
<Suspense fallback={<Loading />}>
|
|
121
|
+
{rx(({ read, safe }) => {
|
|
122
|
+
const [err, posts] = safe(() => read(posts$));
|
|
123
|
+
if (err) return <ErrorBanner message="Failed to load" />;
|
|
124
|
+
return posts.map((p) => <PostCard key={p.id} post={p} />);
|
|
125
|
+
})}
|
|
126
|
+
</Suspense>
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Alternative: state()
|
|
130
|
+
|
|
131
|
+
For manual loading handling (no Suspense):
|
|
132
|
+
|
|
133
|
+
```typescript
|
|
134
|
+
const result = useSelector(({ state }) => state(user$));
|
|
135
|
+
// { status: "loading" | "ready" | "error", value?, error? }
|
|
136
|
+
|
|
137
|
+
if (result.status === "loading") return <Loading />;
|
|
138
|
+
if (result.status === "error") return <Error error={result.error} />;
|
|
139
|
+
return <User data={result.value} />;
|
|
140
|
+
```
|