nanoeffects 0.4.0 → 0.5.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 +4 -1
- package/dist/{action-creator-D48fp13T.d.ts → action-creator-DOWcbXpy.d.ts} +16 -2
- package/dist/actions.d.ts +38 -3
- package/dist/actions.js +1 -1
- package/dist/{bus-Cho463Sq.js → bus-BAs9x7IQ.js} +1 -1
- package/dist/{context-DlA80GOA.js → context-CK0ZomWt.js} +1 -1
- package/dist/context-plugin-B0sLWOxR.d.ts +34 -0
- package/dist/dev.d.ts +2 -2
- package/dist/effects.d.ts +153 -56
- package/dist/effects.js +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1 -1
- package/dist/{module-DVX8aYlq.d.ts → module-DCJl-qfi.d.ts} +16 -2
- package/dist/notrace/{action-creator-D48fp13T.d.ts → action-creator-DOWcbXpy.d.ts} +16 -2
- package/dist/notrace/actions.d.ts +52 -3
- package/dist/notrace/actions.js +1 -1
- package/dist/notrace/{bus-B12SvxLv.js → bus-Bdk0YyFQ.js} +1 -1
- package/dist/notrace/{context-yAWj0BFZ.js → context-CctVmHLC.js} +1 -1
- package/dist/notrace/context-plugin-B0sLWOxR.d.ts +34 -0
- package/dist/notrace/{delay-Cgvn16oY.js → delay-B_W1tnkS.js} +1 -1
- package/dist/notrace/effects.d.ts +153 -56
- package/dist/notrace/effects.js +1 -1
- package/dist/notrace/index.d.ts +3 -2
- package/dist/notrace/index.js +1 -1
- package/dist/notrace/policies.d.ts +1 -1
- package/dist/notrace/policies.js +1 -1
- package/dist/notrace/redux.d.ts +18 -20
- package/dist/notrace/redux.js +1 -1
- package/dist/observable.d.ts +1 -1
- package/dist/policies.d.ts +1 -1
- package/dist/policies.js +1 -1
- package/dist/react.d.ts +50 -0
- package/dist/react.js +1 -0
- package/dist/redux.d.ts +18 -20
- package/dist/redux.js +1 -1
- package/dist/trace.d.ts +1 -1
- package/docs/README.md +7 -1
- package/docs/actions.md +1 -1
- package/docs/context.md +24 -2
- package/docs/effects.md +82 -5
- package/docs/guide.md +2 -0
- package/docs/migrating-from-effector.md +293 -0
- package/docs/migrating-from-rxjs.md +127 -0
- package/docs/migrating-from-saga.md +93 -0
- package/docs/observable.md +1 -0
- package/docs/package.md +12 -2
- package/docs/policies.md +1 -0
- package/docs/react.md +157 -0
- package/docs/stores.md +429 -40
- package/docs/typescript.md +153 -0
- package/llms-full.txt +2645 -0
- package/llms.txt +48 -0
- package/package.json +35 -6
package/llms-full.txt
ADDED
|
@@ -0,0 +1,2645 @@
|
|
|
1
|
+
# nanoeffects
|
|
2
|
+
|
|
3
|
+
> A small typed event bus for JavaScript and TypeScript apps. Each handler runs as a task with an `AbortSignal`, so in-flight work can be cancelled. Policies (`latest`, `debounce`, ...) and effects (`retry`, `timeout`, ...) are plain functions.
|
|
4
|
+
|
|
5
|
+
Rules that generated code often gets wrong:
|
|
6
|
+
|
|
7
|
+
- The package is ESM only. There is no default export and no `require`.
|
|
8
|
+
- `const unbind = bus.on("event", handler)` subscribes and returns the unsubscribe function. There is no `subscribe` or `addListener`.
|
|
9
|
+
- A handler gets the payload and a task context: `(payload, ctx)`. The context has `signal`, `emit`, `on`, `run`. Pass `ctx.signal` to `fetch`.
|
|
10
|
+
- Effects take `ctx` as the first argument: `retry(ctx, fn, options)`, `timeout(ctx, ms, fn)`, `take(ctx, "event")`.
|
|
11
|
+
- Policies wrap a handler: `bus.on("search", latest(async (query, ctx) => { ... }))`.
|
|
12
|
+
- Each part is imported from its own path: `nanoeffects` (`createBus`), `nanoeffects/policies`, `nanoeffects/effects`, `nanoeffects/actions`, `nanoeffects/redux`, `nanoeffects/observable`, `nanoeffects/react` (`reactPlugin`, adds `bus.useEmitter`; needs React), `nanoeffects/trace`, `nanoeffects/dev`.
|
|
13
|
+
- `bus.emit(command(payload))` runs the command's handler and returns its result. Plain events return nothing.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
Source: docs/guide.md
|
|
17
|
+
|
|
18
|
+
# Guide
|
|
19
|
+
|
|
20
|
+
Climb one step at a time: small bus → policies → effects → commands and modules. Stop when your project has what it needs.
|
|
21
|
+
|
|
22
|
+
| Your project | Stop after |
|
|
23
|
+
| ------------ | ---------- |
|
|
24
|
+
| Widget / prototype | §1–2 |
|
|
25
|
+
| SPA with async UI | §3–4 |
|
|
26
|
+
| Unreliable network / long flows | §5–6 |
|
|
27
|
+
| Domain logic off the UI | §7–9 |
|
|
28
|
+
|
|
29
|
+
Runnable code for every step: [`examples/guide.ts`](../examples/guide.ts).
|
|
30
|
+
|
|
31
|
+
## 1. Create a bus
|
|
32
|
+
|
|
33
|
+
`on` subscribes. `emit` calls the subscribers. `on` returns the unsubscribe function.
|
|
34
|
+
|
|
35
|
+
```js
|
|
36
|
+
import { createBus } from "nanoeffects";
|
|
37
|
+
|
|
38
|
+
const bus = createBus(); // event bus; each handler call is a task
|
|
39
|
+
|
|
40
|
+
const unbind = bus.on("greet", (name) => {
|
|
41
|
+
console.log(`hello ${name}`);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
bus.emit("greet", "Ada"); // hello Ada
|
|
45
|
+
unbind();
|
|
46
|
+
bus.emit("greet", "Bob"); // nobody listens
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Handlers run at once, in subscription order.
|
|
50
|
+
|
|
51
|
+
`bus.once` is like `on`, but the handler runs at most once (it is removed before it is invoked). `bus.off("greet")` clears every listener of that event; `bus.off()` with no arguments clears **every** listener on the bus (including `"*"`). `ctx.off` is the same `off` as the bus’s — so `ctx.off()` also clears the whole bus.
|
|
52
|
+
|
|
53
|
+
```js
|
|
54
|
+
import { createBus } from "nanoeffects";
|
|
55
|
+
|
|
56
|
+
const bus = createBus();
|
|
57
|
+
|
|
58
|
+
// once — runs at most once, then unsubscribes
|
|
59
|
+
bus.once("ready", () => console.log("ready"));
|
|
60
|
+
bus.emit("ready"); // ready
|
|
61
|
+
bus.emit("ready"); // nobody listens
|
|
62
|
+
|
|
63
|
+
bus.on("tick", () => {});
|
|
64
|
+
bus.off(); // clear every subscription
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## 2. Use the handler context
|
|
68
|
+
|
|
69
|
+
The second argument is the **task** context for this call. Emit further events through it. Pass `ctx.signal` to `fetch` when you cancel work.
|
|
70
|
+
|
|
71
|
+
```js
|
|
72
|
+
import { createBus } from "nanoeffects";
|
|
73
|
+
|
|
74
|
+
const bus = createBus();
|
|
75
|
+
|
|
76
|
+
bus.on("moved", ({ x, y }, ctx) => {
|
|
77
|
+
if (x < 0) {
|
|
78
|
+
ctx.emit("outOfBounds", { x, y });
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`ctx` has `signal`, `abort`, `emit`, `on`, and `run`. An aborted task stops emitting and delivers no result.
|
|
84
|
+
|
|
85
|
+
## 3. Abort the previous run with `latest`
|
|
86
|
+
|
|
87
|
+
A handler is often called again before the last call finished (typing, double-clicks). A **policy** wraps the handler and decides what happens then. Start with `latest`: a newer call aborts the older one through `signal`.
|
|
88
|
+
|
|
89
|
+
```js
|
|
90
|
+
import { createBus } from "nanoeffects";
|
|
91
|
+
import { latest } from "nanoeffects/policies";
|
|
92
|
+
|
|
93
|
+
const bus = createBus();
|
|
94
|
+
|
|
95
|
+
// latest — a newer run aborts the older one
|
|
96
|
+
bus.on(
|
|
97
|
+
"search",
|
|
98
|
+
latest(async (query, { signal }) => {
|
|
99
|
+
await api.search(query, { signal });
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Same `on`, same handler shape. More in [Policies](policies.md).
|
|
105
|
+
|
|
106
|
+
## 4. Pick another policy when overlap differs
|
|
107
|
+
|
|
108
|
+
| Policy | When calls overlap |
|
|
109
|
+
| ------ | ------------------ |
|
|
110
|
+
| `latest` | previous call is aborted |
|
|
111
|
+
| `leading` | new calls drop while one runs |
|
|
112
|
+
| `debounce` | runs after a pause |
|
|
113
|
+
| `throttle` | at most once per window |
|
|
114
|
+
| `queue` | one after another |
|
|
115
|
+
|
|
116
|
+
```js
|
|
117
|
+
import { createBus } from "nanoeffects";
|
|
118
|
+
import { debounce, latest, queue, throttle } from "nanoeffects/policies";
|
|
119
|
+
|
|
120
|
+
const bus = createBus();
|
|
121
|
+
|
|
122
|
+
// throttle — at most once per 100 ms
|
|
123
|
+
bus.on("scroll", throttle(100, (position) => {
|
|
124
|
+
updateScrollbar(position);
|
|
125
|
+
}));
|
|
126
|
+
|
|
127
|
+
// debounce — run after 300 ms of quiet
|
|
128
|
+
bus.on("typed", debounce(300, (text) => {
|
|
129
|
+
validate(text);
|
|
130
|
+
}));
|
|
131
|
+
|
|
132
|
+
// queue — one save after another, in order
|
|
133
|
+
bus.on(
|
|
134
|
+
"save",
|
|
135
|
+
queue(async (draft, { signal }) => {
|
|
136
|
+
await api.save(draft, { signal });
|
|
137
|
+
}),
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
// nest: wait for quiet, then keep only the latest search
|
|
141
|
+
const searchLatest = latest(async (query, { signal }) => {
|
|
142
|
+
await api.search(query, { signal });
|
|
143
|
+
});
|
|
144
|
+
bus.on("search", debounce(300, searchLatest));
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Full options: [Policies](policies.md).
|
|
148
|
+
|
|
149
|
+
## 5. Add retry and timeout
|
|
150
|
+
|
|
151
|
+
An **effect** adds what an async handler usually needs. `retry` and `timeout` wrap a handler like a policy, so they go into `on` with no `ctx` in their arguments.
|
|
152
|
+
|
|
153
|
+
```js
|
|
154
|
+
import { createBus } from "nanoeffects";
|
|
155
|
+
import { retry, timeout } from "nanoeffects/effects";
|
|
156
|
+
import { latest } from "nanoeffects/policies";
|
|
157
|
+
|
|
158
|
+
const bus = createBus();
|
|
159
|
+
|
|
160
|
+
const search = (query, { signal }) => api.search(query, { signal });
|
|
161
|
+
|
|
162
|
+
// timeout — fail one try after 5 s
|
|
163
|
+
const searchOnce = timeout(5000, search);
|
|
164
|
+
// retry — up to 3 tries
|
|
165
|
+
const searchWithRetry = retry(searchOnce, { times: 3 });
|
|
166
|
+
|
|
167
|
+
// latest — a newer typed query aborts the whole retry chain
|
|
168
|
+
bus.on("typed", latest(searchWithRetry));
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
When the handler itself needs the result, `ctx.run` runs the wrapped function as a child task (aborted with the parent):
|
|
172
|
+
|
|
173
|
+
```js
|
|
174
|
+
import { createBus } from "nanoeffects";
|
|
175
|
+
import { retry, timeout } from "nanoeffects/effects";
|
|
176
|
+
import { latest } from "nanoeffects/policies";
|
|
177
|
+
|
|
178
|
+
const bus = createBus();
|
|
179
|
+
const search = (query, { signal }) => api.search(query, { signal });
|
|
180
|
+
|
|
181
|
+
bus.on(
|
|
182
|
+
"search",
|
|
183
|
+
latest(async (query, ctx) => {
|
|
184
|
+
const load = retry(timeout(5000, search), { times: 3 });
|
|
185
|
+
const items = await ctx.run(load, query); // child task; aborted with this one
|
|
186
|
+
ctx.emit("results", items);
|
|
187
|
+
}),
|
|
188
|
+
);
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
For one call in the middle of a body: `await retry(ctx, () => api.search(q, { signal: ctx.signal }))`. More in [Effects](effects.md).
|
|
192
|
+
|
|
193
|
+
## 6. Run work in parallel or wait for an event
|
|
194
|
+
|
|
195
|
+
```js
|
|
196
|
+
import { createBus } from "nanoeffects";
|
|
197
|
+
import { all, take } from "nanoeffects/effects";
|
|
198
|
+
|
|
199
|
+
const bus = createBus();
|
|
200
|
+
|
|
201
|
+
// all — run together; one failure cancels the rest
|
|
202
|
+
bus.on("search", async (id, ctx) => {
|
|
203
|
+
const { user, posts } = await all(ctx, {
|
|
204
|
+
user: ({ signal }) => api.user(id, { signal }),
|
|
205
|
+
posts: ({ signal }) => api.posts(id, { signal }),
|
|
206
|
+
});
|
|
207
|
+
ctx.emit("loaded", { user, posts });
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// take — wait for the next event of that name
|
|
211
|
+
bus.on("typed", async (query, ctx) => {
|
|
212
|
+
const position = await take(ctx, "scroll");
|
|
213
|
+
ctx.emit("scrolled", { query, position });
|
|
214
|
+
});
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
More in [Effects](effects.md).
|
|
218
|
+
|
|
219
|
+
## 7. Name events three ways
|
|
220
|
+
|
|
221
|
+
`emit` and `on` take a string, an object `{ type, payload }` (a Redux action fits), or a function with a `type` property (action creators and commands).
|
|
222
|
+
|
|
223
|
+
```js
|
|
224
|
+
import { createBus } from "nanoeffects";
|
|
225
|
+
import { createAction, createCommand } from "nanoeffects/actions";
|
|
226
|
+
|
|
227
|
+
const bus = createBus();
|
|
228
|
+
|
|
229
|
+
bus.emit("moved", { x: 1, y: 2 });
|
|
230
|
+
bus.emit({ type: "moved", payload: { x: 2, y: 2 } });
|
|
231
|
+
|
|
232
|
+
// createAction — names an event; calling it builds { type, payload }
|
|
233
|
+
const moved = createAction("moved");
|
|
234
|
+
bus.emit(moved({ x: 3, y: 2 }));
|
|
235
|
+
bus.on(moved, ({ x }) => console.log(x));
|
|
236
|
+
|
|
237
|
+
// createCommand — like an action, but emit runs a handler and returns its result
|
|
238
|
+
const double = createCommand("math/double", (value) => value * 2);
|
|
239
|
+
bus.emit(double(21)); // 42
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Use strings for the plain case. Use objects when other code already builds actions. Use commands when you need a result from `emit`.
|
|
243
|
+
|
|
244
|
+
## 8. Register a command handler
|
|
245
|
+
|
|
246
|
+
A command may carry its handler, or only be a **contract** (name + types) with the work registered elsewhere — another file, or a fake in a test.
|
|
247
|
+
|
|
248
|
+
```js
|
|
249
|
+
import { createBus } from "nanoeffects";
|
|
250
|
+
import { createCommand, commandsPlugin } from "nanoeffects/actions";
|
|
251
|
+
|
|
252
|
+
const getUser = createCommand("user/get"); // contract — no handler yet
|
|
253
|
+
|
|
254
|
+
const bus = createBus();
|
|
255
|
+
// commandsPlugin — registry of handlers for this bus
|
|
256
|
+
const commands = bus.use(commandsPlugin());
|
|
257
|
+
|
|
258
|
+
bus.emit(getUser("1")); // throws: No handler for command "user/get"
|
|
259
|
+
|
|
260
|
+
const unregister = commands.handle(getUser, (id) => api.user(id));
|
|
261
|
+
const user = await bus.emit(getUser("1"));
|
|
262
|
+
|
|
263
|
+
unregister();
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
Each bus has its own registry. A [module](#9-mount-a-domain-as-a-module) can register handlers and remove them on unmount. More in [Actions](actions.md).
|
|
267
|
+
|
|
268
|
+
## 9. Mount a domain as a module
|
|
269
|
+
|
|
270
|
+
**Problem.** A feature owns several things on the bus: who answers `getUser`, who listens to `greet`, maybe a command that already carries its handler. With plain `bus.on` / `commands.handle` you must remember every unsubscribe when the feature goes away. Miss one and listeners pile up.
|
|
271
|
+
|
|
272
|
+
**When.** Domain logic lives on the bus, off the UI, and you need one mount / one unmount for that feature. Skip modules while a few top-level `bus.on` calls are enough.
|
|
273
|
+
|
|
274
|
+
**What you do.** `createModule(name, setup)` describes the feature. `bus.use(modulePlugin([…]))` mounts it. `app.unmount(module)` undoes everything the setup registered.
|
|
275
|
+
|
|
276
|
+
```js
|
|
277
|
+
import { createBus } from "nanoeffects";
|
|
278
|
+
import { createCommand, createModule, modulePlugin } from "nanoeffects/actions";
|
|
279
|
+
|
|
280
|
+
const getUser = createCommand("user/get"); // contract — no handler yet
|
|
281
|
+
const double = createCommand("math/double", (value) => value * 2); // carries its handler
|
|
282
|
+
|
|
283
|
+
const bus = createBus();
|
|
284
|
+
|
|
285
|
+
// createModule — one feature; name is stable for hot reload replace
|
|
286
|
+
const greeting = createModule("greeting", (m) => {
|
|
287
|
+
// m.on — like bus.on, removed automatically on unmount
|
|
288
|
+
m.on("greet", (name) => console.log(`hello ${name}`));
|
|
289
|
+
|
|
290
|
+
// m.handle — this module answers getUser while mounted
|
|
291
|
+
m.handle(getUser, (id) => api.user(id));
|
|
292
|
+
|
|
293
|
+
// m.use — register a command that already has a handler, for this module’s life
|
|
294
|
+
m.use(double);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// modulePlugin — mount modules on the bus
|
|
298
|
+
const app = bus.use(modulePlugin([greeting]));
|
|
299
|
+
bus.emit("greet", "Ada"); // hello Ada
|
|
300
|
+
app.unmount(greeting); // greet listener and getUser binding are gone
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
- **`m.on`** — same job as `bus.on`, scoped to the module’s lifetime.
|
|
304
|
+
- **`m.handle`** — bind a handler to a contract command; gone on unmount.
|
|
305
|
+
- **`m.use`** — bring a command that already carries a handler (or a sub-module). Prefer `handle` when the handler must die with the feature.
|
|
306
|
+
|
|
307
|
+
Modules also make [hot reload](hmr.md) work. Full API in [Actions](actions.md); a longer recipe in [`examples/modules.ts`](../examples/modules.ts).
|
|
308
|
+
|
|
309
|
+
## 10. Add types when you want checking (optional)
|
|
310
|
+
|
|
311
|
+
Describe events once when you want `on` / `emit` checked. An event without data is `void`. Skip this while you are learning the bus.
|
|
312
|
+
|
|
313
|
+
```ts
|
|
314
|
+
import { createBus } from "nanoeffects";
|
|
315
|
+
|
|
316
|
+
// Events — event name → payload for this bus (checker only)
|
|
317
|
+
type Events = {
|
|
318
|
+
greet: string;
|
|
319
|
+
moved: { x: number; y: number };
|
|
320
|
+
reset: void;
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const bus = createBus<Events>();
|
|
324
|
+
|
|
325
|
+
bus.on("moved", ({ x, y }) => console.log(x + y));
|
|
326
|
+
bus.emit("moved", { x: 1, y: 2 });
|
|
327
|
+
bus.emit("reset");
|
|
328
|
+
// bus.emit("moved", { x: "1" }); // error
|
|
329
|
+
// bus.emit("jumped", 1); // error
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
Without a map, any string event is allowed and the payload is `unknown`.
|
|
333
|
+
|
|
334
|
+
## 11. Isolate errors and warn on typos
|
|
335
|
+
|
|
336
|
+
```js
|
|
337
|
+
import { createBus } from "nanoeffects";
|
|
338
|
+
|
|
339
|
+
const bus = createBus({
|
|
340
|
+
onError: (error) => report(error), // isolate failing handlers
|
|
341
|
+
onUnhandled: ({ type }) => console.warn(`nobody listens to ${type}`),
|
|
342
|
+
});
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
More in [Errors](errors.md).
|
|
346
|
+
|
|
347
|
+
## Where next
|
|
348
|
+
|
|
349
|
+
| Need | Page |
|
|
350
|
+
| ---- | ---- |
|
|
351
|
+
| Store or client on every task | [Context fields](context.md) |
|
|
352
|
+
| Redux / Zustand / MobX | [Stores](stores.md) |
|
|
353
|
+
| Observe tasks | [Tracing](tracing.md) |
|
|
354
|
+
| Guarantees | [Semantics](semantics.md) |
|
|
355
|
+
| Which import | [Package entries](package.md) |
|
|
356
|
+
|
|
357
|
+
---
|
|
358
|
+
Source: docs/package.md
|
|
359
|
+
|
|
360
|
+
# Choose a package entry
|
|
361
|
+
|
|
362
|
+
Imports are split so you only load what you use. `createBus` stays small; the rest is another entry.
|
|
363
|
+
|
|
364
|
+
| Import | What is in it |
|
|
365
|
+
| ------ | ------------- |
|
|
366
|
+
| `nanoeffects` | `createBus`, the types |
|
|
367
|
+
| `nanoeffects/policies` | `latest`, `leading`, `debounce`, `throttle`, `queue` |
|
|
368
|
+
| `nanoeffects/effects` | `retry`, `timeout`, `poll`, `delay`, `take`, `all`, `allSettled`, `race`, `effectsPlugin` |
|
|
369
|
+
| `nanoeffects/actions` | `createAction`, `createCommand`, `createJob`, `createModule`, `modulePlugin`, `commandsPlugin` |
|
|
370
|
+
| `nanoeffects/redux` | `createEffectsMiddleware` |
|
|
371
|
+
| `nanoeffects/observable` | `createObserve` |
|
|
372
|
+
| `nanoeffects/react` | `reactPlugin`: adds the `useEmitter` hook to a bus (needs React 18 or newer) |
|
|
373
|
+
| `nanoeffects/dev` | `devPlugin` (no-op in production) |
|
|
374
|
+
| `nanoeffects/trace` | `createTracer` and the loggers |
|
|
375
|
+
|
|
376
|
+
## Give the docs to an AI assistant
|
|
377
|
+
|
|
378
|
+
The package ships two plain-text files for assistants that write code with it:
|
|
379
|
+
|
|
380
|
+
- `llms.txt`: a short index. It states the rules that generated code often gets wrong and links to every page of these docs.
|
|
381
|
+
- `llms-full.txt`: the same summary followed by the text of every linked page, in one file.
|
|
382
|
+
|
|
383
|
+
Find them in `node_modules/nanoeffects/`, or load `llms-full.txt` from a CDN such as `https://unpkg.com/nanoeffects/llms-full.txt`. Both match the installed version.
|
|
384
|
+
|
|
385
|
+
---
|
|
386
|
+
Source: docs/policies.md
|
|
387
|
+
|
|
388
|
+
# Policies
|
|
389
|
+
|
|
390
|
+
Use this page when handlers overlap in a SPA (search-as-you-type, double submit, leave page). A **policy** wraps a handler and decides what happens then.
|
|
391
|
+
|
|
392
|
+
Start with `latest`. The table and sections below are the rest of the reference.
|
|
393
|
+
|
|
394
|
+
## Abort the previous run
|
|
395
|
+
|
|
396
|
+
```js
|
|
397
|
+
import { createBus } from "nanoeffects";
|
|
398
|
+
import { latest } from "nanoeffects/policies";
|
|
399
|
+
|
|
400
|
+
const bus = createBus();
|
|
401
|
+
|
|
402
|
+
// latest — a newer run aborts the older one
|
|
403
|
+
bus.on(
|
|
404
|
+
"search",
|
|
405
|
+
latest(async (query, { signal }) => {
|
|
406
|
+
// signal — lets latest cancel this request, not only drop its result
|
|
407
|
+
await api.search(query, { signal });
|
|
408
|
+
}),
|
|
409
|
+
);
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
Pass `signal` to `fetch` (and the like) so the request cancels.
|
|
413
|
+
|
|
414
|
+
## Choose a policy
|
|
415
|
+
|
|
416
|
+
Every policy accepts `key` in its options (see [Isolate state by key](#isolate-state-by-key)).
|
|
417
|
+
|
|
418
|
+
| Policy | Overlapping calls |
|
|
419
|
+
| ------ | ----------------- |
|
|
420
|
+
| `latest(fn)` | previous run aborted, result dropped |
|
|
421
|
+
| `leading(fn)` | new calls dropped while a run is going |
|
|
422
|
+
| `debounce(ms, fn, options?)` | run after `ms` of quiet |
|
|
423
|
+
| `throttle(ms, fn, options?)` | at most one run per `ms` |
|
|
424
|
+
| `queue(fn, options?)` | one after another, in order |
|
|
425
|
+
|
|
426
|
+
## Configure debounce and throttle
|
|
427
|
+
|
|
428
|
+
`debounce` and `throttle` take `leading` and `trailing`. Behavior matches lodash (and patronum); tests compare random call sequences to lodash itself.
|
|
429
|
+
|
|
430
|
+
**Throttle** (`throttle(ms, fn)`):
|
|
431
|
+
|
|
432
|
+
- Default `leading: true`, `trailing: true`.
|
|
433
|
+
- A call when the throttle is idle (at least `ms` after the previous call and run) runs at once.
|
|
434
|
+
- Calls made while it waits are held; the latest runs when the window closes.
|
|
435
|
+
- Under a steady stream it runs every `ms`.
|
|
436
|
+
- `leading: false` — even the first call waits for the end of its window.
|
|
437
|
+
- `trailing: false` — drop calls made during a window.
|
|
438
|
+
|
|
439
|
+
**Debounce** (`debounce(ms, fn)`):
|
|
440
|
+
|
|
441
|
+
- Default `trailing: true` — run the last call after `ms` of quiet.
|
|
442
|
+
- `leading: true` — also run the first call of a burst at once; the trailing run happens only if more calls follow.
|
|
443
|
+
- `leading: true, trailing: false` — only the first call of a burst.
|
|
444
|
+
- Both `false` — nothing runs.
|
|
445
|
+
|
|
446
|
+
## Isolate state by key
|
|
447
|
+
|
|
448
|
+
`key` gives each key its own state. Calls with different keys do not affect each other. Idle keys are dropped, so keys can be unbounded (ids, urls).
|
|
449
|
+
|
|
450
|
+
```js
|
|
451
|
+
import { createBus } from "nanoeffects";
|
|
452
|
+
import { latest, queue } from "nanoeffects/policies";
|
|
453
|
+
|
|
454
|
+
const bus = createBus();
|
|
455
|
+
|
|
456
|
+
// one latest run per user id
|
|
457
|
+
bus.on(
|
|
458
|
+
"load",
|
|
459
|
+
latest(loadUser, { key: (user) => user.id }),
|
|
460
|
+
);
|
|
461
|
+
|
|
462
|
+
// uploads of the same file go one after another
|
|
463
|
+
bus.on(
|
|
464
|
+
"upload",
|
|
465
|
+
queue(upload, { key: (file) => file.name }),
|
|
466
|
+
);
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
## Run calls one after another with `queue`
|
|
470
|
+
|
|
471
|
+
```js
|
|
472
|
+
import { createBus } from "nanoeffects";
|
|
473
|
+
import { queue } from "nanoeffects/policies";
|
|
474
|
+
|
|
475
|
+
const bus = createBus();
|
|
476
|
+
|
|
477
|
+
// queue — one after another, in order
|
|
478
|
+
bus.on(
|
|
479
|
+
"save",
|
|
480
|
+
queue(async (draft, { signal }) => {
|
|
481
|
+
await api.save(draft, { signal });
|
|
482
|
+
}),
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
bus.emit("save", draftA); // runs now
|
|
486
|
+
bus.emit("save", draftB); // waits for draftA, then runs
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
| Option | Meaning |
|
|
490
|
+
| ------ | ------- |
|
|
491
|
+
| `concurrency` | how many calls may run at once (default `1`) |
|
|
492
|
+
| `limit` | how many calls may wait |
|
|
493
|
+
| `overflow` | when the line is full: `"drop-new"` (default) or `"drop-old"` |
|
|
494
|
+
|
|
495
|
+
A dropped call resolves with `undefined`. `queue(fn, { limit: 1, overflow: "drop-old" })` keeps only the latest waiting call. `limit: 0` lets nothing wait.
|
|
496
|
+
|
|
497
|
+
Every call returns a promise with the result of its own run. An error rejects only that call; the queue continues.
|
|
498
|
+
|
|
499
|
+
**Abort:**
|
|
500
|
+
|
|
501
|
+
- A waiting call whose task aborts is skipped and resolves with `undefined`.
|
|
502
|
+
- When a running call aborts, its slot is freed at once and its result is dropped.
|
|
503
|
+
- Pass `signal` into what `fn` does. A `fn` that ignores it can overlap with the next run.
|
|
504
|
+
|
|
505
|
+
---
|
|
506
|
+
Source: docs/effects.md
|
|
507
|
+
|
|
508
|
+
# Effects and cancellation
|
|
509
|
+
|
|
510
|
+
Use this page for retry, timeouts, waiting, or parallel work — not as the first page for a small bus. Pick **one** helper for your job; the rest is reference.
|
|
511
|
+
|
|
512
|
+
An abort only raises a flag. Your code decides to stop. Start with `retry` / `timeout` on a handler (see [Guide §5](guide.md#5-add-retry-and-timeout)).
|
|
513
|
+
|
|
514
|
+
## Wrap a handler with retry and timeout
|
|
515
|
+
|
|
516
|
+
`retry`, `timeout`, and `poll` have two forms:
|
|
517
|
+
|
|
518
|
+
- **Task wrapper** — take a function `(payload, ctx) => result` and return one. Pass them into `on` next to policies (no `ctx` in their arguments).
|
|
519
|
+
- **Call inside a body** — take `ctx` first.
|
|
520
|
+
|
|
521
|
+
The first argument tells them apart: a function (`retry`), a number (`timeout`), or a number/function (`poll`) is a wrapper; an object is `ctx`.
|
|
522
|
+
|
|
523
|
+
```js
|
|
524
|
+
import { createBus } from "nanoeffects";
|
|
525
|
+
import { poll, retry, timeout } from "nanoeffects/effects";
|
|
526
|
+
import { latest } from "nanoeffects/policies";
|
|
527
|
+
|
|
528
|
+
const bus = createBus();
|
|
529
|
+
const loadUser = (id, { signal }) => fetchUser(id, { signal });
|
|
530
|
+
|
|
531
|
+
// timeout — one try must finish within 5 s
|
|
532
|
+
const loadOnce = timeout(5000, loadUser);
|
|
533
|
+
// retry — up to 4 attempts with backoff
|
|
534
|
+
const loadWithRetry = retry(loadOnce, {
|
|
535
|
+
times: 4,
|
|
536
|
+
delay: (attempt) => 2 ** attempt * 100,
|
|
537
|
+
shouldRetry: (error) => !isFatal(error),
|
|
538
|
+
});
|
|
539
|
+
// latest — a newer load aborts the older retry chain
|
|
540
|
+
bus.on("load", latest(loadWithRetry));
|
|
541
|
+
|
|
542
|
+
// poll — call refresh every 10 s until the task aborts
|
|
543
|
+
bus.on("watch", poll(10_000, (_, { signal }) => refresh({ signal })));
|
|
544
|
+
|
|
545
|
+
// ctx.run — run a wrapped Task as a child (aborted with the handler)
|
|
546
|
+
bus.on("open", async (id, ctx) => {
|
|
547
|
+
const load = retry(timeout(5000, loadUser));
|
|
548
|
+
const user = await ctx.run(load, id);
|
|
549
|
+
ctx.emit("opened", user);
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
// ctx-first form — one call inside a body
|
|
553
|
+
bus.on("load", async (id, ctx) => {
|
|
554
|
+
const user = await retry(
|
|
555
|
+
ctx,
|
|
556
|
+
() => fetchUser(id, { signal: ctx.signal }),
|
|
557
|
+
{ times: 4 },
|
|
558
|
+
);
|
|
559
|
+
const data = await timeout(ctx, 5000, ({ signal }) => {
|
|
560
|
+
return fetchData(id, { signal });
|
|
561
|
+
});
|
|
562
|
+
await poll(ctx, () => refresh(), 10_000);
|
|
563
|
+
ctx.emit("ready", { user, data });
|
|
564
|
+
});
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
Use the wrapper for a whole handler or a whole `ctx.run`. Use the `ctx` form for one call in the middle of a body.
|
|
568
|
+
|
|
569
|
+
Each attempt or call of a wrapper gets its own child context. Abort of the caller reaches it. Abort of one attempt (a timeout) does not reach the caller. The attempt number is passed only in the `ctx` form.
|
|
570
|
+
|
|
571
|
+
### Options for retry, timeout, and poll
|
|
572
|
+
|
|
573
|
+
**`retry`**
|
|
574
|
+
|
|
575
|
+
- `times` — total attempts (default `3`).
|
|
576
|
+
- `delay` — number or `(attempt, error) => ms`.
|
|
577
|
+
- `shouldRetry(error, attempt)` — stop early.
|
|
578
|
+
- `fn` gets the attempt number starting at `1`.
|
|
579
|
+
- On abort, rejects with the abort reason.
|
|
580
|
+
|
|
581
|
+
**`timeout`**
|
|
582
|
+
|
|
583
|
+
- Error is a `DOMException` named `TimeoutError` (like `AbortSignal.timeout`).
|
|
584
|
+
- Rejects even if the starter ignores its signal.
|
|
585
|
+
|
|
586
|
+
**`poll`** (like patronum `interval`)
|
|
587
|
+
|
|
588
|
+
- First call after `ms`. `leading: true` also calls at once.
|
|
589
|
+
- Next ticks keep a steady rate whatever the duration of `fn`, so a slow `fn` can overlap the next call.
|
|
590
|
+
- `waitForFn: true` starts each pause when the call ends — calls never overlap.
|
|
591
|
+
- `fn` gets the call number from `0`.
|
|
592
|
+
- An error from `fn` stops polling and rejects. An abort ends it normally and clears the timer.
|
|
593
|
+
|
|
594
|
+
A pause can be a function wherever a time is given. For retries with a cap and jitter, see [Back off between retries](#back-off-between-retries):
|
|
595
|
+
|
|
596
|
+
```js
|
|
597
|
+
import { createBus } from "nanoeffects";
|
|
598
|
+
import { delay, poll, retry } from "nanoeffects/effects";
|
|
599
|
+
|
|
600
|
+
const bus = createBus();
|
|
601
|
+
|
|
602
|
+
bus.on("sync", async (_, ctx) => {
|
|
603
|
+
retry(ctx, load, { times: 5, delay: (attempt) => 2 ** attempt * 100 }); // 200, 400, 800, …
|
|
604
|
+
poll(ctx, refresh, (call) => Math.min(1000 * 2 ** call, 60_000)); // slows to once a minute
|
|
605
|
+
await delay(ctx, () => 200 + Math.random() * 200); // 200–400 ms
|
|
606
|
+
});
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
All three are traced: the effect is a group (`retry`, `timeout`, `poll`) and each attempt or call is a `run` child.
|
|
610
|
+
|
|
611
|
+
## Back off between retries
|
|
612
|
+
|
|
613
|
+
Use this when a server or network fails under load. Retrying at once, or at the same fixed pause, sends every client back at the same moment and can keep the server down. Make the pause grow with each attempt, and add jitter so clients spread out.
|
|
614
|
+
|
|
615
|
+
The shortest form is a `delay` function. It needs no import:
|
|
616
|
+
|
|
617
|
+
```js
|
|
618
|
+
import { retry } from "nanoeffects/effects";
|
|
619
|
+
|
|
620
|
+
const loadWithRetry = retry(loadUser, {
|
|
621
|
+
times: 5,
|
|
622
|
+
delay: (attempt) => 2 ** attempt * 100, // 200, 400, 800, 1600 ms
|
|
623
|
+
});
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
When you also want a cap and jitter, build the function with `backoff`:
|
|
627
|
+
|
|
628
|
+
```js
|
|
629
|
+
import { backoff, retry } from "nanoeffects/effects";
|
|
630
|
+
|
|
631
|
+
const loadWithRetry = retry(loadUser, {
|
|
632
|
+
times: 5,
|
|
633
|
+
// backoff — pause 200, 400, 800, … ms, never over 5 s, a random part of it
|
|
634
|
+
delay: backoff({ base: 200, max: 5000, jitter: true }),
|
|
635
|
+
});
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
| Option | Meaning |
|
|
639
|
+
| ------ | ------- |
|
|
640
|
+
| `base` | pause before the second attempt, in ms (default `100`) |
|
|
641
|
+
| `factor` | each pause is this many times longer (default `2`) |
|
|
642
|
+
| `max` | longest pause, in ms (default none) |
|
|
643
|
+
| `jitter` | `true` picks a random pause from 0 up to the computed one (default `false`) |
|
|
644
|
+
|
|
645
|
+
- With `jitter: true` a pause can be close to 0, so treat `base` as a scale, not a minimum.
|
|
646
|
+
- `retry` counts attempts from 1. `poll` counts calls from 0, so with `poll` the first pause is `base / factor`.
|
|
647
|
+
- The result is a plain function `(attempt) => ms`, so it also fits `delay(ctx, …)`.
|
|
648
|
+
|
|
649
|
+
## Put selected effects on `ctx`
|
|
650
|
+
|
|
651
|
+
If handlers call the same helpers often, put them on the task once. A handler then destructures them like `signal`, with no `ctx` argument. Only the effects you pass are imported, so the rest stay out of the bundle ([package entries](package.md)).
|
|
652
|
+
|
|
653
|
+
```js
|
|
654
|
+
import { createBus } from "nanoeffects";
|
|
655
|
+
import { all, delay, effectsPlugin, race } from "nanoeffects/effects";
|
|
656
|
+
|
|
657
|
+
// effectsPlugin — add race, all and delay to the context of every task; returns the same bus
|
|
658
|
+
const bus = createBus().use(effectsPlugin({ race, all, delay }));
|
|
659
|
+
|
|
660
|
+
bus.on("load", async (id, { race, all, delay }) => {
|
|
661
|
+
// race — same as race(ctx, …)
|
|
662
|
+
const result = await race({
|
|
663
|
+
data: ({ signal }) => fetchData(id, { signal }),
|
|
664
|
+
timeout: () => delay(5000), // delay — same as delay(ctx, 5000)
|
|
665
|
+
});
|
|
666
|
+
if ("timeout" in result) throw new Error("timed out");
|
|
667
|
+
|
|
668
|
+
// all — same as all(ctx, …)
|
|
669
|
+
const { user, posts } = await all({
|
|
670
|
+
user: ({ signal }) => fetchUser(id, { signal }),
|
|
671
|
+
posts: ({ signal }) => fetchPosts(id, { signal }),
|
|
672
|
+
});
|
|
673
|
+
});
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
- Supported: `race`, `all`, `allSettled`, `take`, `delay`. The key is the effect name.
|
|
677
|
+
- `retry`, `timeout` and `poll` are not supported: bound to the task they would look like their handler wrappers.
|
|
678
|
+
- Fields from `createBus({ context })` stay. TypeScript rejects an effect whose name is already a context field of the bus, including one added by an earlier `effectsPlugin`.
|
|
679
|
+
- Free forms keep working: `race(ctx, …)`.
|
|
680
|
+
|
|
681
|
+
## Tie a promise to the task signal
|
|
682
|
+
|
|
683
|
+
`delay(ctx, ms)` waits and rejects on abort (`ms` can be a function).
|
|
684
|
+
|
|
685
|
+
`cancellable(ctx, executor)` is a promise tied to the task signal. It works like `new Promise`, but the executor gets `{ resolve, reject, onCancel }`.
|
|
686
|
+
|
|
687
|
+
```js
|
|
688
|
+
import { createBus } from "nanoeffects";
|
|
689
|
+
import { cancellable } from "nanoeffects/effects";
|
|
690
|
+
import { latest } from "nanoeffects/policies";
|
|
691
|
+
|
|
692
|
+
const bus = createBus();
|
|
693
|
+
|
|
694
|
+
// cancellable — promise tied to the task signal; onCancel for cleanups
|
|
695
|
+
// latest — a newer wait aborts the older timer
|
|
696
|
+
bus.on(
|
|
697
|
+
"wait",
|
|
698
|
+
latest((_, ctx) => {
|
|
699
|
+
return cancellable(ctx, ({ resolve, onCancel }) => {
|
|
700
|
+
const timer = setTimeout(resolve, 5000);
|
|
701
|
+
onCancel(() => clearTimeout(timer));
|
|
702
|
+
});
|
|
703
|
+
}),
|
|
704
|
+
);
|
|
705
|
+
```
|
|
706
|
+
|
|
707
|
+
- If the task aborts before the promise settles, cleanups run (last registered first) and the promise rejects with the abort reason.
|
|
708
|
+
- If the task is already aborted, the executor does not run.
|
|
709
|
+
- After the promise settles, the abort listener is removed and cleanups are dropped.
|
|
710
|
+
|
|
711
|
+
## Run cleanup on abort
|
|
712
|
+
|
|
713
|
+
`onAbort(ctx, fn)` runs `fn(reason)` on abort (at once if already aborted) and returns a function that unsubscribes.
|
|
714
|
+
|
|
715
|
+
```js
|
|
716
|
+
import { createBus } from "nanoeffects";
|
|
717
|
+
import { onAbort } from "nanoeffects/effects";
|
|
718
|
+
|
|
719
|
+
const bus = createBus();
|
|
720
|
+
|
|
721
|
+
bus.on("connect", (url, ctx) => {
|
|
722
|
+
const socket = new WebSocket(url);
|
|
723
|
+
// onAbort — run cleanup when this task aborts
|
|
724
|
+
onAbort(ctx, () => socket.close());
|
|
725
|
+
});
|
|
726
|
+
```
|
|
727
|
+
|
|
728
|
+
## Wait for the next event with `take`
|
|
729
|
+
|
|
730
|
+
A task context has `on` and `off`, like the bus. A subscription made with `ctx.on` ends when the task aborts:
|
|
731
|
+
|
|
732
|
+
```js
|
|
733
|
+
import { createBus } from "nanoeffects";
|
|
734
|
+
|
|
735
|
+
const bus = createBus();
|
|
736
|
+
|
|
737
|
+
bus.on("watch", (_, ctx) => {
|
|
738
|
+
ctx.on("tick", (n) => console.log(n)); // gone when this task aborts
|
|
739
|
+
});
|
|
740
|
+
```
|
|
741
|
+
|
|
742
|
+
- The returned function unsubscribes sooner. After abort, `ctx.on` does nothing.
|
|
743
|
+
- Under `latest`, subscriptions of a superseded run end with that run.
|
|
744
|
+
- `ctx.off(event)` clears every handler of the event on the bus, not only the task’s.
|
|
745
|
+
|
|
746
|
+
`take(ctx, event, options?)` waits for the next `event` and resolves with its payload:
|
|
747
|
+
|
|
748
|
+
```js
|
|
749
|
+
import { createBus } from "nanoeffects";
|
|
750
|
+
import { take, timeout } from "nanoeffects/effects";
|
|
751
|
+
|
|
752
|
+
const bus = createBus();
|
|
753
|
+
|
|
754
|
+
bus.on("login", async (user, ctx) => {
|
|
755
|
+
const reason = await take(ctx, "logout"); // resolves on the next logout
|
|
756
|
+
cleanup(user, reason);
|
|
757
|
+
|
|
758
|
+
// wait for a matching event, but not longer than 5 s
|
|
759
|
+
const id = await timeout(ctx, 5000, ({ signal }) =>
|
|
760
|
+
take(ctx, "saved", { signal, predicate: (savedId) => savedId === draft.id }),
|
|
761
|
+
);
|
|
762
|
+
});
|
|
763
|
+
```
|
|
764
|
+
|
|
765
|
+
- Sees only events emitted after the call, takes one, and unsubscribes.
|
|
766
|
+
- `options.predicate` skips payloads it rejects. In TypeScript, a type guard (`(payload): payload is Admin`) narrows the type of the result.
|
|
767
|
+
- `options.signal` stops the wait (how a `race` or `timeout` starter cancels it).
|
|
768
|
+
- On abort of the task or of `options.signal`, unsubscribes and rejects with the abort reason.
|
|
769
|
+
|
|
770
|
+
## Run several starters together
|
|
771
|
+
|
|
772
|
+
`all(ctx, effects)` runs starters together. One failure cancels the rest. Shape of the input (object or array) is preserved in the result.
|
|
773
|
+
|
|
774
|
+
`allSettled(ctx, effects)` waits for every starter and never fails because of one. Each result is `{ status: "fulfilled", value }` or `{ status: "rejected", reason }`. Nothing is cancelled when a starter fails. Only an abort of `ctx` rejects it.
|
|
775
|
+
|
|
776
|
+
```js
|
|
777
|
+
import { createBus } from "nanoeffects";
|
|
778
|
+
import { allSettled } from "nanoeffects/effects";
|
|
779
|
+
|
|
780
|
+
const bus = createBus();
|
|
781
|
+
|
|
782
|
+
bus.on("open", async (id, ctx) => {
|
|
783
|
+
const { user, posts } = await allSettled(ctx, {
|
|
784
|
+
user: ({ signal }) => fetchUser(id, { signal }),
|
|
785
|
+
posts: ({ signal }) => fetchPosts(id, { signal }),
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
if (user.status === "fulfilled") {
|
|
789
|
+
render(user.value);
|
|
790
|
+
}
|
|
791
|
+
});
|
|
792
|
+
```
|
|
793
|
+
|
|
794
|
+
`race(ctx, effects)` settles with the first starter that finishes and cancels the others.
|
|
795
|
+
|
|
796
|
+
## Pass `ctx` outside a task
|
|
797
|
+
|
|
798
|
+
Effects that wait inside a body take `ctx` first: `delay`, `all`, `allSettled`, `race`, `cancellable`, `onAbort`, `take`, and the `ctx` forms of `retry` / `timeout` / `poll`. Outside a task, pass `{}` or any object with a `signal`.
|
|
799
|
+
|
|
800
|
+
For `fetch` and other APIs that take a signal, pass `ctx.signal`. A timeout without `race`: `AbortSignal.any([ctx.signal, AbortSignal.timeout(5000)])`.
|
|
801
|
+
|
|
802
|
+
---
|
|
803
|
+
Source: docs/errors.md
|
|
804
|
+
|
|
805
|
+
# Handle errors and unheard events
|
|
806
|
+
|
|
807
|
+
Defaults match a small bus. Set these when you want isolation or typo warnings.
|
|
808
|
+
|
|
809
|
+
## Isolate failing handlers
|
|
810
|
+
|
|
811
|
+
`emit` is a plain loop over the handlers (like nanoevents). If a handler throws, the remaining handlers are skipped and the error reaches the caller of `emit`. A rejected promise of an async handler is left unhandled.
|
|
812
|
+
|
|
813
|
+
Pass `onError` to isolate handlers instead. Then every handler runs. Thrown errors and rejections of async handlers (including those wrapped in `latest`, `debounce`, and the like) go to `onError`. An `onError` that throws is logged to `console.error`.
|
|
814
|
+
|
|
815
|
+
```js
|
|
816
|
+
import { createBus } from "nanoeffects";
|
|
817
|
+
|
|
818
|
+
// createBus — onError isolates failing handlers so others still run
|
|
819
|
+
const bus = createBus({
|
|
820
|
+
onError: (error, { type, payload }) => {
|
|
821
|
+
report(error, type, payload);
|
|
822
|
+
},
|
|
823
|
+
});
|
|
824
|
+
```
|
|
825
|
+
|
|
826
|
+
## Warn when nobody listens
|
|
827
|
+
|
|
828
|
+
A typo in an event name (`emit("serch")`) does nothing and says nothing. `onUnhandled` runs when an event is emitted and nobody listens (a listener of every event, `"*"`, counts). It is not called for commands, which always have a handler to run.
|
|
829
|
+
|
|
830
|
+
Use it as a warning in development:
|
|
831
|
+
|
|
832
|
+
```js
|
|
833
|
+
import { createBus } from "nanoeffects";
|
|
834
|
+
|
|
835
|
+
const bus = createBus({
|
|
836
|
+
onUnhandled: import.meta.env.DEV
|
|
837
|
+
? ({ type }) => console.warn(`Nobody listens to "${type}"`)
|
|
838
|
+
: void 0,
|
|
839
|
+
});
|
|
840
|
+
```
|
|
841
|
+
|
|
842
|
+
---
|
|
843
|
+
Source: docs/actions.md
|
|
844
|
+
|
|
845
|
+
# Actions, commands, jobs and modules
|
|
846
|
+
|
|
847
|
+
Use this page when **domain logic lives on the bus**, off the UI: the UI emits and listens; handlers and modules own the work.
|
|
848
|
+
|
|
849
|
+
If you only need `on` / `emit` or policies, stay in the [Guide](guide.md) until you need a result from `emit` or a mountable domain.
|
|
850
|
+
|
|
851
|
+
Recipe without a UI framework: [`examples/modules.ts`](../examples/modules.ts).
|
|
852
|
+
|
|
853
|
+
## Return a value from `emit` with a command
|
|
854
|
+
|
|
855
|
+
```js
|
|
856
|
+
import { createBus } from "nanoeffects";
|
|
857
|
+
import { createCommand } from "nanoeffects/actions";
|
|
858
|
+
|
|
859
|
+
const bus = createBus();
|
|
860
|
+
|
|
861
|
+
// createCommand — emit runs the handler and returns its result
|
|
862
|
+
const double = createCommand("math/double", (value) => value * 2);
|
|
863
|
+
bus.emit(double(21)); // 42
|
|
864
|
+
```
|
|
865
|
+
|
|
866
|
+
## Describe events, commands, and jobs
|
|
867
|
+
|
|
868
|
+
`nanoeffects/actions` has the pieces to name what a bus does, apart from the code that does it.
|
|
869
|
+
|
|
870
|
+
```js
|
|
871
|
+
import { createAction, createCommand, createJob } from "nanoeffects/actions";
|
|
872
|
+
|
|
873
|
+
// createAction — event with a payload (like RTK createAction)
|
|
874
|
+
const userLoaded = createAction("user/loaded");
|
|
875
|
+
const loadFailed = createAction("user/failed");
|
|
876
|
+
|
|
877
|
+
// createCommand with a handler — emit returns what the handler returns
|
|
878
|
+
const getUser = createCommand("user/get", (id, { signal }) => {
|
|
879
|
+
return fetchUser(id, { signal });
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
// createCommand without a handler — a contract; register the work elsewhere
|
|
883
|
+
const sendMail = createCommand("mail/send");
|
|
884
|
+
|
|
885
|
+
// createJob — command that also emits success / fail actions
|
|
886
|
+
const loadUser = createJob("user/load", fetchUser, {
|
|
887
|
+
success: userLoaded,
|
|
888
|
+
fail: "user/failed",
|
|
889
|
+
});
|
|
890
|
+
```
|
|
891
|
+
|
|
892
|
+
### Actions
|
|
893
|
+
|
|
894
|
+
`createAction(type)` returns a creator. Without generics it keeps the literal type; the bus checks the payload against its event map. Without an event map, give the payload type: `createAction<User>("user/loaded")`.
|
|
895
|
+
|
|
896
|
+
- `userLoaded(user)` → `{ type, payload }`
|
|
897
|
+
- `userLoaded.type` — the type string
|
|
898
|
+
- `userLoaded.match(action)` — narrows an action
|
|
899
|
+
- `createAction(type, prepare)` — build the payload from several arguments
|
|
900
|
+
- Works as the event of its type: `bus.emit(userLoaded(user))`, `bus.on(userLoaded, fn)`
|
|
901
|
+
|
|
902
|
+
### Commands
|
|
903
|
+
|
|
904
|
+
Calling a command builds `{ type, payload }` like `createAction`. `bus.emit(command(payload))` (or `bus.emit(command, payload)`) tells the listeners of the command’s type, then runs its handler as a task and returns the result.
|
|
905
|
+
|
|
906
|
+
- Wrap the handler in a policy: `createCommand("search", latest(fn))`.
|
|
907
|
+
- `command.match(action)` narrows like an action creator’s `match`.
|
|
908
|
+
|
|
909
|
+
**Where handlers are registered.** The command may carry a handler. The bus can also take handlers from a registry (`commandsPlugin`). A handler bound there wins over the command’s own. Each bus (each test) has its own registry.
|
|
910
|
+
|
|
911
|
+
```js
|
|
912
|
+
import { createBus } from "nanoeffects";
|
|
913
|
+
import {
|
|
914
|
+
bindHandler,
|
|
915
|
+
commandsPlugin,
|
|
916
|
+
createCommand,
|
|
917
|
+
} from "nanoeffects/actions";
|
|
918
|
+
|
|
919
|
+
const sendMail = createCommand("mail/send");
|
|
920
|
+
const getUser = createCommand("user/get");
|
|
921
|
+
|
|
922
|
+
const bus = createBus();
|
|
923
|
+
|
|
924
|
+
// commandsPlugin — registry of handlers for this bus
|
|
925
|
+
const commands = bus.use(
|
|
926
|
+
commandsPlugin([bindHandler(sendMail, smtpSend)]),
|
|
927
|
+
);
|
|
928
|
+
|
|
929
|
+
// later, e.g. in a test — latest binding wins
|
|
930
|
+
const unbind = commands.handle(getUser, async () => fakeUser);
|
|
931
|
+
unbind();
|
|
932
|
+
```
|
|
933
|
+
|
|
934
|
+
A command with no handler anywhere throws `No handler for command "..."` from `emit` (the call itself only builds the action).
|
|
935
|
+
|
|
936
|
+
The registry lives in `nanoeffects/actions`, so a bus without one carries none of that code. Without a registry, `emit` runs the command’s own handler. `bus.use(commandsPlugin())` sets `bus.commands`. A second plugin on the same bus replaces it. There is no `commands` option in `createBus`; assign a custom runner with `bus.commands = { run }`.
|
|
937
|
+
|
|
938
|
+
### Jobs
|
|
939
|
+
|
|
940
|
+
`createJob(type, handler, { success, fail })` makes a command that also sends its outcome as actions, when nobody waits for the result. It returns the command with `loadUser.success` and `loadUser.fail` on it.
|
|
941
|
+
|
|
942
|
+
- On success, emits `loadUser.success(result)`.
|
|
943
|
+
- On failure, emits `loadUser.fail(error)`.
|
|
944
|
+
- `success` / `fail` are a string (job makes an action creator) or an existing action creator. Missing ones become `<type>/success` or `<type>/fail`.
|
|
945
|
+
- `bus.emit(loadUser(id))` returns a promise of the result. Handler errors are always handled: they go to `fail` and the promise resolves with `undefined`. For a result that must reject, use a command.
|
|
946
|
+
- Outcome actions are emitted as the task that ran the job (also for a handler bound with `commands.handle`). Nothing is sent after abort.
|
|
947
|
+
- A missing handler is not an outcome: `emit` throws `No handler for command "..."`.
|
|
948
|
+
- Calling the job only builds `{ type, payload }`; the handler runs on `emit`.
|
|
949
|
+
|
|
950
|
+
## Mount domains with modules
|
|
951
|
+
|
|
952
|
+
**Problem.** A domain needs several subscriptions and command bindings. With plain `bus.on` / `commands.handle` you must remember every unsubscribe.
|
|
953
|
+
|
|
954
|
+
**When.** You want one mount / one unmount for that domain. Skip modules while a few top-level `bus.on` calls are enough.
|
|
955
|
+
|
|
956
|
+
`bus.use(modulePlugin(modules))` assembles an app from modules and returns `mount`, `unmount`, `isMounted`, `dispose`, `commands`, and `createModule`: the same as `createModule` below, with the context fields of the bus typed (see [TypeScript](typescript.md)).
|
|
957
|
+
|
|
958
|
+
```js
|
|
959
|
+
import { createBus } from "nanoeffects";
|
|
960
|
+
import {
|
|
961
|
+
createCommand,
|
|
962
|
+
createModule,
|
|
963
|
+
modulePlugin,
|
|
964
|
+
} from "nanoeffects/actions";
|
|
965
|
+
|
|
966
|
+
const getUser = createCommand("user/get");
|
|
967
|
+
const saveUser = createCommand("user/save", persistUser);
|
|
968
|
+
|
|
969
|
+
// createModule — one domain; unmount undoes everything below
|
|
970
|
+
const userModule = createModule("user", (m) => {
|
|
971
|
+
m.handle(getUser, fetchUser);
|
|
972
|
+
m.use(saveUser, authModule);
|
|
973
|
+
m.on("login", (user, ctx) => {
|
|
974
|
+
ctx.emit("loaded", user);
|
|
975
|
+
});
|
|
976
|
+
m.spawn(async (_, ctx) => {
|
|
977
|
+
/* background work; ends when the module unmounts */
|
|
978
|
+
});
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
const bus = createBus();
|
|
982
|
+
// modulePlugin — mount modules on the bus
|
|
983
|
+
const app = bus.use(modulePlugin([userModule]));
|
|
984
|
+
|
|
985
|
+
const unmount = app.mount(orderModule); // later, e.g. after a lazy import
|
|
986
|
+
app.commands.handle(getUser, fakeUser); // in a test
|
|
987
|
+
app.dispose(); // unmounts every module
|
|
988
|
+
```
|
|
989
|
+
|
|
990
|
+
- **Unmounting undoes everything** the module did: subscriptions, bound commands, sub-modules, and background tasks (aborted).
|
|
991
|
+
- **A module is known by its name.** Mounting a module whose name is taken replaces the mounted one (see [Hot reload](hmr.md)).
|
|
992
|
+
- **Registry.** If `bus.use(commandsPlugin())` ran first, that registry is used; otherwise the module plugin attaches its own. Either way it is `bus.commands` and `app.commands`.
|
|
993
|
+
- A disposed app cannot mount modules.
|
|
994
|
+
- Module context = task context plus `handle`, `use`, and `spawn`:
|
|
995
|
+
- `handle` — give a command a handler (contract, or override).
|
|
996
|
+
- `use` — bring sub-modules and commands that carry a handler; the returned function undoes that call.
|
|
997
|
+
- `spawn` — background task, aborted on unmount.
|
|
998
|
+
- A command that carries a handler keeps answering after unmount (`use` only tracks registration). Use `handle` for a handler that must live only as long as the module.
|
|
999
|
+
|
|
1000
|
+
---
|
|
1001
|
+
Source: docs/stores.md
|
|
1002
|
+
|
|
1003
|
+
# Connect Redux, Zustand, MobX, or Pinia
|
|
1004
|
+
|
|
1005
|
+
Use this page when you **already have a store** and want async work with cancellation and policies next to it. It is not a reason to pick Redux (or any store).
|
|
1006
|
+
|
|
1007
|
+
If you do not have a store yet, use `createBus` from the [Guide](guide.md).
|
|
1008
|
+
|
|
1009
|
+
## On this page
|
|
1010
|
+
|
|
1011
|
+
- [Wire Redux Toolkit to a bus](#wire-redux-toolkit-to-a-bus)
|
|
1012
|
+
- [Emit from a state manager](#emit-from-a-state-manager)
|
|
1013
|
+
- [Nanostores](#nanostores)
|
|
1014
|
+
- [MobX](#mobx)
|
|
1015
|
+
- [Valtio](#valtio)
|
|
1016
|
+
- [Jotai](#jotai)
|
|
1017
|
+
- [Zustand](#zustand)
|
|
1018
|
+
- [Pinia](#pinia)
|
|
1019
|
+
- [React: sharing a value across components](#react-sharing-a-value-across-components)
|
|
1020
|
+
- [Callbacks on every change](#callbacks-on-every-change)
|
|
1021
|
+
- [Stop watching a store](#stop-watching-a-store)
|
|
1022
|
+
- [Give the store to every handler](#give-the-store-to-every-handler)
|
|
1023
|
+
|
|
1024
|
+
## Wire Redux Toolkit to a bus
|
|
1025
|
+
|
|
1026
|
+
Redux’s `dispatch` → reducers updates **state**. It does not give you first-class **tasks** (abort in-flight work, `latest` / `debounce` / `queue`, `retry` / `timeout`). People usually reach for thunks, RTK listener middleware, or sagas for that.
|
|
1027
|
+
|
|
1028
|
+
`createEffectsMiddleware(bus)` does the same job: after the reducer runs, the action also hits the nanoeffects bus you pass to it. Keep RTK for state; use policies and effects for async.
|
|
1029
|
+
|
|
1030
|
+
Skip this section if you are not already on Redux. The recipe as a file, type-checked with the project: [`examples/redux.ts`](../examples/redux.ts).
|
|
1031
|
+
|
|
1032
|
+
```js
|
|
1033
|
+
import { configureStore, createAction } from "@reduxjs/toolkit";
|
|
1034
|
+
import { createBus } from "nanoeffects";
|
|
1035
|
+
import { latest } from "nanoeffects/policies";
|
|
1036
|
+
import { createEffectsMiddleware } from "nanoeffects/redux";
|
|
1037
|
+
|
|
1038
|
+
const userRequested = createAction("user/requested"); // RTK
|
|
1039
|
+
const userLoaded = createAction("user/loaded");
|
|
1040
|
+
|
|
1041
|
+
const bus = createBus(); // the bus of your app; it can exist before the store
|
|
1042
|
+
|
|
1043
|
+
// createEffectsMiddleware — actions reach the bus after reducers; tasks get ctx.store
|
|
1044
|
+
const effects = createEffectsMiddleware(bus);
|
|
1045
|
+
|
|
1046
|
+
const store = configureStore({
|
|
1047
|
+
reducer,
|
|
1048
|
+
middleware: (getDefault) => getDefault().concat(effects),
|
|
1049
|
+
});
|
|
1050
|
+
|
|
1051
|
+
// latest — a newer run aborts the older one
|
|
1052
|
+
bus.on(
|
|
1053
|
+
userRequested,
|
|
1054
|
+
latest(async (id, { store, signal }) => {
|
|
1055
|
+
const user = await fetchUser(id, { signal });
|
|
1056
|
+
store.dispatch(userLoaded(user));
|
|
1057
|
+
}),
|
|
1058
|
+
);
|
|
1059
|
+
```
|
|
1060
|
+
|
|
1061
|
+
String event names (`"user/requested"`) work the same. Use your usual RTK actions (`createSlice` / `createAction`). nanoeffects adds the middleware and policies; it does not replace RTK actions.
|
|
1062
|
+
|
|
1063
|
+
### What the middleware guarantees
|
|
1064
|
+
|
|
1065
|
+
- **No self-loop.** An action that a handler dispatches through `ctx.store` and that has the type the handler works on does not go back to the bus. Other handlers of that type do not get it either. Longer cycles (`a` → `b` → `a`) are yours to avoid.
|
|
1066
|
+
- **A failing handler does not break `dispatch`.** With `onError` on the bus, the bus isolates the handlers. Without it the error goes to `console.error`, as in RTK listeners. Reducers still throw to the caller.
|
|
1067
|
+
- **`store` reaches tasks when the middleware is installed**, even though the bus is created before `configureStore`.
|
|
1068
|
+
|
|
1069
|
+
The only option is `filter`, which keeps rejected actions away from the bus. Options of the bus (`onError`, `context`, `trace`) go to `createBus`. Use one bus for one store. Values that are not plain actions (thunks) go through untouched. Nothing here depends on the `redux` package at runtime.
|
|
1070
|
+
|
|
1071
|
+
### Type `ctx.store` (optional)
|
|
1072
|
+
|
|
1073
|
+
Only if you want TypeScript to know `store` on handlers. **Skip this in JavaScript** — the middleware already puts `store` on the task at runtime. These lines do not connect the store across the app.
|
|
1074
|
+
|
|
1075
|
+
```ts
|
|
1076
|
+
import { createBus, type WithContext } from "nanoeffects";
|
|
1077
|
+
import {
|
|
1078
|
+
createEffectsMiddleware,
|
|
1079
|
+
type ReduxStoreApi,
|
|
1080
|
+
} from "nanoeffects/redux";
|
|
1081
|
+
|
|
1082
|
+
// RootState — your Redux state shape (for getState)
|
|
1083
|
+
type RootState = { user: string | null };
|
|
1084
|
+
|
|
1085
|
+
// Events — event name → payload for this bus
|
|
1086
|
+
// WithContext — checker-only: handlers also have ctx.store
|
|
1087
|
+
type Events = {
|
|
1088
|
+
"user/requested": string;
|
|
1089
|
+
} & WithContext<{ store: ReduxStoreApi<RootState> }>;
|
|
1090
|
+
|
|
1091
|
+
const bus = createBus<Events>(); // the event map types ctx.store
|
|
1092
|
+
const effects = createEffectsMiddleware(bus);
|
|
1093
|
+
```
|
|
1094
|
+
|
|
1095
|
+
## Emit from a state manager
|
|
1096
|
+
|
|
1097
|
+
Zustand, MobX, Jotai, Valtio, Nanostores and Pinia have no adapter. Redux is different: use the middleware from the first section, it sends actions to the bus. A plain store works with the bus in two directions, and both are shown for each store below:
|
|
1098
|
+
|
|
1099
|
+
- **The store triggers an event.** The store tells you about a change with a callback. You pass that callback `bus.emitter(action)`, and the change becomes an event that starts a handler.
|
|
1100
|
+
- **The event changes the store.** The handler reports its result as an event. A listener of the bus writes it into the store.
|
|
1101
|
+
|
|
1102
|
+
The example is a search box. The store holds two fields: `query`, which the user types, and `results`, which a request fills. A change of `query` starts a debounced search; the search ends with an event that writes `results`.
|
|
1103
|
+
|
|
1104
|
+
Writing `(query) => bus.emit(queryChanged(query))` for every subscription repeats the bus and the action. `emitterPlugin` adds `bus.emitter(action)`: it returns that callback for you. The action stays a plain `createAction`, so `bus.emit(queryChanged("nano"))` and `bus.on(queryChanged, …)` work as before.
|
|
1105
|
+
|
|
1106
|
+
The bus is the same for every store below. Create it once; each subsection imports it.
|
|
1107
|
+
|
|
1108
|
+
```js
|
|
1109
|
+
// search-bus.js
|
|
1110
|
+
import { createBus } from "nanoeffects";
|
|
1111
|
+
import { createAction, emitterPlugin } from "nanoeffects/actions";
|
|
1112
|
+
import { debounce, latest } from "nanoeffects/policies";
|
|
1113
|
+
|
|
1114
|
+
export const bus = createBus().use(emitterPlugin()); // adds bus.emitter; returns the same bus
|
|
1115
|
+
|
|
1116
|
+
// createAction — plain action creators; calling one only builds { type, payload }
|
|
1117
|
+
export const queryChanged = createAction("search/queryChanged");
|
|
1118
|
+
export const searchLoaded = createAction("search/loaded");
|
|
1119
|
+
|
|
1120
|
+
// bus.emitter: a callback (query) => bus.emit(queryChanged(query))
|
|
1121
|
+
export const emitQueryChanged = bus.emitter(queryChanged);
|
|
1122
|
+
|
|
1123
|
+
// debounce — wait 300 ms of quiet; latest — abort the search of the previous query
|
|
1124
|
+
bus.on(
|
|
1125
|
+
queryChanged,
|
|
1126
|
+
debounce(
|
|
1127
|
+
300,
|
|
1128
|
+
latest(async (query, ctx) => {
|
|
1129
|
+
// api — your client; ctx.signal lets latest cancel the request itself
|
|
1130
|
+
const items = await api.search(query, { signal: ctx.signal });
|
|
1131
|
+
ctx.emit(searchLoaded(items)); // report the result as an event
|
|
1132
|
+
}),
|
|
1133
|
+
),
|
|
1134
|
+
);
|
|
1135
|
+
```
|
|
1136
|
+
|
|
1137
|
+
Write `results` and never the field you watch: a handler that changed `query` would start itself again. Find your store below.
|
|
1138
|
+
|
|
1139
|
+
### Nanostores
|
|
1140
|
+
|
|
1141
|
+
`listen` calls back with the new value. `subscribe` would also call at once, with the current value, so use `listen`.
|
|
1142
|
+
|
|
1143
|
+
```js
|
|
1144
|
+
import { atom } from "nanostores";
|
|
1145
|
+
import { bus, emitQueryChanged, searchLoaded } from "./search-bus";
|
|
1146
|
+
|
|
1147
|
+
const $query = atom("");
|
|
1148
|
+
const $results = atom([]);
|
|
1149
|
+
|
|
1150
|
+
// store → bus: listen calls back with the new query on every change
|
|
1151
|
+
const stopQuery = $query.listen(emitQueryChanged);
|
|
1152
|
+
|
|
1153
|
+
// bus → store: bus.on returns the function that removes this listener
|
|
1154
|
+
const stopResults = bus.on(searchLoaded, (items) => {
|
|
1155
|
+
$results.set(items);
|
|
1156
|
+
});
|
|
1157
|
+
```
|
|
1158
|
+
|
|
1159
|
+
### MobX
|
|
1160
|
+
|
|
1161
|
+
`reaction` watches an expression and calls back with its value. Change observables inside `runInAction`.
|
|
1162
|
+
|
|
1163
|
+
```js
|
|
1164
|
+
import { observable, reaction, runInAction } from "mobx";
|
|
1165
|
+
import { bus, emitQueryChanged, searchLoaded } from "./search-bus";
|
|
1166
|
+
|
|
1167
|
+
const search = observable({ query: "", results: [] });
|
|
1168
|
+
|
|
1169
|
+
// store → bus: reaction watches the expression and calls back with its value
|
|
1170
|
+
const stopQuery = reaction(() => search.query, emitQueryChanged);
|
|
1171
|
+
|
|
1172
|
+
// bus → store: bus.on returns the function that removes this listener
|
|
1173
|
+
const stopResults = bus.on(searchLoaded, (items) => {
|
|
1174
|
+
runInAction(() => {
|
|
1175
|
+
search.results = items;
|
|
1176
|
+
});
|
|
1177
|
+
});
|
|
1178
|
+
```
|
|
1179
|
+
|
|
1180
|
+
### Valtio
|
|
1181
|
+
|
|
1182
|
+
`subscribeKey` watches one field of a proxy state. Assign to the proxy to change it.
|
|
1183
|
+
|
|
1184
|
+
```js
|
|
1185
|
+
import { proxy } from "valtio";
|
|
1186
|
+
import { subscribeKey } from "valtio/utils";
|
|
1187
|
+
import { bus, emitQueryChanged, searchLoaded } from "./search-bus";
|
|
1188
|
+
|
|
1189
|
+
const state = proxy({ query: "", results: [] });
|
|
1190
|
+
|
|
1191
|
+
// store → bus: subscribeKey watches one field
|
|
1192
|
+
const stopQuery = subscribeKey(state, "query", emitQueryChanged);
|
|
1193
|
+
|
|
1194
|
+
// bus → store: bus.on returns the function that removes this listener
|
|
1195
|
+
const stopResults = bus.on(searchLoaded, (items) => {
|
|
1196
|
+
state.results = items;
|
|
1197
|
+
});
|
|
1198
|
+
```
|
|
1199
|
+
|
|
1200
|
+
### Jotai
|
|
1201
|
+
|
|
1202
|
+
`sub` gives no value, so read it yourself. Write with `store.set`.
|
|
1203
|
+
|
|
1204
|
+
```js
|
|
1205
|
+
import { atom, createStore } from "jotai";
|
|
1206
|
+
import { bus, emitQueryChanged, searchLoaded } from "./search-bus";
|
|
1207
|
+
|
|
1208
|
+
const queryAtom = atom("");
|
|
1209
|
+
const resultsAtom = atom([]);
|
|
1210
|
+
const store = createStore();
|
|
1211
|
+
|
|
1212
|
+
// store → bus: sub calls back without a value; store.get reads it
|
|
1213
|
+
const stopQuery = store.sub(queryAtom, () => emitQueryChanged(store.get(queryAtom)));
|
|
1214
|
+
|
|
1215
|
+
// bus → store: bus.on returns the function that removes this listener
|
|
1216
|
+
const stopResults = bus.on(searchLoaded, (items) => {
|
|
1217
|
+
store.set(resultsAtom, items);
|
|
1218
|
+
});
|
|
1219
|
+
```
|
|
1220
|
+
|
|
1221
|
+
### Zustand
|
|
1222
|
+
|
|
1223
|
+
With `subscribeWithSelector` the selector picks the field, and the callback is called only when it changes. Without a selector Zustand calls back on every `setState`, with the whole state.
|
|
1224
|
+
|
|
1225
|
+
```js
|
|
1226
|
+
import { create } from "zustand";
|
|
1227
|
+
import { subscribeWithSelector } from "zustand/middleware";
|
|
1228
|
+
import { bus, emitQueryChanged, searchLoaded } from "./search-bus";
|
|
1229
|
+
|
|
1230
|
+
const useSearchStore = create(
|
|
1231
|
+
subscribeWithSelector(() => ({ query: "", results: [] })),
|
|
1232
|
+
);
|
|
1233
|
+
|
|
1234
|
+
// store → bus: subscribe with a selector calls back with state.query only when it changes
|
|
1235
|
+
const stopQuery = useSearchStore.subscribe((state) => state.query, emitQueryChanged);
|
|
1236
|
+
|
|
1237
|
+
// bus → store: bus.on returns the function that removes this listener
|
|
1238
|
+
const stopResults = bus.on(searchLoaded, (items) => {
|
|
1239
|
+
useSearchStore.setState({ results: items });
|
|
1240
|
+
});
|
|
1241
|
+
```
|
|
1242
|
+
|
|
1243
|
+
### Pinia
|
|
1244
|
+
|
|
1245
|
+
Pinia (the store of Vue) keeps reactive objects, and its `$subscribe` reports every mutation of the store, not one field. Watch one getter with Vue's `watch` instead: it calls back with the new value. Assign to the store to change it.
|
|
1246
|
+
|
|
1247
|
+
```js
|
|
1248
|
+
import { watch } from "vue";
|
|
1249
|
+
import { defineStore } from "pinia";
|
|
1250
|
+
import { bus, emitQueryChanged, searchLoaded } from "./search-bus";
|
|
1251
|
+
|
|
1252
|
+
// defineStore — Pinia: the state of one store
|
|
1253
|
+
const useSearch = defineStore("search", {
|
|
1254
|
+
state: () => ({ query: "", results: [] }),
|
|
1255
|
+
});
|
|
1256
|
+
|
|
1257
|
+
const search = useSearch(); // call it after the app has installed Pinia
|
|
1258
|
+
|
|
1259
|
+
// store → bus: watch calls back with the new query when search.query changes
|
|
1260
|
+
const stopQuery = watch(() => search.query, emitQueryChanged);
|
|
1261
|
+
|
|
1262
|
+
// bus → store: bus.on returns the function that removes this listener
|
|
1263
|
+
const stopResults = bus.on(searchLoaded, (items) => {
|
|
1264
|
+
search.results = items;
|
|
1265
|
+
});
|
|
1266
|
+
```
|
|
1267
|
+
|
|
1268
|
+
### React: sharing a value across components
|
|
1269
|
+
|
|
1270
|
+
A value of your UI (a query, a filter, a name) often has two jobs. Several components read it and re-render when it changes, and the bus must hear about each change to start a handler. The recipes below differ in where the value lives. Pick the first one unless a handler must read the value.
|
|
1271
|
+
|
|
1272
|
+
| The value lives in | Use when |
|
|
1273
|
+
| --- | --- |
|
|
1274
|
+
| A Context provider with `useState` | Only components read it. Plain React, one `useEmitter` call. |
|
|
1275
|
+
| A small store on `useSyncExternalStore` | A bus handler, a test or a timer must read it too. |
|
|
1276
|
+
| An atom of a store library | You already use Nanostores, Jotai and the like. |
|
|
1277
|
+
|
|
1278
|
+
#### Plain React: Context and `useEmitter`
|
|
1279
|
+
|
|
1280
|
+
Writing `useEffect(() => bus.emit(...), [value])` looks enough, but it emits on mount, emits twice on mount under StrictMode, and re-runs when the emit function is new on every render. `useEmitter` from `nanoeffects/react` avoids all three. It emits after a render commits with a changed value, never on mount, and compares with `Object.is`. You need React 18 or newer; no other entry of the package uses React.
|
|
1281
|
+
|
|
1282
|
+
First the bus. `reactPlugin()` adds `bus.useEmitter`, and destructuring it gives a plain hook that you export:
|
|
1283
|
+
|
|
1284
|
+
```js
|
|
1285
|
+
// react-search-bus.js
|
|
1286
|
+
import { createBus } from "nanoeffects";
|
|
1287
|
+
import { createAction } from "nanoeffects/actions";
|
|
1288
|
+
import { debounce, latest } from "nanoeffects/policies";
|
|
1289
|
+
import { reactPlugin } from "nanoeffects/react";
|
|
1290
|
+
|
|
1291
|
+
export const bus = createBus().use(reactPlugin()); // the bus; reactPlugin adds bus.useEmitter
|
|
1292
|
+
export const { useEmitter } = bus; // a free hook: it does not read `this`
|
|
1293
|
+
|
|
1294
|
+
export const queryChanged = createAction("query/changed");
|
|
1295
|
+
export const searchLoaded = createAction("search/loaded");
|
|
1296
|
+
|
|
1297
|
+
// debounce — wait 300 ms of quiet; latest — a newer query aborts the older request
|
|
1298
|
+
bus.on(
|
|
1299
|
+
queryChanged,
|
|
1300
|
+
debounce(
|
|
1301
|
+
300,
|
|
1302
|
+
latest(async (query, ctx) => {
|
|
1303
|
+
const items = await api.search(query, { signal: ctx.signal });
|
|
1304
|
+
ctx.emit(searchLoaded(items)); // the result goes back to the bus as an event
|
|
1305
|
+
}),
|
|
1306
|
+
),
|
|
1307
|
+
);
|
|
1308
|
+
```
|
|
1309
|
+
|
|
1310
|
+
Then the provider. It holds the state, calls `useEmitter` once, and listens for the result. Every component below it only reads the context:
|
|
1311
|
+
|
|
1312
|
+
```jsx
|
|
1313
|
+
// SearchProvider.jsx
|
|
1314
|
+
import { createContext, useContext, useEffect, useState } from "react";
|
|
1315
|
+
import { bus, queryChanged, searchLoaded, useEmitter } from "./react-search-bus";
|
|
1316
|
+
|
|
1317
|
+
const SearchContext = createContext(null);
|
|
1318
|
+
|
|
1319
|
+
export function SearchProvider({ children }) {
|
|
1320
|
+
const [query, setQuery] = useState("");
|
|
1321
|
+
const [items, setItems] = useState([]);
|
|
1322
|
+
|
|
1323
|
+
// React → bus: emits queryChanged(query) after every change of query, not on mount
|
|
1324
|
+
useEmitter(queryChanged, query);
|
|
1325
|
+
|
|
1326
|
+
// bus → React: bus.on returns the unsubscribe function, which the effect returns
|
|
1327
|
+
useEffect(() => bus.on(searchLoaded, setItems), []);
|
|
1328
|
+
|
|
1329
|
+
return (
|
|
1330
|
+
<SearchContext.Provider value={{ query, setQuery, items }}>
|
|
1331
|
+
{children}
|
|
1332
|
+
</SearchContext.Provider>
|
|
1333
|
+
);
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
export const useSearch = () => useContext(SearchContext);
|
|
1337
|
+
```
|
|
1338
|
+
|
|
1339
|
+
```jsx
|
|
1340
|
+
// Search.jsx
|
|
1341
|
+
import { useSearch } from "./SearchProvider";
|
|
1342
|
+
|
|
1343
|
+
// any number of components can read the same value
|
|
1344
|
+
export function SearchBox() {
|
|
1345
|
+
const { query, setQuery } = useSearch();
|
|
1346
|
+
return <input value={query} onChange={(event) => setQuery(event.target.value)} />;
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
export function Results() {
|
|
1350
|
+
const { items } = useSearch();
|
|
1351
|
+
return (
|
|
1352
|
+
<ul>
|
|
1353
|
+
{items.map((item) => (
|
|
1354
|
+
<li key={item.id}>{item.title}</li>
|
|
1355
|
+
))}
|
|
1356
|
+
</ul>
|
|
1357
|
+
);
|
|
1358
|
+
}
|
|
1359
|
+
```
|
|
1360
|
+
|
|
1361
|
+
Two limits to know:
|
|
1362
|
+
|
|
1363
|
+
- **All consumers re-render** on any change of the context value. Split it into two contexts (`query` and `items`) if that is slow.
|
|
1364
|
+
- **A new object counts as a change.** For a value that is rebuilt with the same content, pass a third argument that says when two values are the same: `useEmitter(profileChanged, profile, (previous, next) => previous.id === next.id)`.
|
|
1365
|
+
|
|
1366
|
+
`useEmitter` emits after the render, not at the moment of `setQuery`. To emit on every call, even with the same value, call an emitter from the event handler (`bus.emitter(action)`, see [Zustand](#zustand)) instead.
|
|
1367
|
+
|
|
1368
|
+
#### A value that a handler must read
|
|
1369
|
+
|
|
1370
|
+
A context is not visible to a bus handler. When a handler needs the current value, keep the value outside React. About twelve lines of `useSyncExternalStore` do it, and `contextPlugin` hands it to every handler as `ctx.query`:
|
|
1371
|
+
|
|
1372
|
+
```jsx
|
|
1373
|
+
// value-store.js
|
|
1374
|
+
import { useSyncExternalStore } from "react";
|
|
1375
|
+
|
|
1376
|
+
export function createValue(initial) {
|
|
1377
|
+
let value = initial;
|
|
1378
|
+
const listeners = new Set();
|
|
1379
|
+
|
|
1380
|
+
return {
|
|
1381
|
+
get: () => value,
|
|
1382
|
+
set(next) {
|
|
1383
|
+
if (Object.is(next, value)) {
|
|
1384
|
+
return; // no change, no notification: this also stops loops
|
|
1385
|
+
}
|
|
1386
|
+
value = next;
|
|
1387
|
+
for (const listener of listeners) {
|
|
1388
|
+
listener();
|
|
1389
|
+
}
|
|
1390
|
+
},
|
|
1391
|
+
subscribe(listener) {
|
|
1392
|
+
listeners.add(listener);
|
|
1393
|
+
return () => listeners.delete(listener);
|
|
1394
|
+
},
|
|
1395
|
+
};
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
export const useValue = (store) => useSyncExternalStore(store.subscribe, store.get);
|
|
1399
|
+
```
|
|
1400
|
+
|
|
1401
|
+
```jsx
|
|
1402
|
+
// search-value.jsx
|
|
1403
|
+
import { createBus, contextPlugin } from "nanoeffects";
|
|
1404
|
+
import { createAction, emitterPlugin } from "nanoeffects/actions";
|
|
1405
|
+
import { createValue, useValue } from "./value-store";
|
|
1406
|
+
|
|
1407
|
+
const query = createValue("");
|
|
1408
|
+
|
|
1409
|
+
// emitterPlugin adds bus.emitter; contextPlugin puts `query` on every task as ctx.query
|
|
1410
|
+
const bus = createBus().use(emitterPlugin()).use(contextPlugin({ query }));
|
|
1411
|
+
|
|
1412
|
+
const queryChanged = createAction("query/changed");
|
|
1413
|
+
const refreshed = createAction("search/refreshed");
|
|
1414
|
+
|
|
1415
|
+
// store → bus: bus.emitter turns the action into a callback; one place, any number of setters
|
|
1416
|
+
const emitQueryChanged = bus.emitter(queryChanged);
|
|
1417
|
+
query.subscribe(() => emitQueryChanged(query.get()));
|
|
1418
|
+
|
|
1419
|
+
// the handler reads the current value from ctx, whoever changed it
|
|
1420
|
+
bus.on(refreshed, async (_payload, ctx) => {
|
|
1421
|
+
const items = await api.search(ctx.query.get(), { signal: ctx.signal });
|
|
1422
|
+
console.log(items.length);
|
|
1423
|
+
});
|
|
1424
|
+
|
|
1425
|
+
// any number of components read and write the same value
|
|
1426
|
+
export function SearchBox() {
|
|
1427
|
+
const text = useValue(query);
|
|
1428
|
+
return <input value={text} onChange={(event) => query.set(event.target.value)} />;
|
|
1429
|
+
}
|
|
1430
|
+
```
|
|
1431
|
+
|
|
1432
|
+
#### A store library
|
|
1433
|
+
|
|
1434
|
+
With Nanostores, Jotai or Zustand the value already lives outside React and has a subscription. Connect it to the bus once, as in [Nanostores](#nanostores), and read it in components with the library's own hook:
|
|
1435
|
+
|
|
1436
|
+
```jsx
|
|
1437
|
+
// search-atom.jsx
|
|
1438
|
+
import { atom } from "nanostores";
|
|
1439
|
+
import { useStore } from "@nanostores/react";
|
|
1440
|
+
import { createBus } from "nanoeffects";
|
|
1441
|
+
import { createAction, emitterPlugin } from "nanoeffects/actions";
|
|
1442
|
+
|
|
1443
|
+
const bus = createBus().use(emitterPlugin()); // adds bus.emitter
|
|
1444
|
+
const $query = atom("");
|
|
1445
|
+
const queryChanged = createAction("query/changed");
|
|
1446
|
+
|
|
1447
|
+
// listen, not subscribe: listen skips the call with the current value at start
|
|
1448
|
+
$query.listen(bus.emitter(queryChanged));
|
|
1449
|
+
|
|
1450
|
+
export function SearchBox() {
|
|
1451
|
+
const query = useStore($query); // the hook of Nanostores; it re-renders on every change
|
|
1452
|
+
return <input value={query} onChange={(event) => $query.set(event.target.value)} />;
|
|
1453
|
+
}
|
|
1454
|
+
```
|
|
1455
|
+
|
|
1456
|
+
#### Do not emit inside a `useReducer` reducer
|
|
1457
|
+
|
|
1458
|
+
React may run a reducer more than once for one dispatch (twice in StrictMode), so a `bus.emit` inside it fires twice. Dispatch to the reducer first, then emit, from one helper. Or keep the state in a reducer and pass it to `useEmitter` like any other value.
|
|
1459
|
+
|
|
1460
|
+
```jsx
|
|
1461
|
+
// search-reducer.jsx
|
|
1462
|
+
import { useReducer } from "react";
|
|
1463
|
+
import { createBus } from "nanoeffects";
|
|
1464
|
+
import { createAction } from "nanoeffects/actions";
|
|
1465
|
+
|
|
1466
|
+
const bus = createBus(); // the bus: created once
|
|
1467
|
+
|
|
1468
|
+
const queryChanged = createAction("query/changed");
|
|
1469
|
+
const queryCleared = createAction("query/cleared");
|
|
1470
|
+
|
|
1471
|
+
function reducer(state, action) {
|
|
1472
|
+
if (queryChanged.match(action)) {
|
|
1473
|
+
return action.payload;
|
|
1474
|
+
}
|
|
1475
|
+
if (queryCleared.match(action)) {
|
|
1476
|
+
return "";
|
|
1477
|
+
}
|
|
1478
|
+
return state;
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
export function Search() {
|
|
1482
|
+
const [query, dispatch] = useReducer(reducer, "");
|
|
1483
|
+
|
|
1484
|
+
// send: the reducer first, then the bus (bus.emit accepts an action)
|
|
1485
|
+
function send(action) {
|
|
1486
|
+
dispatch(action);
|
|
1487
|
+
bus.emit(action);
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
return (
|
|
1491
|
+
<div>
|
|
1492
|
+
<input value={query} onChange={(event) => send(queryChanged(event.target.value))} />
|
|
1493
|
+
<button type="button" onClick={() => send(queryCleared())}>
|
|
1494
|
+
Clear
|
|
1495
|
+
</button>
|
|
1496
|
+
</div>
|
|
1497
|
+
);
|
|
1498
|
+
}
|
|
1499
|
+
```
|
|
1500
|
+
|
|
1501
|
+
A plain `dispatch` skips the bus, so call `send` everywhere.
|
|
1502
|
+
|
|
1503
|
+
### Callbacks on every change
|
|
1504
|
+
|
|
1505
|
+
The stores above call back only when the value changed, so the emitter does not compare values. For a store that calls back on every change, select a smaller value first, as Zustand does above.
|
|
1506
|
+
|
|
1507
|
+
### Stop watching a store
|
|
1508
|
+
|
|
1509
|
+
Stop both directions with what the calls returned: `stopQuery` from the store and `stopResults` from `bus.on`. Call them yourself, or hand them over: `onAbort(ctx, stopQuery)` inside a task, or `m.signal.addEventListener("abort", stopQuery)` in a module.
|
|
1510
|
+
|
|
1511
|
+
### Give the store to every handler
|
|
1512
|
+
|
|
1513
|
+
Instead of a listener per store, give the store to all tasks with `createBus({ context: { store } })` ([Context fields](context.md)) and change it from any handler (`setState`, a MobX action, a Pinia store). The same rule holds: write the field you do not watch. For a MobX `flow`, `onAbort(ctx, () => promise.cancel())` cancels it with the task.
|
|
1514
|
+
|
|
1515
|
+
---
|
|
1516
|
+
Source: docs/react.md
|
|
1517
|
+
|
|
1518
|
+
# Build a sign-up form in React
|
|
1519
|
+
|
|
1520
|
+
Use this page when a React screen starts requests on user input and you need them not to race: a username check while typing, a submit button, a flaky network. It builds one registration form. The bus lives outside React; the component only subscribes to it.
|
|
1521
|
+
|
|
1522
|
+
You need a React project and `nanoeffects` installed. The form talks to your own client, `api` below: replace it with your requests.
|
|
1523
|
+
|
|
1524
|
+
## See what goes wrong without a bus
|
|
1525
|
+
|
|
1526
|
+
A registration form has three problems that `useEffect` and `fetch` leave to you:
|
|
1527
|
+
|
|
1528
|
+
- **A stale answer.** The user types `ann`, then `anna`. The answer for `ann` arrives last and says the name is free, although `anna` is taken.
|
|
1529
|
+
- **A double submit.** A second click on "Sign up" sends a second request while the first one is running.
|
|
1530
|
+
- **A flaky network.** One failed request should be retried, but a "name is taken" answer should not.
|
|
1531
|
+
|
|
1532
|
+
Each has a policy or an effect: `latest`, `leading`, `retry`. They live in one place, outside the component.
|
|
1533
|
+
|
|
1534
|
+
## Wire the bus once
|
|
1535
|
+
|
|
1536
|
+
Put the bus and its handlers in their own file. Create it once, outside any component: a bus created in the body of a component would be a new bus, without subscribers, on every render.
|
|
1537
|
+
|
|
1538
|
+
```js
|
|
1539
|
+
// registration.js
|
|
1540
|
+
import { createBus } from "nanoeffects";
|
|
1541
|
+
import { retry } from "nanoeffects/effects";
|
|
1542
|
+
import { debounce, latest, leading } from "nanoeffects/policies";
|
|
1543
|
+
|
|
1544
|
+
// fetch rejects with a TypeError when the network fails; an HTTP error is a plain Error
|
|
1545
|
+
const isNetworkError = (error) => error instanceof TypeError;
|
|
1546
|
+
|
|
1547
|
+
export const bus = createBus(); // the bus: created once, at the top of the module
|
|
1548
|
+
|
|
1549
|
+
// debounce — wait 300 ms of quiet; latest — abort the check of the previous name
|
|
1550
|
+
bus.on(
|
|
1551
|
+
"username/typed",
|
|
1552
|
+
debounce(
|
|
1553
|
+
300,
|
|
1554
|
+
latest(async (name, ctx) => {
|
|
1555
|
+
// api — your client; pass ctx.signal so latest can cancel the request itself
|
|
1556
|
+
const available = await api.usernameAvailable(name, { signal: ctx.signal });
|
|
1557
|
+
ctx.emit("username/checked", { name, available });
|
|
1558
|
+
}),
|
|
1559
|
+
),
|
|
1560
|
+
);
|
|
1561
|
+
|
|
1562
|
+
// leading — a second click is ignored while the first sign up is running
|
|
1563
|
+
bus.on(
|
|
1564
|
+
"form/submitted",
|
|
1565
|
+
leading(async (form, ctx) => {
|
|
1566
|
+
const signUp = () => api.signUp(form, { signal: ctx.signal });
|
|
1567
|
+
try {
|
|
1568
|
+
// retry — up to 3 tries, 500 ms apart, and only when the network failed
|
|
1569
|
+
const user = await retry(ctx, signUp, {
|
|
1570
|
+
times: 3,
|
|
1571
|
+
delay: 500,
|
|
1572
|
+
shouldRetry: isNetworkError,
|
|
1573
|
+
});
|
|
1574
|
+
ctx.emit("form/succeeded", user);
|
|
1575
|
+
} catch (error) {
|
|
1576
|
+
ctx.emit("form/failed", error.message);
|
|
1577
|
+
}
|
|
1578
|
+
}),
|
|
1579
|
+
);
|
|
1580
|
+
```
|
|
1581
|
+
|
|
1582
|
+
The handlers know nothing about React. A test can emit `username/typed` and read `username/checked` without rendering anything: the same wiring is tested in [`examples/registration-form.ts`](../examples/registration-form.ts) and `src/tests/registration-form.test.ts`.
|
|
1583
|
+
|
|
1584
|
+
## Connect the form
|
|
1585
|
+
|
|
1586
|
+
The component keeps what it shows in `useState`. It subscribes in `useEffect` and emits events from its handlers. `bus.on` returns the function that unsubscribes; returning it from the effect removes the listener on unmount.
|
|
1587
|
+
|
|
1588
|
+
```jsx
|
|
1589
|
+
// SignUpForm.jsx
|
|
1590
|
+
import { useEffect, useState } from "react";
|
|
1591
|
+
import { bus } from "./registration"; // the bus of the previous snippet
|
|
1592
|
+
|
|
1593
|
+
export function SignUpForm() {
|
|
1594
|
+
const [name, setName] = useState("");
|
|
1595
|
+
const [password, setPassword] = useState("");
|
|
1596
|
+
const [available, setAvailable] = useState(null); // null: not checked yet
|
|
1597
|
+
const [status, setStatus] = useState("idle"); // idle, sending, done or failed
|
|
1598
|
+
const [error, setError] = useState("");
|
|
1599
|
+
|
|
1600
|
+
useEffect(() => {
|
|
1601
|
+
// bus.on — listen for what the handlers report; each call returns an unsubscribe function
|
|
1602
|
+
const unbindChecked = bus.on("username/checked", (result) => {
|
|
1603
|
+
setAvailable(result.available);
|
|
1604
|
+
});
|
|
1605
|
+
const unbindSucceeded = bus.on("form/succeeded", () => setStatus("done"));
|
|
1606
|
+
const unbindFailed = bus.on("form/failed", (message) => {
|
|
1607
|
+
setStatus("failed");
|
|
1608
|
+
setError(message);
|
|
1609
|
+
});
|
|
1610
|
+
|
|
1611
|
+
return () => {
|
|
1612
|
+
unbindChecked();
|
|
1613
|
+
unbindSucceeded();
|
|
1614
|
+
unbindFailed();
|
|
1615
|
+
};
|
|
1616
|
+
}, []);
|
|
1617
|
+
|
|
1618
|
+
function handleName(event) {
|
|
1619
|
+
const value = event.target.value;
|
|
1620
|
+
setName(value);
|
|
1621
|
+
setAvailable(null);
|
|
1622
|
+
bus.emit("username/typed", value); // bus.emit — the handler debounces and checks the name
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
function handleSubmit(event) {
|
|
1626
|
+
event.preventDefault();
|
|
1627
|
+
setStatus("sending");
|
|
1628
|
+
bus.emit("form/submitted", { name, password }); // a second click is dropped by leading
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
return (
|
|
1632
|
+
<form onSubmit={handleSubmit}>
|
|
1633
|
+
<input value={name} onChange={handleName} placeholder="Username" />
|
|
1634
|
+
{available === false && <p>This name is taken</p>}
|
|
1635
|
+
<input
|
|
1636
|
+
type="password"
|
|
1637
|
+
value={password}
|
|
1638
|
+
onChange={(event) => setPassword(event.target.value)}
|
|
1639
|
+
/>
|
|
1640
|
+
<button type="submit" disabled={status === "sending"}>Sign up</button>
|
|
1641
|
+
{status === "failed" && <p>{error}</p>}
|
|
1642
|
+
{status === "done" && <p>Welcome!</p>}
|
|
1643
|
+
</form>
|
|
1644
|
+
);
|
|
1645
|
+
}
|
|
1646
|
+
```
|
|
1647
|
+
|
|
1648
|
+
The `disabled` button is only for the look. The form stays correct without it: `leading` drops a second submit while the first one runs.
|
|
1649
|
+
|
|
1650
|
+
In development, React StrictMode runs the effect twice (subscribe, unsubscribe, subscribe). That is safe here, because the cleanup removes exactly what the effect added.
|
|
1651
|
+
|
|
1652
|
+
## Stop running work when the form closes
|
|
1653
|
+
|
|
1654
|
+
Unsubscribing stops the component from hearing results, but a request that has started keeps running. To cancel it, the component emits `form/closed` when it unmounts, and each handler that should stop listens for it with `ctx.on`:
|
|
1655
|
+
|
|
1656
|
+
```js
|
|
1657
|
+
import { latest } from "nanoeffects/policies";
|
|
1658
|
+
|
|
1659
|
+
// the username check of registration.js, with one new line
|
|
1660
|
+
const checkName = latest(async (name, ctx) => {
|
|
1661
|
+
// ctx.on — a subscription of this task; aborting the task also drops the result
|
|
1662
|
+
ctx.on("form/closed", () => ctx.abort());
|
|
1663
|
+
const available = await api.usernameAvailable(name, { signal: ctx.signal });
|
|
1664
|
+
ctx.emit("username/checked", { name, available });
|
|
1665
|
+
});
|
|
1666
|
+
```
|
|
1667
|
+
|
|
1668
|
+
In the component, add `bus.emit("form/closed")` to the cleanup function of the effect. This is optional: skip it when finishing a request after unmount does no harm, as for a cheap check.
|
|
1669
|
+
|
|
1670
|
+
## Where to go next
|
|
1671
|
+
|
|
1672
|
+
- The policies behind this form: [Policies](policies.md).
|
|
1673
|
+
- `retry`, `timeout` and the other effects: [Effects](effects.md).
|
|
1674
|
+
- A state library instead of `useState`: [Stores](stores.md).
|
|
1675
|
+
|
|
1676
|
+
---
|
|
1677
|
+
Source: docs/context.md
|
|
1678
|
+
|
|
1679
|
+
# Put your own fields on every task
|
|
1680
|
+
|
|
1681
|
+
Optional: put a store, client, or logger on every task. Skip until many handlers need the same dependency.
|
|
1682
|
+
|
|
1683
|
+
## Pass values at runtime
|
|
1684
|
+
|
|
1685
|
+
```js
|
|
1686
|
+
import { createBus } from "nanoeffects";
|
|
1687
|
+
|
|
1688
|
+
// createBus({ context }) — every task gets these fields
|
|
1689
|
+
const bus = createBus({ context: { store } });
|
|
1690
|
+
|
|
1691
|
+
bus.on("save", (draft, { store }) => {
|
|
1692
|
+
store.put(draft);
|
|
1693
|
+
});
|
|
1694
|
+
```
|
|
1695
|
+
|
|
1696
|
+
That is enough in JavaScript. The fields appear on handlers, `ctx.run`, `ctx.on`, commands, modules, and child tasks of effects and policies.
|
|
1697
|
+
|
|
1698
|
+
Library fields (`signal`, `emit`, `run`, …) win on name clashes. `context` may be a function `(task) => fields` when the value depends on the task.
|
|
1699
|
+
|
|
1700
|
+
To get `race`, `all`, `take` and the like on every task without passing `ctx`, see [`effectsPlugin`](effects.md#put-selected-effects-on-ctx).
|
|
1701
|
+
|
|
1702
|
+
## Type the fields (optional)
|
|
1703
|
+
|
|
1704
|
+
In TypeScript, `contextPlugin` puts the fields on every task of **this** bus and takes their types from the value you pass. The event map holds only events. It does not wire anything across the app — another bus can have different fields or none.
|
|
1705
|
+
|
|
1706
|
+
```ts
|
|
1707
|
+
import { contextPlugin, createBus } from "nanoeffects";
|
|
1708
|
+
|
|
1709
|
+
// Events — event name → payload
|
|
1710
|
+
type Events = { save: Draft };
|
|
1711
|
+
|
|
1712
|
+
// contextPlugin — every task gets ctx.store; the type is the type of `store`
|
|
1713
|
+
const bus = createBus<Events>().use(contextPlugin({ store }));
|
|
1714
|
+
|
|
1715
|
+
bus.on("save", (draft, { store }) => store.put(draft));
|
|
1716
|
+
```
|
|
1717
|
+
|
|
1718
|
+
- A function `(task) => fields` makes the fields per task. It gets `{ type, payload }`.
|
|
1719
|
+
- Several `contextPlugin` calls, `effectsPlugin` and the `context` option of `createBus` all add their fields to the same context.
|
|
1720
|
+
- A name that the bus already has is a type error. The fields of the library (`signal`, `emit`, `run`, …) win at runtime, and a field that the context already has is not replaced.
|
|
1721
|
+
- The plugin returns the same bus, so keep the result of the chain: `const bus = createBus().use(contextPlugin({ store }))`.
|
|
1722
|
+
|
|
1723
|
+
### Name the fields in the event map instead
|
|
1724
|
+
|
|
1725
|
+
`createBus<Events>({ context: { store } })` also types the fields, but through the event map: `WithContext` is checker-only and says which fields the handlers of this bus have. Use it when you cannot call `use`, for example for the bus that `createEffectsMiddleware` makes.
|
|
1726
|
+
|
|
1727
|
+
```ts
|
|
1728
|
+
import { createBus, type WithContext } from "nanoeffects";
|
|
1729
|
+
|
|
1730
|
+
// WithContext — checker-only: handlers also have ctx.store
|
|
1731
|
+
type Events = { save: Draft } & WithContext<{ store: Store }>;
|
|
1732
|
+
|
|
1733
|
+
const bus = createBus<Events>({ context: { store } });
|
|
1734
|
+
```
|
|
1735
|
+
|
|
1736
|
+
---
|
|
1737
|
+
Source: docs/typescript.md
|
|
1738
|
+
|
|
1739
|
+
# Use nanoeffects in a TypeScript app with many files
|
|
1740
|
+
|
|
1741
|
+
Read this page if your app is in TypeScript and the commands and modules live in different files. It shows how to write a command so that TypeScript infers its payload and result, and what to add when the handlers need the fields of your bus (`ctx.store`, the effects of `effectsPlugin`). For one file or for JavaScript you do not need it.
|
|
1742
|
+
|
|
1743
|
+
## Write a command with its handler
|
|
1744
|
+
|
|
1745
|
+
Give `createCommand` the handler and write nothing else. The payload comes from the annotated parameter, the result from the body. You do not write a generic.
|
|
1746
|
+
|
|
1747
|
+
<!-- docs-check: skip - a file of examples/typescript, type-checked there -->
|
|
1748
|
+
```ts
|
|
1749
|
+
// user.ts
|
|
1750
|
+
import { createCommand, createModule } from "nanoeffects/actions";
|
|
1751
|
+
|
|
1752
|
+
export type User = { id: string; name: string };
|
|
1753
|
+
|
|
1754
|
+
// payload: string, from `id: string`; result: Promise<User>, from the body
|
|
1755
|
+
export const getUser = createCommand(
|
|
1756
|
+
"user/get",
|
|
1757
|
+
async (id: string, { signal }) => {
|
|
1758
|
+
const response = await fetch(`/api/users/${id}`, { signal });
|
|
1759
|
+
const user: User = await response.json();
|
|
1760
|
+
return user;
|
|
1761
|
+
},
|
|
1762
|
+
);
|
|
1763
|
+
|
|
1764
|
+
// createModule: m.use binds the handler the command carries, while the module is mounted
|
|
1765
|
+
export const userModule = createModule("user", (m) => {
|
|
1766
|
+
m.use(getUser);
|
|
1767
|
+
});
|
|
1768
|
+
```
|
|
1769
|
+
|
|
1770
|
+
Mount the module where you create the bus. The result is typed from the handler:
|
|
1771
|
+
|
|
1772
|
+
<!-- docs-check: skip - a file of examples/typescript, type-checked there -->
|
|
1773
|
+
```ts
|
|
1774
|
+
// main.ts
|
|
1775
|
+
import { createBus } from "nanoeffects";
|
|
1776
|
+
import { modulePlugin } from "nanoeffects/actions";
|
|
1777
|
+
import { getUser, userModule } from "./user";
|
|
1778
|
+
|
|
1779
|
+
const bus = createBus();
|
|
1780
|
+
const app = bus.use(modulePlugin([userModule])); // mounts the modules
|
|
1781
|
+
|
|
1782
|
+
const user = bus.emit(getUser("1")); // Promise<User>
|
|
1783
|
+
```
|
|
1784
|
+
|
|
1785
|
+
What you annotate, and why:
|
|
1786
|
+
|
|
1787
|
+
| Part | Annotated? | Why |
|
|
1788
|
+
| ---- | ---------- | --- |
|
|
1789
|
+
| payload (`id: string`) | yes | a function parameter has no type to infer from |
|
|
1790
|
+
| `ctx` | no, for `signal` and the fields every task has | `signal`, `emit`, `on` and `run` are on every `ctx` |
|
|
1791
|
+
| result | no | inferred from the body |
|
|
1792
|
+
|
|
1793
|
+
A wrong payload at the call (`getUser(1)`) is an error. Annotate the parameters, not the variable: a type on the whole variable, such as `const handler: Task<string, Events> = …`, fixes the type of the function, and the result becomes `unknown`.
|
|
1794
|
+
|
|
1795
|
+
## Give `ctx` the fields of your bus
|
|
1796
|
+
|
|
1797
|
+
Types of a bus live on the bus. `contextPlugin({ store })` and `effectsPlugin({ race })` make `ctx.store` and `ctx.race` known to the handlers of **that bus**. A command made in another file does not see the bus, so its `ctx` has no `store`:
|
|
1798
|
+
|
|
1799
|
+
<!-- docs-check: skip - shows the error on purpose -->
|
|
1800
|
+
```ts
|
|
1801
|
+
export const getUser = createCommand("user/get", (id: string, ctx) => {
|
|
1802
|
+
ctx.store; // error: Property 'store' does not exist
|
|
1803
|
+
});
|
|
1804
|
+
```
|
|
1805
|
+
|
|
1806
|
+
At runtime the bus still gives the task its fields. Only the checker does not know it. Name the types of the bus once, in the file that creates it. `contextPlugin` takes the type of each field from the value you pass, so the event map holds only events. `EventsOf` reads the event map from the bus, with the fields that the plugins added. The code of this section is in [`examples/typescript-context`](../examples/typescript-context).
|
|
1807
|
+
|
|
1808
|
+
<!-- docs-check: skip - a file of examples/typescript-context, type-checked there -->
|
|
1809
|
+
```ts
|
|
1810
|
+
// bus.ts
|
|
1811
|
+
import { type Context, contextPlugin, createBus, type EventsOf } from "nanoeffects";
|
|
1812
|
+
import { modulePlugin } from "nanoeffects/actions";
|
|
1813
|
+
import { effectsPlugin, race } from "nanoeffects/effects";
|
|
1814
|
+
|
|
1815
|
+
// Events: event name → payload; nothing about ctx is written here
|
|
1816
|
+
type Events = { "user/loaded": User };
|
|
1817
|
+
|
|
1818
|
+
export const bus = createBus<Events>()
|
|
1819
|
+
.use(contextPlugin({ store })) // ctx.store, typed from the value `store`
|
|
1820
|
+
.use(effectsPlugin({ race })); // ctx.race; each plugin returns the same bus, typed with its fields
|
|
1821
|
+
|
|
1822
|
+
// the app of the bus: mounts modules and creates them with the types of the bus
|
|
1823
|
+
export const app = bus.use(modulePlugin()); // no modules yet: main.ts mounts them
|
|
1824
|
+
export const { createModule } = app; // createModule with the types of the bus
|
|
1825
|
+
|
|
1826
|
+
export type AppEvents = EventsOf<typeof bus>; // the event map and all fields of ctx
|
|
1827
|
+
export type AppContext = Context<AppEvents>; // ctx of a handler of this bus: has store and race
|
|
1828
|
+
```
|
|
1829
|
+
|
|
1830
|
+
| Type | What it is | Why you name it |
|
|
1831
|
+
| ---- | ---------- | --------------- |
|
|
1832
|
+
| `EventsOf<typeof bus>` | the event map of the bus, with the context fields of the plugins (and of `WithContext`, if you use it) | the source of `AppContext` |
|
|
1833
|
+
| `Context<AppEvents>` | the `ctx` that a handler of the bus gets | annotate the `ctx` of a handler given to `createCommand` |
|
|
1834
|
+
|
|
1835
|
+
These types change nothing at runtime. They only tell the checker what the bus already does.
|
|
1836
|
+
|
|
1837
|
+
Annotate `ctx` with `AppContext` where the handler reads a field of the bus, and create the module with the `createModule` that `bus.ts` exports, so its `m.on` handlers see the fields too:
|
|
1838
|
+
|
|
1839
|
+
<!-- docs-check: skip - a file of examples/typescript-context, type-checked there -->
|
|
1840
|
+
```ts
|
|
1841
|
+
// user.ts
|
|
1842
|
+
import { createCommand } from "nanoeffects/actions";
|
|
1843
|
+
import { type AppContext, createModule } from "./bus";
|
|
1844
|
+
|
|
1845
|
+
export const getUser = createCommand(
|
|
1846
|
+
"user/get",
|
|
1847
|
+
(id: string, { store, signal }: AppContext) => store.fetchUser(id, { signal }),
|
|
1848
|
+
);
|
|
1849
|
+
|
|
1850
|
+
// createModule from bus.ts: handlers of the module see the context fields of the bus
|
|
1851
|
+
export const userModule = createModule("user", (m) => {
|
|
1852
|
+
m.use(getUser);
|
|
1853
|
+
m.on("user/loaded", (user, { store }) => store.remember(user));
|
|
1854
|
+
});
|
|
1855
|
+
```
|
|
1856
|
+
|
|
1857
|
+
<!-- docs-check: skip - a file of examples/typescript-context, type-checked there -->
|
|
1858
|
+
```ts
|
|
1859
|
+
// main.ts: the modules are mounted here, because the module files need `app`
|
|
1860
|
+
import { app, bus } from "./bus";
|
|
1861
|
+
import { getUser, userModule } from "./user";
|
|
1862
|
+
|
|
1863
|
+
const unmount = app.mount(userModule); // starts the module; unmount() undoes it
|
|
1864
|
+
const user = bus.emit(getUser("1")); // Promise<User>
|
|
1865
|
+
```
|
|
1866
|
+
|
|
1867
|
+
`createModule` of the app only creates the module: it does not mount it, and it keeps no link to the bus. That is why the app in `bus.ts` starts without modules. `createModule` from `nanoeffects/actions` stays for a module made without a bus: there you write the event map yourself, `createModule<AppEvents>("user", …)`.
|
|
1868
|
+
|
|
1869
|
+
## Keep the contract apart from the code
|
|
1870
|
+
|
|
1871
|
+
When the file with the command must not import the code that implements it, make the command without a handler. Here you write the payload and the result once, as generics, and the handler goes to a module, where it needs no annotation:
|
|
1872
|
+
|
|
1873
|
+
<!-- docs-check: skip - fragment of the file above -->
|
|
1874
|
+
```ts
|
|
1875
|
+
export const getUser = createCommand<string, Promise<User>>("user/get"); // the contract
|
|
1876
|
+
|
|
1877
|
+
createModule("user", (m) => {
|
|
1878
|
+
// payload and result come from the command, ctx from the app: no annotation
|
|
1879
|
+
m.handle(getUser, (id, { store, signal }) => store.fetchUser(id, { signal }));
|
|
1880
|
+
});
|
|
1881
|
+
```
|
|
1882
|
+
|
|
1883
|
+
A wrong payload, a wrong field of `ctx` and a wrong result are errors.
|
|
1884
|
+
|
|
1885
|
+
## Check your setup
|
|
1886
|
+
|
|
1887
|
+
- **A field is missing on `ctx`.** The `ctx` of a handler given to `createCommand` is not annotated: add `: AppContext`. A handler written inline in `m.handle` or `m.on` of a module made with the `createModule` of `bus.ts` does not need it. The default `ctx` has no fields of your bus.
|
|
1888
|
+
- **`EventsOf<typeof bus>` is `never`.** `bus` is not a bus. `bus.use(commandsPlugin())` and `bus.use(modulePlugin(...))` return the registry or the app, not the bus: keep the bus in its own constant (`bus`, then `app = bus.use(modulePlugin())`).
|
|
1889
|
+
- **`effectsPlugin` is not in the type.** `use` must come before you read `typeof bus`. Chain `createBus(...).use(effectsPlugin(...))` in one binding.
|
|
1890
|
+
- **A policy loses the types.** It does not: `latest(handler)` keeps the payload, the result and the context of `handler`.
|
|
1891
|
+
- **Do not pass the context as a generic of `createCommand`.** It is the fourth generic, after the payload, the result and the type string, so you would have to write all four. Annotate `ctx` with `AppContext`, or put the handler in a module made with the `createModule` of `bus.ts`.
|
|
1892
|
+
|
|
1893
|
+
---
|
|
1894
|
+
Source: docs/migrating-from-saga.md
|
|
1895
|
+
|
|
1896
|
+
# Coming from redux-saga
|
|
1897
|
+
|
|
1898
|
+
Read this page if you know redux-saga and want to find the same job in nanoeffects. It maps the names, shows one saga rewritten, and lists what has no equivalent. It does not teach the library: start with the [Guide](guide.md).
|
|
1899
|
+
|
|
1900
|
+
## Find the equivalent
|
|
1901
|
+
|
|
1902
|
+
| redux-saga | nanoeffects |
|
|
1903
|
+
| ---------- | ----------- |
|
|
1904
|
+
| `takeEvery` | `bus.on(event, handler)` |
|
|
1905
|
+
| `takeLatest` | [`latest`](policies.md) |
|
|
1906
|
+
| `takeLeading` | [`leading`](policies.md) |
|
|
1907
|
+
| `debounce`, `throttle` | [`debounce`, `throttle`](policies.md) (same rules as lodash) |
|
|
1908
|
+
| `call(fn, x)` | `await ctx.run(task, x)` |
|
|
1909
|
+
| `fork(fn, x)` | `ctx.run(task, x)` without `await` |
|
|
1910
|
+
| `spawn(fn, x)` | `ctx.emit(event, x)` or `bus.emit(event, x)` |
|
|
1911
|
+
| `put(action)` | `ctx.emit(action)` |
|
|
1912
|
+
| `take(pattern)` | [`take(ctx, event, { predicate })`](effects.md) |
|
|
1913
|
+
| `select` | a [context field](context.md), such as `store` |
|
|
1914
|
+
| `all`, `race` | [`all`, `race`](effects.md) |
|
|
1915
|
+
| `retry`, `delay` | [`retry`, `delay`](effects.md) |
|
|
1916
|
+
| `cancel(task)` | `ctx.abort()` on the context, or a newer call under `latest` |
|
|
1917
|
+
| `eventChannel` | `bus.emitter(action)` as the callback of the subscription |
|
|
1918
|
+
| `actionChannel` | [`queue`](policies.md) and the other policies |
|
|
1919
|
+
|
|
1920
|
+
## Rewrite a saga
|
|
1921
|
+
|
|
1922
|
+
A saga that searches on every new query, cancels the older request, and retries a failed one:
|
|
1923
|
+
|
|
1924
|
+
<!-- docs-check: skip - redux-saga code, not nanoeffects -->
|
|
1925
|
+
```js
|
|
1926
|
+
// redux-saga
|
|
1927
|
+
function* search({ payload }) {
|
|
1928
|
+
const items = yield retry(3, 1000, api.search, payload);
|
|
1929
|
+
yield put(found(items));
|
|
1930
|
+
}
|
|
1931
|
+
function* watchSearch() {
|
|
1932
|
+
yield takeLatest("search", search);
|
|
1933
|
+
}
|
|
1934
|
+
```
|
|
1935
|
+
|
|
1936
|
+
The same job in nanoeffects:
|
|
1937
|
+
|
|
1938
|
+
```js
|
|
1939
|
+
import { createBus } from "nanoeffects";
|
|
1940
|
+
import { retry } from "nanoeffects/effects";
|
|
1941
|
+
import { latest } from "nanoeffects/policies";
|
|
1942
|
+
|
|
1943
|
+
const bus = createBus(); // the bus: events and handlers
|
|
1944
|
+
|
|
1945
|
+
const search = (query, { signal }) => api.search(query, { signal });
|
|
1946
|
+
|
|
1947
|
+
bus.on(
|
|
1948
|
+
"search",
|
|
1949
|
+
latest(async (query, ctx) => {
|
|
1950
|
+
// latest: a newer "search" aborts this run; ctx.signal is aborted
|
|
1951
|
+
const load = retry(search, { times: 3, delay: 1000 }); // retry: up to 3 attempts, 1 s apart
|
|
1952
|
+
const items = await ctx.run(load, query); // child task, aborted with this handler
|
|
1953
|
+
ctx.emit("found", items); // like put
|
|
1954
|
+
}),
|
|
1955
|
+
);
|
|
1956
|
+
```
|
|
1957
|
+
|
|
1958
|
+
Tasks are plain `async` functions. `ctx.signal` replaces `cancel`: pass it to `fetch` or to your client, and the request stops.
|
|
1959
|
+
|
|
1960
|
+
## Start a task without waiting
|
|
1961
|
+
|
|
1962
|
+
`fork` and `spawn` differ in what happens when the caller is aborted. Pick by that.
|
|
1963
|
+
|
|
1964
|
+
```js
|
|
1965
|
+
import { createBus } from "nanoeffects";
|
|
1966
|
+
|
|
1967
|
+
const bus = createBus(); // the bus: events and handlers
|
|
1968
|
+
|
|
1969
|
+
const loadSidebar = (_, { signal }) => api.sidebar({ signal });
|
|
1970
|
+
|
|
1971
|
+
bus.on("page-opened", async (_, ctx) => {
|
|
1972
|
+
// like fork: a child of this task, aborted with it
|
|
1973
|
+
const warmUp = ctx.run(loadSidebar);
|
|
1974
|
+
warmUp.catch(console.error); // nobody awaits it, so handle the error here
|
|
1975
|
+
|
|
1976
|
+
// like spawn: a new task, not stopped when this one is aborted
|
|
1977
|
+
ctx.emit("analytics", { page: "home" });
|
|
1978
|
+
});
|
|
1979
|
+
```
|
|
1980
|
+
|
|
1981
|
+
## What is different
|
|
1982
|
+
|
|
1983
|
+
- **Errors go up only through `await`.** `await ctx.run(task)` and `all(ctx, …)` throw the error of a child to the caller. A child that nobody awaits does not abort its parent, as a forked child does in redux-saga: handle it yourself, as above. See [Errors](errors.md).
|
|
1984
|
+
- **Abort goes down.** Aborting a task aborts its children. A child does not abort its siblings.
|
|
1985
|
+
- **Effects are not data.** `yield call(fn)` returns a description that a test can compare. Here `await` calls the function. Test with the real handler and fake timers, as the tests of this library do.
|
|
1986
|
+
- **No generators.** Sequential flows are `async` functions with `await take(ctx, event)`.
|
|
1987
|
+
- **No `END` of a channel.** A task stops when its signal aborts.
|
|
1988
|
+
- **One payload.** A task has one argument, so pass an object where a saga passes several.
|
|
1989
|
+
|
|
1990
|
+
---
|
|
1991
|
+
Source: docs/migrating-from-effector.md
|
|
1992
|
+
|
|
1993
|
+
# Coming from Effector
|
|
1994
|
+
|
|
1995
|
+
Read this page if your project uses Effector and you want to know where nanoeffects fits. They overlap in one place: events that start effects. Effector also holds state; nanoeffects does not. It maps the names, shows one effect both ways, and lists what has no equivalent.
|
|
1996
|
+
|
|
1997
|
+
## Split the work
|
|
1998
|
+
|
|
1999
|
+
| Job | Use |
|
|
2000
|
+
| --- | --- |
|
|
2001
|
+
| Hold state (`createStore`, `combine`, `restore`) | Effector, or any store: [Stores](stores.md) |
|
|
2002
|
+
| Derive state from events (`.on`, `sample` into a store) | Effector, or any store |
|
|
2003
|
+
| Run a request when an event happens, cancel, retry, time out, trace | nanoeffects |
|
|
2004
|
+
|
|
2005
|
+
nanoeffects has no store. `createStore` can be replaced by any store you like, for example Zustand: a store and its `set` do the job of `createStore` and `.on`. [Stores](stores.md) shows how each of Zustand, Redux, MobX, Jotai and others calls the bus. If you keep Effector, use nanoeffects only for the effects that `createEffect` runs with little control over overlapping calls.
|
|
2006
|
+
|
|
2007
|
+
## Find the equivalent
|
|
2008
|
+
|
|
2009
|
+
| Effector | nanoeffects |
|
|
2010
|
+
| -------- | ----------- |
|
|
2011
|
+
| `createEvent()` | an event name on the bus, or [`createAction`](actions.md) |
|
|
2012
|
+
| event call `loaded(user)` | `bus.emit("loaded", user)` |
|
|
2013
|
+
| `createEffect(handler)` | a handler: `bus.on(event, handler)` |
|
|
2014
|
+
| effect with `done` and `fail` | [`createJob`](actions.md): emits the success and fail actions |
|
|
2015
|
+
| `effect.pending` | not provided: keep a flag in your store, or read it from [Tracing](tracing.md) |
|
|
2016
|
+
| `sample({ clock, fn, target })` | `bus.on(clock, handler)` and `ctx.emit(target, …)` |
|
|
2017
|
+
| `sample({ source: $store, clock })` | read the store from a [context field](context.md) in the handler |
|
|
2018
|
+
| `sample({ filter })` | an `if` in the handler |
|
|
2019
|
+
| `debounce`, `throttle` from patronum | [`debounce`, `throttle`](policies.md) (same rules as lodash, checked in tests) |
|
|
2020
|
+
| take only the last effect call | [`latest`](policies.md) |
|
|
2021
|
+
| `attach` | a [context field](context.md): the handler reads it from `ctx` |
|
|
2022
|
+
| `fork()` and scope | one `createBus()` per scope |
|
|
2023
|
+
| `createStore`, `combine`, `restore` | any store, such as Zustand, Redux, MobX or Jotai: see [Stores](stores.md) |
|
|
2024
|
+
| `watch`, `createWatch` | `bus.on(event, handler)` |
|
|
2025
|
+
| `event.prepend(fn)` | a function that you write, `(event) => ({ key, value })`; the bus needs nothing |
|
|
2026
|
+
| `useStoreMap({ store, keys, fn })` | a selector of your store; with `useState` the component keeps the value |
|
|
2027
|
+
| `event.watch((e) => e.preventDefault())` | call `event.preventDefault()` in the `onSubmit` handler |
|
|
2028
|
+
|
|
2029
|
+
## Rewrite an effect
|
|
2030
|
+
|
|
2031
|
+
A search that runs on every query, and reacts to the result:
|
|
2032
|
+
|
|
2033
|
+
<!-- docs-check: skip - needs effector, which the project does not depend on -->
|
|
2034
|
+
```js
|
|
2035
|
+
// Effector
|
|
2036
|
+
import { createEffect, createEvent, createStore, sample } from "effector";
|
|
2037
|
+
|
|
2038
|
+
const queryChanged = createEvent();
|
|
2039
|
+
const searchFx = createEffect((query) => api.search(query));
|
|
2040
|
+
const $items = createStore([]).on(searchFx.doneData, (_, items) => items);
|
|
2041
|
+
|
|
2042
|
+
sample({ clock: queryChanged, target: searchFx });
|
|
2043
|
+
```
|
|
2044
|
+
|
|
2045
|
+
If the user types fast, several `searchFx` calls overlap, and the old ones are not cancelled. The same job in nanoeffects, with the old request cancelled:
|
|
2046
|
+
|
|
2047
|
+
```js
|
|
2048
|
+
import { createBus } from "nanoeffects";
|
|
2049
|
+
import { debounce, latest } from "nanoeffects/policies";
|
|
2050
|
+
|
|
2051
|
+
const bus = createBus(); // the bus: events and handlers
|
|
2052
|
+
|
|
2053
|
+
// latest: a newer query aborts the request in flight
|
|
2054
|
+
const search = latest(async (query, { signal }) => {
|
|
2055
|
+
const items = await api.search(query, { signal });
|
|
2056
|
+
bus.emit("found", items);
|
|
2057
|
+
});
|
|
2058
|
+
|
|
2059
|
+
bus.on("query-changed", debounce(300, search)); // debounce: run after 300 ms of quiet
|
|
2060
|
+
```
|
|
2061
|
+
|
|
2062
|
+
The store (`$items`) is not here. Keep it in Effector and update it from the `found` event, or replace it with a store of your choice. The next example does that with Zustand.
|
|
2063
|
+
|
|
2064
|
+
### Keep the state in Zustand
|
|
2065
|
+
|
|
2066
|
+
Without Effector, something must hold `$items`. A Zustand store does it: `create` replaces `createStore`, and `setState` replaces `.on(searchFx.doneData, …)`. The handler writes the result to the store directly, so the `found` event is not needed. A component reads the store with a selector, as `useUnit($items)` did.
|
|
2067
|
+
|
|
2068
|
+
```jsx
|
|
2069
|
+
// search-zustand.jsx
|
|
2070
|
+
import { create } from "zustand";
|
|
2071
|
+
import { createBus } from "nanoeffects";
|
|
2072
|
+
import { debounce, latest } from "nanoeffects/policies";
|
|
2073
|
+
|
|
2074
|
+
// create — Zustand: the store replaces $items
|
|
2075
|
+
const useSearchStore = create(() => ({ items: [] }));
|
|
2076
|
+
|
|
2077
|
+
const bus = createBus(); // the bus: events and handlers
|
|
2078
|
+
|
|
2079
|
+
// latest — a newer query aborts the request in flight; debounce — wait 300 ms of quiet
|
|
2080
|
+
const search = latest(async (query, { signal }) => {
|
|
2081
|
+
const items = await api.search(query, { signal });
|
|
2082
|
+
useSearchStore.setState({ items }); // Zustand: replaces .on(searchFx.doneData, …)
|
|
2083
|
+
});
|
|
2084
|
+
|
|
2085
|
+
bus.on("query-changed", debounce(300, search));
|
|
2086
|
+
|
|
2087
|
+
export function Search() {
|
|
2088
|
+
const items = useSearchStore((state) => state.items); // Zustand: a selector reads items
|
|
2089
|
+
|
|
2090
|
+
return (
|
|
2091
|
+
<div>
|
|
2092
|
+
<input onChange={(event) => bus.emit("query-changed", event.target.value)} />
|
|
2093
|
+
<ul>
|
|
2094
|
+
{items.map((item) => (
|
|
2095
|
+
<li key={item.id}>{item.title}</li>
|
|
2096
|
+
))}
|
|
2097
|
+
</ul>
|
|
2098
|
+
</div>
|
|
2099
|
+
);
|
|
2100
|
+
}
|
|
2101
|
+
```
|
|
2102
|
+
|
|
2103
|
+
`bus.emit("query-changed", …)` in `onChange` does the work of calling the `queryChanged` event. To also start a task when the store changes, see [Zustand in Stores](stores.md#zustand).
|
|
2104
|
+
|
|
2105
|
+
## Rewrite a form
|
|
2106
|
+
|
|
2107
|
+
The form from the [Forms recipe of the Effector docs](https://effector.dev/en/recipes/react/forms/) has five parts: an event for a change of a field (`fieldUpdate`), a store for the values (`$form`), an event for the submit (`formSubmitted`), an effect that sends them (`sendFormFx`), and a `sample` that reads the store when the form is submitted. Four of them keep state or wire units together. Only the effect has a counterpart in nanoeffects.
|
|
2108
|
+
|
|
2109
|
+
<!-- docs-check: skip - needs effector, which the project does not depend on -->
|
|
2110
|
+
```jsx
|
|
2111
|
+
// Effector (adapted from the Effector docs)
|
|
2112
|
+
import { createEffect, createEvent, createStore, sample } from "effector";
|
|
2113
|
+
import { useStoreMap } from "effector-react";
|
|
2114
|
+
|
|
2115
|
+
const formSubmitted = createEvent();
|
|
2116
|
+
const fieldUpdate = createEvent();
|
|
2117
|
+
|
|
2118
|
+
const sendFormFx = createEffect((params) => {
|
|
2119
|
+
console.log(params);
|
|
2120
|
+
});
|
|
2121
|
+
|
|
2122
|
+
const $form = createStore({});
|
|
2123
|
+
$form.on(fieldUpdate, (form, { key, value }) => ({ ...form, [key]: value }));
|
|
2124
|
+
|
|
2125
|
+
// sample: when the form is submitted, read $form and run the effect with it
|
|
2126
|
+
sample({ clock: formSubmitted, source: $form, target: sendFormFx });
|
|
2127
|
+
|
|
2128
|
+
// prepend: turn a DOM event into the { key, value } that fieldUpdate takes
|
|
2129
|
+
const handleChange = fieldUpdate.prepend((event) => ({
|
|
2130
|
+
key: event.target.name,
|
|
2131
|
+
value: event.target.value,
|
|
2132
|
+
}));
|
|
2133
|
+
|
|
2134
|
+
const Field = ({ name, type, label }) => {
|
|
2135
|
+
const value = useStoreMap({
|
|
2136
|
+
store: $form,
|
|
2137
|
+
keys: [name],
|
|
2138
|
+
fn: (values) => values[name] ?? "",
|
|
2139
|
+
});
|
|
2140
|
+
|
|
2141
|
+
return (
|
|
2142
|
+
<div>
|
|
2143
|
+
{label} <input name={name} type={type} value={value} onChange={handleChange} />
|
|
2144
|
+
</div>
|
|
2145
|
+
);
|
|
2146
|
+
};
|
|
2147
|
+
|
|
2148
|
+
const App = () => (
|
|
2149
|
+
<form onSubmit={formSubmitted}>
|
|
2150
|
+
<Field name="login" label="Login" />
|
|
2151
|
+
<Field name="password" type="password" label="Password" />
|
|
2152
|
+
<button type="submit">Submit!</button>
|
|
2153
|
+
</form>
|
|
2154
|
+
);
|
|
2155
|
+
|
|
2156
|
+
formSubmitted.watch((event) => {
|
|
2157
|
+
event.preventDefault();
|
|
2158
|
+
});
|
|
2159
|
+
```
|
|
2160
|
+
|
|
2161
|
+
The same form with nanoeffects. The values live in React state: nanoeffects holds none, and any store of [Stores](stores.md) works the same way. The effect becomes a handler of the submit event. The values travel in the event, so no `sample` and no `source` are needed. First the bus, in its own file:
|
|
2162
|
+
|
|
2163
|
+
```js
|
|
2164
|
+
// form-bus.js
|
|
2165
|
+
import { createBus } from "nanoeffects";
|
|
2166
|
+
|
|
2167
|
+
export const bus = createBus(); // the bus: events and their handlers
|
|
2168
|
+
|
|
2169
|
+
// bus.on — the job of sendFormFx: runs when the form is submitted
|
|
2170
|
+
bus.on("form/submitted", (values) => {
|
|
2171
|
+
console.log(values);
|
|
2172
|
+
});
|
|
2173
|
+
```
|
|
2174
|
+
|
|
2175
|
+
Then the form. `useState` does the work of `$form`, `fieldUpdate` and `prepend`; the event handler does the work of the `watch` that called `preventDefault`:
|
|
2176
|
+
|
|
2177
|
+
```jsx
|
|
2178
|
+
// App.jsx
|
|
2179
|
+
import { useState } from "react";
|
|
2180
|
+
import { bus } from "./form-bus"; // the bus of the previous snippet
|
|
2181
|
+
|
|
2182
|
+
function Field({ name, type, label, value, onChange }) {
|
|
2183
|
+
return (
|
|
2184
|
+
<div>
|
|
2185
|
+
{label} <input name={name} type={type} value={value} onChange={onChange} />
|
|
2186
|
+
</div>
|
|
2187
|
+
);
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
export function App() {
|
|
2191
|
+
const [form, setForm] = useState({}); // the values of the form, as $form held them
|
|
2192
|
+
|
|
2193
|
+
// one handler for every field, as handleChange was
|
|
2194
|
+
function handleChange(event) {
|
|
2195
|
+
const { name, value } = event.target;
|
|
2196
|
+
setForm((current) => ({ ...current, [name]: value }));
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
function handleSubmit(event) {
|
|
2200
|
+
event.preventDefault(); // the page must not reload; no separate watcher is needed
|
|
2201
|
+
bus.emit("form/submitted", form); // bus.emit — the values go in the event
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
return (
|
|
2205
|
+
<form onSubmit={handleSubmit}>
|
|
2206
|
+
<Field name="login" label="Login" value={form.login ?? ""} onChange={handleChange} />
|
|
2207
|
+
<Field
|
|
2208
|
+
name="password"
|
|
2209
|
+
type="password"
|
|
2210
|
+
label="Password"
|
|
2211
|
+
value={form.password ?? ""}
|
|
2212
|
+
onChange={handleChange}
|
|
2213
|
+
/>
|
|
2214
|
+
<button type="submit">Submit!</button>
|
|
2215
|
+
</form>
|
|
2216
|
+
);
|
|
2217
|
+
}
|
|
2218
|
+
```
|
|
2219
|
+
|
|
2220
|
+
`useStoreMap` re-renders only the field whose value changed. Here every field re-renders with the form, which is fine for a small form. If it matters, use the selector of your store (Zustand and Jotai have one).
|
|
2221
|
+
|
|
2222
|
+
If the values live in a store outside React, give the store to the handlers with `createBus({ context: { store } })` ([Context fields](context.md)) and read it in the handler, as `sample` read `$form`.
|
|
2223
|
+
|
|
2224
|
+
### Replace the log with a request
|
|
2225
|
+
|
|
2226
|
+
`sendFormFx` only logs. A real sign-up sends a request, and then the double click and the network matter. `leading` drops a second submit while the first one runs, and `ctx.signal` lets the request be cancelled:
|
|
2227
|
+
|
|
2228
|
+
```js
|
|
2229
|
+
import { createBus } from "nanoeffects";
|
|
2230
|
+
import { leading } from "nanoeffects/policies";
|
|
2231
|
+
|
|
2232
|
+
const bus = createBus(); // the bus: events and their handlers
|
|
2233
|
+
|
|
2234
|
+
// leading — a second submit is ignored while the request is running
|
|
2235
|
+
bus.on(
|
|
2236
|
+
"form/submitted",
|
|
2237
|
+
leading(async (values, ctx) => {
|
|
2238
|
+
// api — your client; ctx.signal lets the request be cancelled
|
|
2239
|
+
await api.signUp(values, { signal: ctx.signal });
|
|
2240
|
+
}),
|
|
2241
|
+
);
|
|
2242
|
+
```
|
|
2243
|
+
|
|
2244
|
+
Retrying a failed network request, and cancelling when the form closes, are in [Build a sign-up form in React](react.md).
|
|
2245
|
+
|
|
2246
|
+
## Use both in one app
|
|
2247
|
+
|
|
2248
|
+
Keep the Effector stores, and run the effects on the bus. A store calls the bus through `bus.emitter`, and the handler sends the result back as an event that the store listens to.
|
|
2249
|
+
|
|
2250
|
+
<!-- docs-check: skip - needs effector, which the project does not depend on -->
|
|
2251
|
+
```js
|
|
2252
|
+
import { createEvent, createStore } from "effector";
|
|
2253
|
+
import { createBus } from "nanoeffects";
|
|
2254
|
+
import { createAction, emitterPlugin } from "nanoeffects/actions";
|
|
2255
|
+
import { latest } from "nanoeffects/policies";
|
|
2256
|
+
|
|
2257
|
+
const bus = createBus().use(emitterPlugin()); // adds bus.emitter
|
|
2258
|
+
|
|
2259
|
+
const queryChanged = createAction("query/changed"); // a plain action creator
|
|
2260
|
+
|
|
2261
|
+
const found = createEvent(); // Effector: the result comes back as an event
|
|
2262
|
+
const $items = createStore([]).on(found, (_, items) => items);
|
|
2263
|
+
const $query = createStore("");
|
|
2264
|
+
|
|
2265
|
+
// watch calls back on every change of the store; bus.emitter turns it into an emit
|
|
2266
|
+
$query.watch(bus.emitter(queryChanged));
|
|
2267
|
+
|
|
2268
|
+
bus.on(
|
|
2269
|
+
queryChanged,
|
|
2270
|
+
latest(async (query, { signal }) => {
|
|
2271
|
+
const items = await api.search(query, { signal });
|
|
2272
|
+
found(items); // back to Effector
|
|
2273
|
+
}),
|
|
2274
|
+
);
|
|
2275
|
+
```
|
|
2276
|
+
|
|
2277
|
+
## What is different
|
|
2278
|
+
|
|
2279
|
+
- **No state.** There is no `createStore`, `combine`, `restore`, `$store.map`. The bus holds listeners only. Take a store such as Zustand for state ([Stores](stores.md)).
|
|
2280
|
+
- **No `pending`.** A running task is visible through [Tracing](tracing.md), not through a store. A flag in your store set at the start and the end of the handler does the job.
|
|
2281
|
+
- **Cancellation is the default tool.** An Effector effect runs every call to the end. Here `latest` and `ctx.signal` stop what is no longer needed.
|
|
2282
|
+
- **Events are matched by name, not by identity.** An event is a string or a `createAction` creator, not a unit that you import and call. Two modules agree on the name.
|
|
2283
|
+
- **No graph.** `sample` and `forward` build a static graph. A bus subscribes handlers at runtime and the call order is the order of `on`.
|
|
2284
|
+
- **The event carries the data.** `sample({ source })` reads a store at the moment of the clock. Here the sender puts the values in the event (`bus.emit("form/submitted", form)`), or the handler reads a store from a [context field](context.md).
|
|
2285
|
+
- **Scopes are buses.** `fork()` becomes a `createBus()` per scope (a test, a request on the server).
|
|
2286
|
+
|
|
2287
|
+
---
|
|
2288
|
+
Source: docs/migrating-from-rxjs.md
|
|
2289
|
+
|
|
2290
|
+
# Use nanoeffects next to RxJS
|
|
2291
|
+
|
|
2292
|
+
Read this page if your project uses RxJS and you wonder what nanoeffects takes over. It does not replace RxJS. The two split the work: RxJS shapes streams of values, nanoeffects runs the side effects that events start. It maps the operators, shows one search box both ways, and lists what stays in RxJS.
|
|
2293
|
+
|
|
2294
|
+
## Split the work
|
|
2295
|
+
|
|
2296
|
+
| Job | Use |
|
|
2297
|
+
| --- | --- |
|
|
2298
|
+
| Start a request when something happens, cancel it when a newer one arrives, retry it, time it out | nanoeffects |
|
|
2299
|
+
| Know which task started which, log and trace it | nanoeffects ([Tracing](tracing.md)) |
|
|
2300
|
+
| Combine several streams, keep a running value, buffer, window, merge over time | RxJS |
|
|
2301
|
+
| Work with a stream as a value (`map`, `scan`, `combineLatest`) | RxJS |
|
|
2302
|
+
|
|
2303
|
+
A handler in nanoeffects is one function per event. A pipeline in RxJS is a chain of operators over a stream. If your pipeline is a few operators that end in `switchMap` and a request, it fits a handler with a policy and is shorter. If it combines streams, it stays.
|
|
2304
|
+
|
|
2305
|
+
## Find the equivalent
|
|
2306
|
+
|
|
2307
|
+
| RxJS | nanoeffects |
|
|
2308
|
+
| ---- | ----------- |
|
|
2309
|
+
| `mergeMap` | `bus.on(event, handler)`: every event starts a task |
|
|
2310
|
+
| `switchMap` | [`latest`](policies.md) |
|
|
2311
|
+
| `exhaustMap` | [`leading`](policies.md) |
|
|
2312
|
+
| `concatMap` | [`queue`](policies.md) |
|
|
2313
|
+
| `debounceTime` | [`debounce`](policies.md) |
|
|
2314
|
+
| `throttleTime` | [`throttle`](policies.md) |
|
|
2315
|
+
| `groupBy` + `switchMap` | `latest(handler, { key })` |
|
|
2316
|
+
| `retry`, `retryWhen` | [`retry`, `backoff`](effects.md) |
|
|
2317
|
+
| `timeout` | [`timeout`](effects.md) |
|
|
2318
|
+
| `interval` + `switchMap` | [`poll`](effects.md) |
|
|
2319
|
+
| `takeUntil(stop$)` | `ctx.signal`, and [`take`](effects.md) to wait for the stop event |
|
|
2320
|
+
| `forkJoin` | [`all`](effects.md) |
|
|
2321
|
+
| `race` | [`race`](effects.md) |
|
|
2322
|
+
| `firstValueFrom(fromEvent(…))` | [`take(ctx, event)`](effects.md) |
|
|
2323
|
+
| `Subject` | `bus.emit` and `bus.on` |
|
|
2324
|
+
| `map`, `filter`, `scan`, `combineLatest`, `buffer`, `window`, `share` | stay in RxJS |
|
|
2325
|
+
| `distinctUntilChanged` | a check in the handler, or in the store subscription (see [Stores](stores.md)) |
|
|
2326
|
+
|
|
2327
|
+
A policy and an operator differ in one thing. `switchMap` drops the old inner stream. `latest` aborts the old task through its `signal`, so a `fetch` that got the signal stops. Pass the signal on, or the work goes on in the background.
|
|
2328
|
+
|
|
2329
|
+
## Rewrite a search box
|
|
2330
|
+
|
|
2331
|
+
The same job in RxJS: wait for a pause, skip repeats, cancel the old request.
|
|
2332
|
+
|
|
2333
|
+
<!-- docs-check: skip - needs rxjs, which the project does not depend on -->
|
|
2334
|
+
```js
|
|
2335
|
+
// RxJS
|
|
2336
|
+
import { fromEvent } from "rxjs";
|
|
2337
|
+
import { debounceTime, distinctUntilChanged, map, switchMap } from "rxjs/operators";
|
|
2338
|
+
|
|
2339
|
+
fromEvent(input, "input")
|
|
2340
|
+
.pipe(
|
|
2341
|
+
map((event) => event.target.value),
|
|
2342
|
+
debounceTime(300),
|
|
2343
|
+
distinctUntilChanged(),
|
|
2344
|
+
switchMap((query) => api.searchStream(query)),
|
|
2345
|
+
)
|
|
2346
|
+
.subscribe((items) => render(items));
|
|
2347
|
+
```
|
|
2348
|
+
|
|
2349
|
+
The same job in nanoeffects:
|
|
2350
|
+
|
|
2351
|
+
```js
|
|
2352
|
+
import { createBus } from "nanoeffects";
|
|
2353
|
+
import { debounce, latest } from "nanoeffects/policies";
|
|
2354
|
+
|
|
2355
|
+
const bus = createBus(); // the bus: events and handlers
|
|
2356
|
+
|
|
2357
|
+
// latest: a newer search aborts the request in flight
|
|
2358
|
+
const searchLatest = latest(async (query, { signal }) => {
|
|
2359
|
+
const items = await api.search(query, { signal });
|
|
2360
|
+
bus.emit("results", items);
|
|
2361
|
+
});
|
|
2362
|
+
|
|
2363
|
+
// debounce: run after 300 ms without a new "typed"
|
|
2364
|
+
bus.on("typed", debounce(300, searchLatest));
|
|
2365
|
+
|
|
2366
|
+
bus.on("results", (items) => render(items));
|
|
2367
|
+
|
|
2368
|
+
const input = document.querySelector("#search"); // the search box
|
|
2369
|
+
input.addEventListener("input", (event) => bus.emit("typed", event.target.value));
|
|
2370
|
+
```
|
|
2371
|
+
|
|
2372
|
+
The skip of repeated values is not here. Add `if (query === lastQuery) return;` to the handler, or keep that one step in RxJS (next section).
|
|
2373
|
+
|
|
2374
|
+
## Use both in one app
|
|
2375
|
+
|
|
2376
|
+
Keep RxJS where a stream is how you think, and let events cross to the bus in either direction.
|
|
2377
|
+
|
|
2378
|
+
**Bus to RxJS.** `createObserve(bus)` gives an Observable of an event ([Observables](observable.md)).
|
|
2379
|
+
|
|
2380
|
+
**RxJS to bus.** `bus.emitter(action)` returns a callback that emits the action, so it is the argument of `subscribe` ([Stores](stores.md) has the plugin).
|
|
2381
|
+
|
|
2382
|
+
<!-- docs-check: skip - needs rxjs, which the project does not depend on -->
|
|
2383
|
+
```js
|
|
2384
|
+
import { combineLatest } from "rxjs";
|
|
2385
|
+
import { createBus } from "nanoeffects";
|
|
2386
|
+
import { createAction, emitterPlugin } from "nanoeffects/actions";
|
|
2387
|
+
import { latest } from "nanoeffects/policies";
|
|
2388
|
+
|
|
2389
|
+
const bus = createBus().use(emitterPlugin()); // adds bus.emitter
|
|
2390
|
+
|
|
2391
|
+
const filtersChanged = createAction("filters/changed"); // a plain action creator
|
|
2392
|
+
|
|
2393
|
+
// RxJS: combine two streams into one value; nanoeffects does not do that
|
|
2394
|
+
const subscription = combineLatest([query$, category$]).subscribe(
|
|
2395
|
+
bus.emitter(filtersChanged), // calls bus.emit(filtersChanged(value))
|
|
2396
|
+
);
|
|
2397
|
+
|
|
2398
|
+
// nanoeffects: one cancellable request per change of the filters
|
|
2399
|
+
bus.on(
|
|
2400
|
+
filtersChanged,
|
|
2401
|
+
latest(async ([query, category], { signal }) => {
|
|
2402
|
+
const items = await api.search(query, category, { signal });
|
|
2403
|
+
bus.emit("results", items);
|
|
2404
|
+
}),
|
|
2405
|
+
);
|
|
2406
|
+
```
|
|
2407
|
+
|
|
2408
|
+
The subscription is not tied to a task. Call `subscription.unsubscribe()` when the screen goes away.
|
|
2409
|
+
|
|
2410
|
+
## What is different
|
|
2411
|
+
|
|
2412
|
+
- **No streams of values.** A handler gets one payload and runs a task. There is no `pipe`, no operator for the values that follow.
|
|
2413
|
+
- **Abort replaces unsubscribe for work in flight.** A stopped task is stopped through its `signal`. Pass it to `fetch` or to the client.
|
|
2414
|
+
- **Errors are not part of the stream.** A failed task rejects its own promise. Without `onError` on the bus nobody else hears it ([Errors](errors.md)).
|
|
2415
|
+
- **No completion.** An event bus does not end. A task ends when its work ends or its signal aborts.
|
|
2416
|
+
- **No marble tests.** Test with the real handler and fake timers.
|
|
2417
|
+
|
|
2418
|
+
---
|
|
2419
|
+
Source: docs/semantics.md
|
|
2420
|
+
|
|
2421
|
+
# Semantics
|
|
2422
|
+
|
|
2423
|
+
Guarantees and sharp edges. Read when something surprises you; not required to start.
|
|
2424
|
+
|
|
2425
|
+
- **An aborted task delivers no result.** If the task aborts before a promise settles, the promise rejects with the abort reason, even if the value was just produced in the same tick. `latest`, `debounce`, `all`, `race`, `take`, and the rest follow this rule.
|
|
2426
|
+
- **`emit` is a plain, synchronous loop** (see [Errors](errors.md)). A `ctx.emit` inside a handler is the same call: without `onError`, an error of a nested handler is thrown into the handler that emitted; with `onError` it goes there and `ctx.emit` does not throw.
|
|
2427
|
+
- **Rejections of async handlers** are not handled for you. Without `onError` they are unhandled rejections, which end a Node process by default. Use `onError` for async handlers.
|
|
2428
|
+
- **`leading` releases its lock when the task aborts**, not only when `fn` settles. A `fn` that ignores the abort signal can then overlap with the next run — pass `ctx.signal` to what `fn` does.
|
|
2429
|
+
- **Effects start their function synchronously**, so an event emitted right after the call reaches a `take` inside it.
|
|
2430
|
+
|
|
2431
|
+
---
|
|
2432
|
+
Source: docs/tracing.md
|
|
2433
|
+
|
|
2434
|
+
# Trace tasks
|
|
2435
|
+
|
|
2436
|
+
Reference for long-lived apps that need to observe task phases, Redux DevTools, or a build without tracing. Not part of onboarding.
|
|
2437
|
+
|
|
2438
|
+
## Attach a tracer
|
|
2439
|
+
|
|
2440
|
+
Pass `trace` to `createBus`. Make it with `createTracer` from `nanoeffects/trace`, which takes a logger (a function) or an array of them. The tracing code lives in that entry, so a bus without `trace` does not carry it.
|
|
2441
|
+
|
|
2442
|
+
```js
|
|
2443
|
+
import { createBus } from "nanoeffects";
|
|
2444
|
+
import { createTracer } from "nanoeffects/trace";
|
|
2445
|
+
|
|
2446
|
+
const bus = createBus({
|
|
2447
|
+
// createTracer — observe every task phase
|
|
2448
|
+
trace: createTracer((event) => console.log(event)),
|
|
2449
|
+
});
|
|
2450
|
+
```
|
|
2451
|
+
|
|
2452
|
+
Each `TraceEvent` has:
|
|
2453
|
+
|
|
2454
|
+
| Field | Meaning |
|
|
2455
|
+
| ----- | ------- |
|
|
2456
|
+
| `id` | Unique task id within the bus |
|
|
2457
|
+
| `parent` | Id of the task that started this one (absent for handler tasks) |
|
|
2458
|
+
| `type` | Event type the task works on |
|
|
2459
|
+
| `effect` | `latest`, `debounce`, `throttle`, `leading`, `run`, `all`, `race`, … |
|
|
2460
|
+
| `phase` | `start`, `done`, `error`, `cancel`, or `drop` |
|
|
2461
|
+
| `payload` | Event payload |
|
|
2462
|
+
| `error` | Thrown value, only for `error` |
|
|
2463
|
+
|
|
2464
|
+
Phases:
|
|
2465
|
+
|
|
2466
|
+
- `start` — the work began.
|
|
2467
|
+
- `done` / `error` — it finished or threw.
|
|
2468
|
+
- `cancel` — aborted after it started (superseded by `latest`, parent aborted, lost a `race`).
|
|
2469
|
+
- `drop` — never started (`leading` while busy, a `throttle` call replaced by a newer one, a `debounce` call replaced before its delay ended).
|
|
2470
|
+
|
|
2471
|
+
`all` and `race` are traced when you pass them the handler `ctx`: the group is one task; every starter is a child `run` task.
|
|
2472
|
+
|
|
2473
|
+
```js
|
|
2474
|
+
import { createBus } from "nanoeffects";
|
|
2475
|
+
import { all } from "nanoeffects/effects";
|
|
2476
|
+
|
|
2477
|
+
const bus = createBus();
|
|
2478
|
+
|
|
2479
|
+
bus.on("load", async (id, ctx) => {
|
|
2480
|
+
await all(ctx, {
|
|
2481
|
+
user: () => fetchUser(id),
|
|
2482
|
+
posts: () => fetchPosts(id),
|
|
2483
|
+
});
|
|
2484
|
+
});
|
|
2485
|
+
```
|
|
2486
|
+
|
|
2487
|
+
## Use built-in loggers
|
|
2488
|
+
|
|
2489
|
+
A logger is `(event) => void`. `createTracer` takes one logger or an array. A logger that throws is reported to `console.error` and never breaks the task or the other loggers.
|
|
2490
|
+
|
|
2491
|
+
```js
|
|
2492
|
+
import { createBus } from "nanoeffects";
|
|
2493
|
+
import { consoleLogger, createTracer, reduxDevtools } from "nanoeffects/trace";
|
|
2494
|
+
|
|
2495
|
+
const bus = createBus({
|
|
2496
|
+
trace: createTracer([reduxDevtools({ name: "app" }), consoleLogger()]),
|
|
2497
|
+
});
|
|
2498
|
+
```
|
|
2499
|
+
|
|
2500
|
+
| Logger | What it does |
|
|
2501
|
+
| ------ | ------------ |
|
|
2502
|
+
| `reduxDevtools()` | Sends tasks to Redux DevTools as `type/effect/phase`; state is `{ running }` |
|
|
2503
|
+
| `consoleLogger()` | Prints one line per phase, indented by task depth |
|
|
2504
|
+
| `performanceLogger()` | User Timing measures for the browser Performance panel |
|
|
2505
|
+
| `createTraceRecorder()` | Keeps events in `recorder.events`, for tests |
|
|
2506
|
+
|
|
2507
|
+
### Cut volume in production
|
|
2508
|
+
|
|
2509
|
+
```js
|
|
2510
|
+
import { createTracer } from "nanoeffects/trace";
|
|
2511
|
+
|
|
2512
|
+
createTracer(logger, {
|
|
2513
|
+
sample: 0.05, // trace 5% of handler calls (share, or function of { type, payload })
|
|
2514
|
+
filter: (event) => event.phase === "error", // keep only failures
|
|
2515
|
+
});
|
|
2516
|
+
```
|
|
2517
|
+
|
|
2518
|
+
- `sample` is decided once per root task; every task below it follows, so a trace is never cut in the middle. Unsampled tasks cost almost nothing.
|
|
2519
|
+
- `filter` runs on each event before any logger sees it.
|
|
2520
|
+
|
|
2521
|
+
### Configure Redux DevTools
|
|
2522
|
+
|
|
2523
|
+
Read-only (the bus owns no state, so there is no time travel). Does nothing when the extension is not installed. Has its own instance in the DevTools picker, apart from your store.
|
|
2524
|
+
|
|
2525
|
+
Quiet by default:
|
|
2526
|
+
|
|
2527
|
+
- **A task is one action**, sent when it ends (`done`, `error`, `cancel`, `drop`). Running tasks are in `{ running }`. `starts: true` also sends every start.
|
|
2528
|
+
- **Only the top is shown**: handler tasks and the effects they start. Attempts and starters under them are hidden. `depth: 0` — handlers only; `depth: 3` — more; `Infinity` — everything.
|
|
2529
|
+
- `maxAge: 200` — how many actions DevTools keeps (its default is 50).
|
|
2530
|
+
|
|
2531
|
+
```js
|
|
2532
|
+
import { reduxDevtools } from "nanoeffects/trace";
|
|
2533
|
+
|
|
2534
|
+
reduxDevtools({ name: "app", depth: 0 }); // one line per handler call
|
|
2535
|
+
reduxDevtools({ name: "app", starts: true, depth: Infinity }); // everything
|
|
2536
|
+
```
|
|
2537
|
+
|
|
2538
|
+
### Write your own logger
|
|
2539
|
+
|
|
2540
|
+
```ts
|
|
2541
|
+
import type { TraceLogger } from "nanoeffects";
|
|
2542
|
+
|
|
2543
|
+
const errorsOnly = (): TraceLogger => (event) => {
|
|
2544
|
+
if (event.phase === "error") {
|
|
2545
|
+
report(event.error);
|
|
2546
|
+
}
|
|
2547
|
+
};
|
|
2548
|
+
```
|
|
2549
|
+
|
|
2550
|
+
## Drop tracing from a build
|
|
2551
|
+
|
|
2552
|
+
Without a tracer the hooks cost almost nothing. `nanoeffects/trace` is only bundled when imported.
|
|
2553
|
+
|
|
2554
|
+
To also drop tracing code from the core, use the `notrace` export condition. The package ships a second build of `index`, `effects`, `policies`, and `actions` with `__NANOEFFECTS_TRACE__` set to `false`.
|
|
2555
|
+
|
|
2556
|
+
```js
|
|
2557
|
+
// vite / rolldown
|
|
2558
|
+
resolve: { conditions: ["notrace"] }
|
|
2559
|
+
// webpack
|
|
2560
|
+
resolve: { conditionNames: ["notrace", "import", "..."] }
|
|
2561
|
+
// esbuild
|
|
2562
|
+
conditions: ["notrace"]
|
|
2563
|
+
```
|
|
2564
|
+
|
|
2565
|
+
In a build from the sources, `define: { __NANOEFFECTS_TRACE__: "false" }` does the same. With the condition set, that build’s types have no `trace` option: `createBus({ trace })` is a type error. `nanoeffects/trace` has no `notrace` build. The saving is about 0.4 kB gzip of the whole library.
|
|
2566
|
+
|
|
2567
|
+
---
|
|
2568
|
+
Source: docs/observable.md
|
|
2569
|
+
|
|
2570
|
+
# Turn events into Observables
|
|
2571
|
+
|
|
2572
|
+
Use this page only when you already work in Observables / RxJS. Prefer `bus.on` with policies for cancellation and tracing.
|
|
2573
|
+
|
|
2574
|
+
`createObserve(bus)` from `nanoeffects/observable` turns a bus event into an Observable that follows the TC39 proposal (`subscribe`, `unsubscribe`, `Symbol.observable`). RxJS and other stream libraries take it as it is. Nothing here depends on them.
|
|
2575
|
+
|
|
2576
|
+
<!-- docs-check: skip - needs rxjs, which the project does not depend on -->
|
|
2577
|
+
```js
|
|
2578
|
+
import { from } from "rxjs";
|
|
2579
|
+
import { debounceTime, distinctUntilChanged, switchMap } from "rxjs/operators";
|
|
2580
|
+
import { createObserve } from "nanoeffects/observable";
|
|
2581
|
+
|
|
2582
|
+
const observe = createObserve(bus); // Observable per event name
|
|
2583
|
+
|
|
2584
|
+
from(observe("typed"))
|
|
2585
|
+
.pipe(debounceTime(300), distinctUntilChanged(), switchMap(searchRequest))
|
|
2586
|
+
.subscribe((items) => bus.emit("results", items));
|
|
2587
|
+
```
|
|
2588
|
+
|
|
2589
|
+
- `observe(event)` returns an Observable of the payloads of that event. Each `subscribe` is a `bus.on` of its own; `unsubscribe()` removes it.
|
|
2590
|
+
- `subscribe` takes a function or an object with `next`.
|
|
2591
|
+
- The stream never completes and never fails: events are the only thing that arrives. Errors of the subscriber’s handler belong to the subscriber.
|
|
2592
|
+
- A subscription made this way is not tied to a task. End it yourself: `onAbort(ctx, subscription.unsubscribe)` inside a task, or with the signal of a module.
|
|
2593
|
+
- For cancellation, policies, and tracing, keep to `bus.on` with `latest`, `debounce`, `queue` — they know about tasks. The Observable is for places where a stream is already how you work.
|
|
2594
|
+
|
|
2595
|
+
---
|
|
2596
|
+
Source: docs/hmr.md
|
|
2597
|
+
|
|
2598
|
+
# Hot reload and development diagnostics
|
|
2599
|
+
|
|
2600
|
+
Use this page in development with modules and hot reload. Skip until you mount domains with `modulePlugin`.
|
|
2601
|
+
|
|
2602
|
+
When a file is edited, the bundler loads its new version and runs it again, while the page and the rest of the code stay alive. For a bus that can go wrong in three ways:
|
|
2603
|
+
|
|
2604
|
+
1. A file that calls `bus.on(...)` subscribes a second time.
|
|
2605
|
+
2. A file that makes the bus makes a new one.
|
|
2606
|
+
3. Tasks of the old code keep running.
|
|
2607
|
+
|
|
2608
|
+
## Replace modules by name
|
|
2609
|
+
|
|
2610
|
+
Mounting a module whose name is taken replaces the mounted one. The old module is unmounted first — and with it its subscriptions, bound commands, sub-modules, and background tasks (their signals abort). The new version of a module file only has to mount again; nothing piles up.
|
|
2611
|
+
|
|
2612
|
+
- **Names must be unique.** The name means “this is the new version of that module”, so two different modules with one name replace each other. The name is also the type of the module’s tasks in the trace.
|
|
2613
|
+
- **Pass `signal` to what the module does.** An unmount aborts the tasks, but a `fetch` or a loop that ignores `ctx.signal` runs on with the old code.
|
|
2614
|
+
- **Sub-modules** (`m.use(...)`) are replaced by name inside their parent. A reload of the parent replaces it with all its children.
|
|
2615
|
+
- Keep the bus and the app in a file that is not reloaded (or in what your bundler keeps across reloads), so a reload does not make a new bus that other files do not talk to.
|
|
2616
|
+
|
|
2617
|
+
A `bus.on` outside a module returns the function that unsubscribes: hand it to the dispose hook of your bundler, or put the subscription in a module.
|
|
2618
|
+
|
|
2619
|
+
## Report leaks with `devPlugin`
|
|
2620
|
+
|
|
2621
|
+
`bus.use(devPlugin({ app }))` reports what is easy to miss while developing:
|
|
2622
|
+
|
|
2623
|
+
```js
|
|
2624
|
+
import { createBus } from "nanoeffects";
|
|
2625
|
+
import { modulePlugin } from "nanoeffects/actions";
|
|
2626
|
+
import { devPlugin } from "nanoeffects/dev";
|
|
2627
|
+
|
|
2628
|
+
const bus = createBus();
|
|
2629
|
+
const app = bus.use(modulePlugin([userModule]));
|
|
2630
|
+
// devPlugin — warn on piled-up listeners; log module mount/replace/unmount
|
|
2631
|
+
bus.use(devPlugin({ app }));
|
|
2632
|
+
```
|
|
2633
|
+
|
|
2634
|
+
- **Listeners that pile up.** When more than `maxListeners` (default `10`) listeners of one event were added and not unsubscribed, one warning names the event and the count. The count follows the function that `on` returns; `bus.off(event, fn)` is not seen.
|
|
2635
|
+
- **Modules.** With `app` it logs `module "user" mounted`, `replaced` when a module of the same name was mounted, and `unmounted`. Only what goes through `app.mount` and `app.unmount` is reported, not sub-modules.
|
|
2636
|
+
- **Options.** `leaks: false` and `modules: false` switch a diagnostic off; `maxListeners` sets the limit; `log` receives the messages instead of the console.
|
|
2637
|
+
- **Disconnect.** `bus.use(devPlugin(...))` returns the function that stops the diagnostics and puts `bus.on` and `app.mount` back.
|
|
2638
|
+
|
|
2639
|
+
It changes no behavior: it wraps `bus.on`, `app.mount`, and `app.unmount` and adds messages.
|
|
2640
|
+
|
|
2641
|
+
### Keep the import in production
|
|
2642
|
+
|
|
2643
|
+
`nanoeffects/dev` is a no-op in a production build. `package.json` maps it to an empty build under the `production` export condition (Vite and webpack set that in production mode), so `bus.use(devPlugin({ app }))` can stay without a guard: it touches nothing and costs a few bytes.
|
|
2644
|
+
|
|
2645
|
+
If you want even the call gone, guard it (`if (import.meta.env.DEV)` in Vite). An app that does not import the entry does not carry it.
|