nanoeffects 0.7.0 → 0.9.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 +2 -2
- package/dist/{action-creator-Bd4TBZgl.d.ts → action-creator-BrZF6hXc.d.ts} +0 -4
- package/dist/actions.d.ts +41 -25
- package/dist/actions.js +1 -1
- package/dist/bus-CkTHUAvq.js +1 -0
- package/dist/context-COgyNlrC.js +1 -0
- package/dist/{context-plugin-BVNKqkIx.d.ts → context-plugin--HkNN1ha.d.ts} +1 -1
- package/dist/dev.d.ts +9 -8
- package/dist/dev.js +1 -1
- package/dist/effects.d.ts +2 -2
- package/dist/effects.js +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/match.d.ts +50 -0
- package/dist/match.js +1 -0
- package/dist/{module-DZ5ngUrM.d.ts → module-DqKegXHt.d.ts} +43 -18
- package/dist/notrace/{action-creator-D-y_-xRS.d.ts → action-creator-DHAEgwuh.d.ts} +0 -4
- package/dist/notrace/actions.d.ts +90 -39
- package/dist/notrace/actions.js +1 -1
- package/dist/notrace/bus-BmCTo8BG.js +1 -0
- package/dist/notrace/{context-plugin-DDfsaErA.d.ts → context-plugin-BapQkGwQ.d.ts} +1 -1
- package/dist/notrace/context-yYU3_9lq.js +1 -0
- package/dist/notrace/{delay-B_W1tnkS.js → delay-BosJKFjg.js} +1 -1
- package/dist/notrace/effects.d.ts +2 -2
- package/dist/notrace/effects.js +1 -1
- package/dist/notrace/index.d.ts +2 -2
- package/dist/notrace/index.js +1 -1
- package/dist/notrace/{plugin-CDAngHwg.d.ts → plugin-Cxadz6uU.d.ts} +2 -2
- package/dist/notrace/policies.d.ts +30 -2
- package/dist/notrace/policies.js +1 -1
- package/dist/notrace/redux.d.ts +1 -1
- package/dist/observable.d.ts +1 -1
- package/dist/oop.d.ts +131 -0
- package/dist/oop.js +1 -0
- package/dist/{plugin-DQNPBcxx.d.ts → plugin-DkXCvsmB.d.ts} +2 -2
- package/dist/policies.d.ts +3 -2
- package/dist/policies.js +1 -1
- package/dist/react.d.ts +33 -1
- package/dist/react.js +1 -1
- package/dist/redux.d.ts +1 -1
- package/dist/scope.d.ts +5 -6
- package/dist/scope.js +1 -1
- package/dist/signal.d.ts +51 -0
- package/dist/signal.js +1 -0
- package/dist/trace.d.ts +1 -1
- package/dist/{types-BJ0fzV1p.d.ts → types-KavbvHix.d.ts} +13 -3
- package/dist/with-policy-CqUS-VWW.d.ts +30 -0
- package/dist/with-policy-DBV9dh2F.js +1 -0
- package/docs/README.md +7 -0
- package/docs/actions.md +152 -39
- package/docs/cancel-stale-requests.md +111 -0
- package/docs/debounce-and-abort.md +106 -0
- package/docs/guide.md +143 -19
- package/docs/hmr.md +6 -5
- package/docs/migrating-from-effector.md +51 -18
- package/docs/migrating-from-mitt.md +97 -0
- package/docs/migrating-from-rxjs.md +17 -6
- package/docs/motivation.md +41 -0
- package/docs/observable.md +124 -10
- package/docs/oop.md +286 -0
- package/docs/package.md +6 -2
- package/docs/plugins.md +247 -51
- package/docs/policies.md +33 -0
- package/docs/prevent-double-submit.md +128 -0
- package/docs/query-libraries.md +124 -0
- package/docs/react.md +61 -5
- package/docs/scope.md +60 -22
- package/docs/stores.md +263 -109
- package/llms-full.txt +2153 -355
- package/llms.txt +10 -3
- package/package.json +43 -4
- package/skills/nanoeffects/SKILL.md +52 -0
- package/dist/bus-BAs9x7IQ.js +0 -1
- package/dist/context-CK0ZomWt.js +0 -1
- package/dist/notrace/bus-Bdk0YyFQ.js +0 -1
- package/dist/notrace/context-CctVmHLC.js +0 -1
package/llms-full.txt
CHANGED
|
@@ -9,7 +9,7 @@ Rules that generated code often gets wrong:
|
|
|
9
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
10
|
- Effects take `ctx` as the first argument: `retry(ctx, fn, options)`, `timeout(ctx, ms, fn)`, `take(ctx, "event")`.
|
|
11
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`.
|
|
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`; `usePolicy`; needs React), `nanoeffects/trace`, `nanoeffects/dev`.
|
|
13
13
|
- `bus.emit(command(payload))` runs the command's handler and returns its result. Plain events return nothing.
|
|
14
14
|
|
|
15
15
|
---
|
|
@@ -86,6 +86,74 @@ unbind();
|
|
|
86
86
|
- An event that only `"*"` listens to counts as heard, so `onUnhandled` does not fire for it.
|
|
87
87
|
- `bus.off("*")` removes every `"*"` listener; `bus.off()` clears the whole bus, `"*"` included.
|
|
88
88
|
|
|
89
|
+
### Pick events with a function
|
|
90
|
+
|
|
91
|
+
To listen to a group of events, such as every `user/*` event, `"*"` and a check of `ctx.type` do the job but leave the check to you. `matchPlugin` lets `on` take a function that picks the events. The function gets `{ type, payload }` of each event, and the handler runs when it returns true.
|
|
92
|
+
|
|
93
|
+
<!-- docs-check: new -->
|
|
94
|
+
|
|
95
|
+
```js
|
|
96
|
+
// bus.js
|
|
97
|
+
import { createBus } from "nanoeffects";
|
|
98
|
+
import { matchPlugin } from "nanoeffects/match";
|
|
99
|
+
|
|
100
|
+
// matchPlugin — lets on, once and off take a function that picks the events
|
|
101
|
+
export const bus = createBus().use(matchPlugin());
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
```js
|
|
105
|
+
// user.js
|
|
106
|
+
import { createAction } from "nanoeffects/actions";
|
|
107
|
+
|
|
108
|
+
export const userLoaded = createAction("user/loaded");
|
|
109
|
+
export const userFailed = createAction("user/failed");
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
```js
|
|
113
|
+
// user-handlers.js
|
|
114
|
+
import { bus } from "./bus";
|
|
115
|
+
|
|
116
|
+
// the function picks the events; the handler gets the payload and the context of each
|
|
117
|
+
bus.on(
|
|
118
|
+
(action) => action.type.startsWith("user/"),
|
|
119
|
+
(payload, ctx) => {
|
|
120
|
+
console.log(ctx.type, payload);
|
|
121
|
+
},
|
|
122
|
+
);
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
```js
|
|
126
|
+
import { bus } from "./bus";
|
|
127
|
+
import { userFailed, userLoaded } from "./user";
|
|
128
|
+
|
|
129
|
+
bus.emit(userLoaded({ id: "1" })); // user/loaded { id: "1" }
|
|
130
|
+
bus.emit("order/placed", 5); // not picked
|
|
131
|
+
bus.emit(userFailed("boom")); // user/failed boom
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
- `once(fn, handler)` waits for the first event that `fn` accepts. `off(fn)` removes every handler of `fn`, and `off(fn, handler)` removes one.
|
|
135
|
+
- An action creator or a command still means that one event: only a function with no `type` is a function that picks events.
|
|
136
|
+
- The cost: the handler listens to `"*"`, so each event makes one task for each such subscription, also when the function says no, and an event that only such a subscription listens to counts as heard for `onUnhandled`. For a few known events, `on(creator, handler)` is cheaper.
|
|
137
|
+
- It works in any order with the other plugins, `devPlugin` included.
|
|
138
|
+
|
|
139
|
+
In TypeScript a function that is a type guard narrows the payload of the handler:
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
import { bus } from "./bus";
|
|
143
|
+
|
|
144
|
+
type User = { id: string };
|
|
145
|
+
|
|
146
|
+
// the guard says which event this is, and the handler gets its payload typed
|
|
147
|
+
const isLoaded = (action: {
|
|
148
|
+
type: string;
|
|
149
|
+
payload: unknown;
|
|
150
|
+
}): action is { type: "user/loaded"; payload: User } => action.type === "user/loaded";
|
|
151
|
+
|
|
152
|
+
bus.on(isLoaded, (user) => console.log(user.id)); // user: User
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
`ctx.on` takes such a function at run time too, but its type does not know it yet.
|
|
156
|
+
|
|
89
157
|
## 2. Use the handler context
|
|
90
158
|
|
|
91
159
|
The second argument is the **task** context for this call. Emit further events through it. Pass `ctx.signal` to `fetch` when you cancel work.
|
|
@@ -166,7 +234,7 @@ const searchLatest = latest(async (query, { signal }) => {
|
|
|
166
234
|
bus.on("search", debounce(300, searchLatest));
|
|
167
235
|
```
|
|
168
236
|
|
|
169
|
-
Full options: [Policies](policies.md).
|
|
237
|
+
Full options: [Policies](policies.md). No bus? A policy also works as a plain function: [Use a policy without a bus](policies.md#use-a-policy-without-a-bus).
|
|
170
238
|
|
|
171
239
|
## 5. Add retry and timeout
|
|
172
240
|
|
|
@@ -242,22 +310,42 @@ More in [Effects](effects.md).
|
|
|
242
310
|
|
|
243
311
|
`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).
|
|
244
312
|
|
|
313
|
+
From here on the bus is kept in one file and the events in files named after their domain (`move.js`, `math.js`), as in a real app: the events can then be imported anywhere without importing the bus.
|
|
314
|
+
|
|
245
315
|
```js
|
|
316
|
+
// bus.js
|
|
246
317
|
import { createBus } from "nanoeffects";
|
|
247
|
-
import { createAction, createCommand } from "nanoeffects/actions";
|
|
248
318
|
|
|
249
|
-
const bus = createBus();
|
|
319
|
+
export const bus = createBus();
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
```js
|
|
323
|
+
// move.js
|
|
324
|
+
import { createAction } from "nanoeffects/actions";
|
|
325
|
+
|
|
326
|
+
// createAction — names an event; calling it builds { type, payload }
|
|
327
|
+
export const moved = createAction("moved");
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
```js
|
|
331
|
+
// math.js
|
|
332
|
+
import { createCommand } from "nanoeffects/actions";
|
|
333
|
+
|
|
334
|
+
// createCommand — like an action, but emit runs a handler and returns its result
|
|
335
|
+
export const double = createCommand("math/double", (value) => value * 2);
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
```js
|
|
339
|
+
import { bus } from "./bus";
|
|
340
|
+
import { moved } from "./move";
|
|
341
|
+
import { double } from "./math";
|
|
250
342
|
|
|
251
343
|
bus.emit("moved", { x: 1, y: 2 });
|
|
252
344
|
bus.emit({ type: "moved", payload: { x: 2, y: 2 } });
|
|
253
345
|
|
|
254
|
-
// createAction — names an event; calling it builds { type, payload }
|
|
255
|
-
const moved = createAction("moved");
|
|
256
346
|
bus.emit(moved({ x: 3, y: 2 }));
|
|
257
347
|
bus.on(moved, ({ x }) => console.log(x));
|
|
258
348
|
|
|
259
|
-
// createCommand — like an action, but emit runs a handler and returns its result
|
|
260
|
-
const double = createCommand("math/double", (value) => value * 2);
|
|
261
349
|
bus.emit(double(21)); // 42
|
|
262
350
|
```
|
|
263
351
|
|
|
@@ -268,14 +356,25 @@ Use strings for the plain case. Use objects when other code already builds actio
|
|
|
268
356
|
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.
|
|
269
357
|
|
|
270
358
|
```js
|
|
271
|
-
|
|
272
|
-
import { createCommand
|
|
359
|
+
// user.js
|
|
360
|
+
import { createCommand } from "nanoeffects/actions";
|
|
273
361
|
|
|
274
|
-
const getUser = createCommand("user/get"); // contract — no handler yet
|
|
362
|
+
export const getUser = createCommand("user/get"); // contract — no handler yet
|
|
363
|
+
```
|
|
275
364
|
|
|
276
|
-
|
|
365
|
+
```js
|
|
366
|
+
// bus.js
|
|
367
|
+
import { createBus } from "nanoeffects";
|
|
368
|
+
import { commandsPlugin } from "nanoeffects/actions";
|
|
369
|
+
|
|
370
|
+
export const bus = createBus();
|
|
277
371
|
// commandsPlugin — registry of handlers for this bus
|
|
278
|
-
const commands = bus.use(commandsPlugin());
|
|
372
|
+
export const commands = bus.use(commandsPlugin());
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
```js
|
|
376
|
+
import { bus, commands } from "./bus";
|
|
377
|
+
import { getUser } from "./user";
|
|
279
378
|
|
|
280
379
|
bus.emit(getUser("1")); // throws: No handler for command "user/get"
|
|
281
380
|
|
|
@@ -296,16 +395,27 @@ Each bus has its own registry. A [module](#9-mount-a-domain-as-a-module) can reg
|
|
|
296
395
|
**What you do.** `createModule(name, setup)` describes the feature. `bus.use(modulePlugin([…]))` mounts it. `app.unmount(module)` undoes everything the setup registered.
|
|
297
396
|
|
|
298
397
|
```js
|
|
299
|
-
|
|
300
|
-
import { createCommand
|
|
398
|
+
// user.js
|
|
399
|
+
import { createCommand } from "nanoeffects/actions";
|
|
301
400
|
|
|
302
|
-
const getUser = createCommand("user/get"); // contract — no handler yet
|
|
303
|
-
|
|
401
|
+
export const getUser = createCommand("user/get"); // contract — no handler yet
|
|
402
|
+
```
|
|
304
403
|
|
|
305
|
-
|
|
404
|
+
```js
|
|
405
|
+
// math.js
|
|
406
|
+
import { createCommand } from "nanoeffects/actions";
|
|
407
|
+
|
|
408
|
+
export const double = createCommand("math/double", (value) => value * 2); // carries its handler
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
```js
|
|
412
|
+
// greeting-module.js
|
|
413
|
+
import { createModule } from "nanoeffects/actions";
|
|
414
|
+
import { getUser } from "./user";
|
|
415
|
+
import { double } from "./math";
|
|
306
416
|
|
|
307
417
|
// createModule — one feature; name is stable for hot reload replace
|
|
308
|
-
const greeting = createModule("greeting", (m) => {
|
|
418
|
+
export const greeting = createModule("greeting", (m) => {
|
|
309
419
|
// m.on — like bus.on, removed automatically on unmount
|
|
310
420
|
m.on("greet", (name) => console.log(`hello ${name}`));
|
|
311
421
|
|
|
@@ -315,9 +425,23 @@ const greeting = createModule("greeting", (m) => {
|
|
|
315
425
|
// m.use — register a command that already has a handler, for this module’s life
|
|
316
426
|
m.use(double);
|
|
317
427
|
});
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
```js
|
|
431
|
+
// bus.js
|
|
432
|
+
import { createBus } from "nanoeffects";
|
|
433
|
+
import { modulePlugin } from "nanoeffects/actions";
|
|
434
|
+
import { greeting } from "./greeting-module";
|
|
318
435
|
|
|
436
|
+
export const bus = createBus();
|
|
319
437
|
// modulePlugin — mount modules on the bus
|
|
320
|
-
const app = bus.use(modulePlugin([greeting]));
|
|
438
|
+
export const app = bus.use(modulePlugin([greeting]));
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
```js
|
|
442
|
+
import { app, bus } from "./bus";
|
|
443
|
+
import { greeting } from "./greeting-module";
|
|
444
|
+
|
|
321
445
|
bus.emit("greet", "Ada"); // hello Ada
|
|
322
446
|
app.unmount(greeting); // greet listener and getUser binding are gone
|
|
323
447
|
```
|
|
@@ -376,6 +500,51 @@ More in [Errors](errors.md).
|
|
|
376
500
|
| Guarantees | [Semantics](semantics.md) |
|
|
377
501
|
| Which import | [Package entries](package.md) |
|
|
378
502
|
|
|
503
|
+
---
|
|
504
|
+
Source: docs/motivation.md
|
|
505
|
+
|
|
506
|
+
# Why nanoeffects exists
|
|
507
|
+
|
|
508
|
+
Read this page if you build an SDK, a core library or a micro-frontend shell and wonder why you would pick a new event bus. It explains the constraints the library was made for. It does not teach the API: start with the [Guide](guide.md).
|
|
509
|
+
|
|
510
|
+
## The problem: a shared dependency is a shared risk
|
|
511
|
+
|
|
512
|
+
A shared library (an SDK, a core package, a platform shell) is loaded by many apps. Whatever it depends on, the apps depend on too. State and event libraries are the worst case, because they keep **internal state and identity** (a store, a context, a class). Two copies or two versions of them do not merge: they silently stop seeing each other.
|
|
513
|
+
|
|
514
|
+
Module Federation makes this visible. Each remote can bring its own copy unless every team agrees on a `singleton` and a version range. The [Nx guide on library versions](https://nx.dev/docs/kb/manage-library-versions-with-module-federation) lists the result: libraries with internal state conflict, and a missing method in one version breaks callers written for another.
|
|
515
|
+
|
|
516
|
+
Real cases from the libraries nanoeffects was compared with:
|
|
517
|
+
|
|
518
|
+
| Library | What goes wrong | Source |
|
|
519
|
+
| ------- | --------------- | ------ |
|
|
520
|
+
| Redux, React Redux | A shared component library uses `react-redux`. Every micro-frontend must use the same version of that library, or `react-redux` "breaks weirdly". | [react-redux#1949](https://github.com/reduxjs/react-redux/issues/1949) |
|
|
521
|
+
| Redux | A store is reachable only under a `Provider`. Each sub-app needs its own provider, and a second one must not be created when a module is injected. Reducers duplicated between the host and a module add to it. | [Redux on Module Federation](https://dev.to/manomano-tech-team/how-to-work-with-redux-on-module-federation-13gm) |
|
|
522
|
+
| Effector | A library that uses `effector-react/scope` and is bundled into another project gets two instances. Its `Provider` is not seen by `useStore` of the app: "no scope found". | [effector#727](https://github.com/effector/effector/issues/727) |
|
|
523
|
+
| RxJS | Two copies give two `Observable` classes. Types of one version do not fit the other, and operators added to one copy are missing on the other. The advice is "one version in `node_modules`". | [RxJS#2984](https://github.com/ReactiveX/rxjs/issues/2984), [neo4j-javascript-driver#978](https://github.com/neo4j/neo4j-javascript-driver/issues/978) |
|
|
524
|
+
|
|
525
|
+
A library that is **not** a shared singleton cannot break this way. nanoeffects has no dependencies and no module-level state: a bus is an object you create. Two copies, or two versions, of the package in one page are two independent buses. Nothing is registered globally, no `Provider` is needed, and the host app does not have to pick a version for you.
|
|
526
|
+
|
|
527
|
+
## Why not the alternatives
|
|
528
|
+
|
|
529
|
+
| Option | Why it did not fit |
|
|
530
|
+
| ------ | ------------------ |
|
|
531
|
+
| Redux (with sagas) | A store and a provider shared by every consumer, which is the risk above. `redux-saga` also has no cheap way to get an answer back from an event: effects talk through the store. |
|
|
532
|
+
| Effector | A graph with global identity. A version or scope mismatch between the SDK and the app reaches the whole infrastructure. |
|
|
533
|
+
| Zustand | Good state, but it is a store, not a way to run and cancel work. It is the natural place to hold state next to nanoeffects: see [Stores](stores.md). |
|
|
534
|
+
| RxJS | Flexible, but learning its ecosystem (operators, subjects, schedulers) is a high entry cost for every developer who touches the SDK. The package is also not small: [Bundlephobia](https://bundlephobia.com/package/rxjs) reports 17.3 kB minified and gzipped for 7.8.2. |
|
|
535
|
+
|
|
536
|
+
## What the library does instead
|
|
537
|
+
|
|
538
|
+
- **No dependencies, no global state.** It is safe to ship inside an SDK next to whatever the host app runs. It does not need a `singleton` entry in the Module Federation config.
|
|
539
|
+
- **Small enough for a strict budget.** SDKs often get a few kilobytes for all third-party code. `createBus` is about 1.4 kB gzip (1.3 kB brotli); policies, effects and commands are separate entries you import only when you need them. See [Package entries](package.md).
|
|
540
|
+
- **Simple first.** `on` and `emit` are the whole model. Cancellation, retry and the rest are plain functions on top. The [Guide](guide.md) and [Coming from redux-saga](migrating-from-saga.md) map the familiar names.
|
|
541
|
+
- **`emit` can return an answer.** A [command](actions.md) is an event whose handler returns a result, so one module can ask another and wait for the reply. Events alone cannot do that, and it was the main thing missing from the Redux-based options.
|
|
542
|
+
- **Easy to debug.** Event-driven code is hard to follow: you see an effect and not the event that caused it. Every task knows its parent, so [tracing](tracing.md) shows the chain of events, with loggers for the console, the Performance panel and Redux DevTools.
|
|
543
|
+
|
|
544
|
+
## When this is not the right tool
|
|
545
|
+
|
|
546
|
+
nanoeffects holds no state. If you need a store, keep Redux, Zustand or Effector and let the bus run the async work: [Stores](stores.md). If you already have streams everywhere, keep RxJS: [Use nanoeffects next to RxJS](migrating-from-rxjs.md).
|
|
547
|
+
|
|
379
548
|
---
|
|
380
549
|
Source: docs/package.md
|
|
381
550
|
|
|
@@ -389,6 +558,9 @@ Imports are split so you only load what you use. `createBus` stays small; the re
|
|
|
389
558
|
| `nanoeffects/policies` | `latest`, `leading`, `debounce`, `throttle`, `queue` |
|
|
390
559
|
| `nanoeffects/effects` | `retry`, `timeout`, `poll`, `delay`, `take`, `all`, `allSettled`, `race`, `effectsPlugin` |
|
|
391
560
|
| `nanoeffects/actions` | `createAction`, `createCommand`, `createJob`, `createModule`, `modulePlugin`, `commandsPlugin` |
|
|
561
|
+
| `nanoeffects/match` | `matchPlugin`: `on`, `once` and `off` that take a function which picks the events |
|
|
562
|
+
| `nanoeffects/signal` | `signalPlugin`: `emit(call, { signal })` ties the tasks of an emit to an outside `AbortSignal` |
|
|
563
|
+
| `nanoeffects/oop` | `extendBus`, `WithBus`, `On`, `Once`, `disposeBus`: the methods of a bus on a class, with the subscriptions of each instance ended by `dispose()` |
|
|
392
564
|
| `nanoeffects/scope` | `scopePlugin`, `effect`: fields for one command, action, job, module or `emit` (the types `Scope`, `Effect`) |
|
|
393
565
|
| `nanoeffects/redux` | `createEffectsMiddleware` |
|
|
394
566
|
| `nanoeffects/observable` | `createObserve` |
|
|
@@ -400,67 +572,236 @@ The `...Plugin` names above are used with `bus.use`. [Plugins](plugins.md) lists
|
|
|
400
572
|
|
|
401
573
|
## Give the docs to an AI assistant
|
|
402
574
|
|
|
403
|
-
The package ships
|
|
575
|
+
The package ships plain-text files for assistants that write code with it:
|
|
404
576
|
|
|
405
577
|
- `llms.txt`: a short index. It states the rules that generated code often gets wrong and links to every page of these docs.
|
|
406
578
|
- `llms-full.txt`: the same summary followed by the text of every linked page, in one file.
|
|
579
|
+
- `skills/nanoeffects/SKILL.md`: a skill for assistants that support skills (for example Claude Code). It says when to suggest nanoeffects, when not to, and the same rules. Copy the folder to `.claude/skills/` in your project or to `~/.claude/skills/`.
|
|
407
580
|
|
|
408
|
-
Find them in `node_modules/nanoeffects/`, or load `llms-full.txt` from a CDN such as `https://unpkg.com/nanoeffects/llms-full.txt`.
|
|
581
|
+
Find them in `node_modules/nanoeffects/`, or load `llms-full.txt` from a CDN such as `https://unpkg.com/nanoeffects/llms-full.txt`. All match the installed version.
|
|
409
582
|
|
|
410
583
|
---
|
|
411
584
|
Source: docs/plugins.md
|
|
412
585
|
|
|
413
586
|
# Choose the plugins of a bus
|
|
414
587
|
|
|
415
|
-
Optional. A plugin adds one thing to a bus: fields on `ctx`, a method, a registry of handlers, diagnostics. Read this page when you want to know which plugins exist, what each one
|
|
588
|
+
Optional. A plugin adds one thing to a bus: fields on `ctx`, a method, a registry of handlers, diagnostics. Read this page when you want to know which plugins exist, what each one gives you, and in what order to use them. Skip it for a small bus: `createBus()` alone is enough.
|
|
416
589
|
|
|
417
590
|
## Use a plugin
|
|
418
591
|
|
|
419
|
-
A plugin is a function of the bus. `bus.use(plugin)` calls it with the bus and returns whatever the plugin returns.
|
|
592
|
+
A plugin is a function of the bus. `bus.use(plugin)` calls it with the bus and returns whatever the plugin returns. Most plugins return the same bus, so you can chain them: `createBus().use(a).use(b)`. A few return something of their own (a registry, an app, a `disconnect` function): keep it.
|
|
593
|
+
|
|
594
|
+
In TypeScript keep the result of `contextPlugin` and `effectsPlugin`: the type of the bus with its new fields is the type they return (`const bus = createBus().use(contextPlugin({ store }))`).
|
|
595
|
+
|
|
596
|
+
## Main plugins
|
|
597
|
+
|
|
598
|
+
These four cover what most apps need: shared dependencies, effects, commands and modules. Start here.
|
|
599
|
+
|
|
600
|
+
| Plugin | What it gives you | Import | `bus.use` returns |
|
|
601
|
+
| ------ | ----------------- | ------ | ----------------- |
|
|
602
|
+
| [`contextPlugin`](#put-a-store-or-a-client-on-every-handler-contextplugin) | Your own fields (a store, a client, a logger) on `ctx` of every handler. | `nanoeffects` | The same bus, typed with the fields |
|
|
603
|
+
| [`effectsPlugin`](#use-race-and-delay-without-ctx-effectsplugin) | `race`, `all`, `delay` and your own effects on `ctx`, called without passing `ctx`. | `nanoeffects/effects` | The same bus, typed with the effects |
|
|
604
|
+
| [`commandsPlugin`](#let-a-test-replace-a-handler-commandsplugin) | The handlers of commands in a registry, so a test can replace one. | `nanoeffects/actions` | The registry `{ run, handle }` |
|
|
605
|
+
| [`modulePlugin`](#mount-a-domain-as-one-unit-moduleplugin) | A module: the listeners, handlers and tasks of one domain, mounted and unmounted with one call. | `nanoeffects/actions` | The app `{ mount, unmount, isMounted, dispose, commands, createModule }` |
|
|
606
|
+
|
|
607
|
+
### Put a store or a client on every handler: `contextPlugin`
|
|
608
|
+
|
|
609
|
+
Every task gets the same fields on `ctx`: a store, an API client, a logger. The types come from the values. More in [Context fields](context.md).
|
|
610
|
+
|
|
611
|
+
```js
|
|
612
|
+
import { contextPlugin, createBus } from "nanoeffects";
|
|
613
|
+
|
|
614
|
+
// contextPlugin — every handler gets ctx.store
|
|
615
|
+
const bus = createBus().use(contextPlugin({ store }));
|
|
616
|
+
|
|
617
|
+
bus.on("save", (draft, { store }) => store.put(draft));
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
### Use `race` and `delay` without `ctx`: `effectsPlugin`
|
|
621
|
+
|
|
622
|
+
Effects such as `race`, `all`, `take` and `delay` are bound to the task, so a handler calls `race({ … })` and not `race(ctx, { … })`. Your own functions that take `{ signal }` first work too. More in [Effects](effects.md#put-selected-effects-on-ctx).
|
|
420
623
|
|
|
421
624
|
```js
|
|
422
625
|
import { createBus } from "nanoeffects";
|
|
626
|
+
import { delay, effectsPlugin, race } from "nanoeffects/effects";
|
|
423
627
|
|
|
424
|
-
//
|
|
425
|
-
const
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
628
|
+
// effectsPlugin — race and delay are bound to the task of each handler
|
|
629
|
+
const bus = createBus().use(effectsPlugin({ race, delay }));
|
|
630
|
+
|
|
631
|
+
bus.on("load", async (id, { race, delay }) => {
|
|
632
|
+
const result = await race({
|
|
633
|
+
data: ({ signal }) => api.get(id, { signal }),
|
|
634
|
+
timeout: () => delay(5000),
|
|
429
635
|
});
|
|
430
|
-
|
|
431
|
-
|
|
636
|
+
});
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
### Let a test replace a handler: `commandsPlugin`
|
|
640
|
+
|
|
641
|
+
Commands get their handlers from a registry that belongs to the bus. A command can be only a contract, and a test binds a fake. More in [Actions](actions.md#describe-events-commands-and-jobs).
|
|
642
|
+
|
|
643
|
+
```js
|
|
644
|
+
import { createBus } from "nanoeffects";
|
|
645
|
+
import { commandsPlugin, createCommand } from "nanoeffects/actions";
|
|
432
646
|
|
|
647
|
+
const getUser = createCommand("user/get"); // a contract: no handler yet
|
|
433
648
|
const bus = createBus();
|
|
434
|
-
// use — calls countPlugin(bus) and returns its result
|
|
435
|
-
const counter = bus.use(countPlugin);
|
|
436
649
|
|
|
437
|
-
bus
|
|
438
|
-
|
|
439
|
-
|
|
650
|
+
// commandsPlugin — the registry of handlers of this bus
|
|
651
|
+
const commands = bus.use(commandsPlugin());
|
|
652
|
+
|
|
653
|
+
commands.handle(getUser, async (id) => ({ id })); // the latest binding wins
|
|
654
|
+
const user = await bus.emit(getUser("1")); // { id: "1" }
|
|
655
|
+
```
|
|
656
|
+
|
|
657
|
+
### Mount a domain as one unit: `modulePlugin`
|
|
658
|
+
|
|
659
|
+
A module groups the listeners, the bound commands and the background tasks of one domain, and ends all of them when it is unmounted. It also makes [hot reload](hmr.md) work. More in [Modules](actions.md#mount-domains-with-modules).
|
|
660
|
+
|
|
661
|
+
```js
|
|
662
|
+
import { createBus } from "nanoeffects";
|
|
663
|
+
import { modulePlugin } from "nanoeffects/actions";
|
|
664
|
+
|
|
665
|
+
const bus = createBus();
|
|
666
|
+
|
|
667
|
+
// modulePlugin — mounts userModule on the first use of the app or the bus, and returns the app
|
|
668
|
+
const app = bus.use(modulePlugin([userModule]));
|
|
669
|
+
|
|
670
|
+
app.unmount(userModule); // its listeners, bound commands and tasks are gone
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
## Extra plugins
|
|
674
|
+
|
|
675
|
+
Add one of these when you meet its need. None of them is required.
|
|
676
|
+
|
|
677
|
+
| Plugin | What it gives you | Import | `bus.use` returns |
|
|
678
|
+
| ------ | ----------------- | ------ | ----------------- |
|
|
679
|
+
| [`scopePlugin`](#give-one-command-its-own-fields-scopeplugin) | Its own fields for one command, action, job or module, and a way to replace them in one `emit`. | `nanoeffects/scope` | The same bus |
|
|
680
|
+
| [`signalPlugin`](#tie-an-emit-to-an-outside-signal-signalplugin) | `emit(call, { signal })` that aborts its tasks when an outside signal aborts. | `nanoeffects/signal` | The same bus, typed with the new `emit` |
|
|
681
|
+
| [`matchPlugin`](#listen-to-a-group-of-events-matchplugin) | `on` that listens to a group of events which a function picks. | `nanoeffects/match` | The same bus, typed with the new overloads |
|
|
682
|
+
| [`emitterPlugin`](#hand-the-bus-to-a-state-manager-emitterplugin) | A callback that emits an action, to give to a state manager. | `nanoeffects/actions` | The same bus, with `emitter` |
|
|
683
|
+
| [`reactPlugin`](#tell-the-bus-when-a-react-value-changes-reactplugin) | A React hook that emits when a value of a component changes. | `nanoeffects/react` | The same bus, with `useEmitter` |
|
|
684
|
+
| [`devPlugin`](#find-leaks-while-you-develop-devplugin) | Warnings about listeners that pile up, and messages about modules, while you develop. | `nanoeffects/dev` | A function that disconnects it |
|
|
685
|
+
|
|
686
|
+
### Give one command its own fields: `scopePlugin`
|
|
687
|
+
|
|
688
|
+
A command, an action, a job or a module can carry its own fields and effects, and one `emit` can replace them. A test gives a fake to one call. More in [Scopes](scope.md).
|
|
689
|
+
|
|
690
|
+
```js
|
|
691
|
+
import { createBus } from "nanoeffects";
|
|
692
|
+
import { createCommand } from "nanoeffects/actions";
|
|
693
|
+
import { scopePlugin } from "nanoeffects/scope";
|
|
694
|
+
|
|
695
|
+
// scopePlugin — makes the scope of a command work on this bus
|
|
696
|
+
const bus = createBus().use(scopePlugin());
|
|
697
|
+
|
|
698
|
+
// the last argument: fields of this command only
|
|
699
|
+
const getUser = createCommand("user/get", (id, { api }) => api.getUser(id), {
|
|
700
|
+
api,
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
const fakeApi = { getUser: async (id) => ({ id, name: "test" }) };
|
|
704
|
+
bus.emit(getUser("1"), { api: fakeApi }); // a fake api for this call only
|
|
705
|
+
```
|
|
706
|
+
|
|
707
|
+
### Tie an emit to an outside signal: `signalPlugin`
|
|
708
|
+
|
|
709
|
+
A library such as React Query owns the request and gives you an `AbortSignal`. `emit` takes it as `{ signal }` and aborts the tasks it starts when the signal aborts, so `timeout`, `retry` and the request stop too. More in [Use nanoeffects with React Query and SWR](query-libraries.md).
|
|
710
|
+
|
|
711
|
+
```js
|
|
712
|
+
import { createBus } from "nanoeffects";
|
|
713
|
+
import { createCommand } from "nanoeffects/actions";
|
|
714
|
+
import { signalPlugin } from "nanoeffects/signal";
|
|
715
|
+
|
|
716
|
+
// signalPlugin — emit takes { signal } for a command, a job or an action
|
|
717
|
+
const bus = createBus().use(signalPlugin());
|
|
718
|
+
|
|
719
|
+
const getUser = createCommand("user/get", (id, { signal }) => api.getUser(id, { signal }));
|
|
720
|
+
|
|
721
|
+
const controller = new AbortController();
|
|
722
|
+
bus.emit(getUser("1"), { signal: controller.signal }); // runs the command
|
|
723
|
+
controller.abort(); // ctx.signal of the handler aborts with it
|
|
724
|
+
```
|
|
725
|
+
|
|
726
|
+
### Listen to a group of events: `matchPlugin`
|
|
727
|
+
|
|
728
|
+
`on`, `once` and `off` take a function that picks the events, such as every `user/*` event. A type guard narrows the payload. The subscription listens to every event, so for a few known events `on(creator, handler)` is cheaper. More in the [Guide](guide.md#pick-events-with-a-function).
|
|
729
|
+
|
|
730
|
+
```js
|
|
731
|
+
import { createBus } from "nanoeffects";
|
|
732
|
+
import { matchPlugin } from "nanoeffects/match";
|
|
733
|
+
|
|
734
|
+
// matchPlugin — on takes a function that picks the events
|
|
735
|
+
const bus = createBus().use(matchPlugin());
|
|
736
|
+
|
|
737
|
+
bus.on(
|
|
738
|
+
(action) => action.type.startsWith("user/"),
|
|
739
|
+
(payload, ctx) => console.log(ctx.type, payload),
|
|
740
|
+
);
|
|
741
|
+
```
|
|
742
|
+
|
|
743
|
+
### Hand the bus to a state manager: `emitterPlugin`
|
|
744
|
+
|
|
745
|
+
`bus.emitter(creator)` is a callback that emits an action. A state manager takes it as its subscription callback, so a change of the store becomes an event. More in [Stores](stores.md#emit-from-a-state-manager).
|
|
746
|
+
|
|
747
|
+
```js
|
|
748
|
+
import { atom } from "nanostores";
|
|
749
|
+
import { createBus } from "nanoeffects";
|
|
750
|
+
import { createAction, emitterPlugin } from "nanoeffects/actions";
|
|
751
|
+
|
|
752
|
+
// emitterPlugin — adds bus.emitter
|
|
753
|
+
const bus = createBus().use(emitterPlugin());
|
|
754
|
+
const nameChanged = createAction("name/changed");
|
|
755
|
+
const $name = atom("");
|
|
756
|
+
|
|
757
|
+
$name.listen(bus.emitter(nameChanged)); // every change of $name emits nameChanged
|
|
758
|
+
```
|
|
759
|
+
|
|
760
|
+
### Tell the bus when a React value changes: `reactPlugin`
|
|
761
|
+
|
|
762
|
+
`useEmitter(event, value)` emits after a render commits with a new value. It does not emit on mount, and not twice under StrictMode. It needs React 18 or newer. More in [Stores](stores.md#react-sharing-a-value-across-components).
|
|
763
|
+
|
|
764
|
+
```jsx
|
|
765
|
+
import { useState } from "react";
|
|
766
|
+
import { createBus } from "nanoeffects";
|
|
767
|
+
import { reactPlugin } from "nanoeffects/react";
|
|
768
|
+
|
|
769
|
+
// reactPlugin — adds bus.useEmitter; destructuring gives a free hook
|
|
770
|
+
const bus = createBus().use(reactPlugin());
|
|
771
|
+
const { useEmitter } = bus;
|
|
772
|
+
|
|
773
|
+
function Profile() {
|
|
774
|
+
const [name, setName] = useState("");
|
|
775
|
+
useEmitter("name/changed", name); // emits after a render with a new name
|
|
776
|
+
return <input value={name} onChange={(event) => setName(event.target.value)} />;
|
|
777
|
+
}
|
|
440
778
|
```
|
|
441
779
|
|
|
442
|
-
|
|
780
|
+
### Find leaks while you develop: `devPlugin`
|
|
781
|
+
|
|
782
|
+
It warns about listeners that were added and never unbound, and reports the modules that are mounted, replaced and unmounted. In a production build `nanoeffects/dev` is empty, so the call can stay. More in [Hot reload](hmr.md#report-leaks-with-devplugin).
|
|
783
|
+
|
|
784
|
+
```js
|
|
785
|
+
import { createBus } from "nanoeffects";
|
|
786
|
+
import { modulePlugin } from "nanoeffects/actions";
|
|
787
|
+
import { devPlugin } from "nanoeffects/dev";
|
|
443
788
|
|
|
444
|
-
|
|
789
|
+
const bus = createBus();
|
|
790
|
+
bus.use(modulePlugin([userModule]));
|
|
445
791
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
| `effectsPlugin` | `nanoeffects/effects` | `race`, `all`, `take`, `delay` and your own effects on `ctx`, bound to the task | The same bus, typed with the effects | [Effects](effects.md#put-selected-effects-on-ctx) |
|
|
450
|
-
| `scopePlugin` | `nanoeffects/scope` | Fields and effects for one command, action, job, module or `emit` | The same bus | [Scopes](scope.md) |
|
|
451
|
-
| `commandsPlugin` | `nanoeffects/actions` | The registry of command handlers, `bus.commands` | The registry `{ run, handle }` | [Actions](actions.md#describe-events-commands-and-jobs) |
|
|
452
|
-
| `modulePlugin` | `nanoeffects/actions` | Modules that mount and unmount as one unit | The app `{ mount, unmount, isMounted, dispose, commands, createModule }` | [Modules](actions.md#mount-domains-with-modules) |
|
|
453
|
-
| `emitterPlugin` | `nanoeffects/actions` | `bus.emitter(creator)`, a callback that emits an action | The same bus, with `emitter` | [Stores](stores.md#emit-from-a-state-manager) |
|
|
454
|
-
| `reactPlugin` | `nanoeffects/react` | `bus.useEmitter(event, value)`, a React hook | The same bus, with `useEmitter` | [React and stores](stores.md#react-sharing-a-value-across-components) |
|
|
455
|
-
| `devPlugin` | `nanoeffects/dev` | Warnings about listeners that pile up, and messages about modules | A function that disconnects it | [Hot reload](hmr.md#report-leaks-with-devplugin) |
|
|
792
|
+
// devPlugin — warnings and messages in the console while you develop;
|
|
793
|
+
// it finds the app of modulePlugin on the bus, whichever you use first
|
|
794
|
+
const disconnect = bus.use(devPlugin());
|
|
456
795
|
|
|
457
|
-
|
|
796
|
+
disconnect(); // stops the diagnostics
|
|
797
|
+
```
|
|
458
798
|
|
|
459
799
|
## Put them together
|
|
460
800
|
|
|
461
|
-
One bus with every plugin that does not need React. The
|
|
801
|
+
One bus with every plugin that does not need React, in a file of its own. The events of the app stay in other files. The plugins work in any order; the next section says what that means for modules.
|
|
462
802
|
|
|
463
803
|
```js
|
|
804
|
+
// bus.js
|
|
464
805
|
import { contextPlugin, createBus } from "nanoeffects";
|
|
465
806
|
import {
|
|
466
807
|
commandsPlugin,
|
|
@@ -469,47 +810,45 @@ import {
|
|
|
469
810
|
} from "nanoeffects/actions";
|
|
470
811
|
import { devPlugin } from "nanoeffects/dev";
|
|
471
812
|
import { all, effectsPlugin, race } from "nanoeffects/effects";
|
|
813
|
+
import { matchPlugin } from "nanoeffects/match";
|
|
472
814
|
import { scopePlugin } from "nanoeffects/scope";
|
|
473
815
|
|
|
474
|
-
const bus = createBus()
|
|
816
|
+
export const bus = createBus()
|
|
475
817
|
.use(contextPlugin({ store })) // ctx.store on every task
|
|
476
818
|
.use(effectsPlugin({ race, all })) // ctx.race and ctx.all, bound to the task
|
|
477
819
|
.use(scopePlugin()) // scopes for commands, actions, jobs, modules and emit
|
|
820
|
+
.use(matchPlugin()) // bus.on takes a function that picks the events
|
|
478
821
|
.use(emitterPlugin()); // bus.emitter(creator)
|
|
479
822
|
|
|
480
823
|
// commandsPlugin — the registry of command handlers; keep it to bind handlers later
|
|
481
|
-
const commands = bus.use(commandsPlugin());
|
|
824
|
+
export const commands = bus.use(commandsPlugin());
|
|
482
825
|
|
|
483
|
-
// modulePlugin — mounts userModule
|
|
484
|
-
const app = bus.use(modulePlugin([userModule]));
|
|
826
|
+
// modulePlugin — mounts userModule on the first use; keep app to mount and unmount later
|
|
827
|
+
export const app = bus.use(modulePlugin([userModule]));
|
|
485
828
|
|
|
486
829
|
// devPlugin — reports leaks and modules while you develop; keep disconnect to remove it
|
|
487
|
-
const disconnect = bus.use(devPlugin(
|
|
830
|
+
export const disconnect = bus.use(devPlugin());
|
|
488
831
|
```
|
|
489
832
|
|
|
490
|
-
##
|
|
833
|
+
## Use them in any order
|
|
834
|
+
|
|
835
|
+
The plugins give the same result in any order. Use them as they read best, and put the app of `modulePlugin` where you keep it.
|
|
491
836
|
|
|
492
|
-
|
|
837
|
+
Two things happen because modules are mounted late:
|
|
493
838
|
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
| `scopePlugin` after `effectsPlugin` | `effectsPlugin` assigns its effects on `ctx`, so one used later would replace an effect with the same name that a scope put there. |
|
|
497
|
-
| `scopePlugin` before `modulePlugin` | `modulePlugin` mounts the modules when it is used, and a module with a scope needs the plugin by then. |
|
|
498
|
-
| `commandsPlugin` before `modulePlugin`, if you bind handlers yourself | `modulePlugin` uses the registry that exists. Without one it makes its own, which is `app.commands`. |
|
|
499
|
-
| `modulePlugin` before `devPlugin({ app })` | `devPlugin` reports the modules of the `app` that `modulePlugin` returned. |
|
|
500
|
-
| `emitterPlugin` and `reactPlugin` anywhere | They only call `bus.emit`, which is read when you call them. |
|
|
839
|
+
- **Modules start on the first use.** `modulePlugin([userModule])` mounts `userModule` when the app or the bus is first used: the first `bus.emit`, or the first call of `app.mount`, `app.unmount`, `app.isMounted`, `app.dispose` or the read of `app.commands`. By then every plugin is on the bus, so a module and the tasks it spawns have the fields, effects and scopes of the plugins you used after `modulePlugin`, and `m.on(fn, handler)` works with `matchPlugin`.
|
|
840
|
+
- **Listeners keep their place.** A `bus.on` after `bus.use(modulePlugin(...))` is held until the modules are mounted, then made, in the order you wrote them. The listeners of a module therefore stay ahead of the ones registered after the plugin, and behind the ones registered before it. The function `bus.on` returns works before and after.
|
|
501
841
|
|
|
502
|
-
|
|
842
|
+
Other plugins do not need an order for another reason:
|
|
503
843
|
|
|
504
|
-
|
|
844
|
+
- `commandsPlugin` binds its list in the registry that the bus has, so it can come before or after `modulePlugin`. A custom `bus.commands = { run }` is replaced.
|
|
845
|
+
- `effectsPlugin` and `contextPlugin` do not replace a field that is on `ctx` already, so a scope wins in any order.
|
|
846
|
+
- `devPlugin()` finds the app that `modulePlugin` put on the bus (`bus.app`). `devPlugin({ app })` still works.
|
|
847
|
+
- `matchPlugin` and `devPlugin` both wrap `bus.on`; `devPlugin` counts a function that picks events under `"*"`, and its `disconnect` leaves a wrapper that was put on top of it.
|
|
505
848
|
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
- **A request with a result, or handlers that a test can replace.** `commandsPlugin`. Add `modulePlugin` when the domain needs a mount and an unmount.
|
|
510
|
-
- **A callback for a state manager, such as `$name.listen(...)`.** `emitterPlugin`.
|
|
511
|
-
- **Tell the bus when a value of a React component changes.** `reactPlugin`.
|
|
512
|
-
- **Find listeners that were never unbound, or see modules reload.** `devPlugin`. In a production build `nanoeffects/dev` is empty, so the call can stay.
|
|
849
|
+
A name that is already a context field of the bus is a type error when `contextPlugin` or `effectsPlugin` adds it again.
|
|
850
|
+
|
|
851
|
+
A test puts every plugin on a bus in many orders and checks what each one does (`src/tests/plugin-matrix.test.ts`). An order that breaks something is a bug.
|
|
513
852
|
|
|
514
853
|
## See what is not a plugin
|
|
515
854
|
|
|
@@ -517,8 +856,38 @@ These connect the bus to something, but are not used with `bus.use`:
|
|
|
517
856
|
|
|
518
857
|
- `createEffectsMiddleware(bus, { filter })` from `nanoeffects/redux` is a Redux middleware for a bus that exists. See [Stores](stores.md#wire-redux-toolkit-to-a-bus).
|
|
519
858
|
- `createBus({ trace })` takes a tracer from `nanoeffects/trace`. See [Tracing](tracing.md).
|
|
859
|
+
- `extendBus(bus, Base)` from `nanoeffects/oop` gives a class the methods of a bus, with the subscriptions of each instance ended by `dispose()`. It reads the bus as it is, so use the plugins first. See [Use the bus from a class](oop.md).
|
|
520
860
|
- `createObserve(bus)` from `nanoeffects/observable` turns an event into an Observable. See [Observable](observable.md).
|
|
521
861
|
|
|
862
|
+
## Write your own plugin
|
|
863
|
+
|
|
864
|
+
A plugin is a function of the bus that returns what you want back. The bus keeps no list of plugins, so a plugin that was used cannot be taken out by the bus; it returns its own `stop` or `disconnect` when it needs one.
|
|
865
|
+
|
|
866
|
+
```js
|
|
867
|
+
// count-plugin.js
|
|
868
|
+
// your own plugin: a function of the bus that returns what you want back
|
|
869
|
+
export const countPlugin = (bus) => {
|
|
870
|
+
let count = 0;
|
|
871
|
+
const unbind = bus.on("*", () => {
|
|
872
|
+
count += 1;
|
|
873
|
+
});
|
|
874
|
+
return { count: () => count, stop: unbind };
|
|
875
|
+
};
|
|
876
|
+
```
|
|
877
|
+
|
|
878
|
+
```js
|
|
879
|
+
import { createBus } from "nanoeffects";
|
|
880
|
+
import { countPlugin } from "./count-plugin";
|
|
881
|
+
|
|
882
|
+
const bus = createBus();
|
|
883
|
+
// use — calls countPlugin(bus) and returns its result
|
|
884
|
+
const counter = bus.use(countPlugin);
|
|
885
|
+
|
|
886
|
+
bus.emit("greet", "Ada");
|
|
887
|
+
console.log(counter.count()); // 1
|
|
888
|
+
counter.stop();
|
|
889
|
+
```
|
|
890
|
+
|
|
522
891
|
---
|
|
523
892
|
Source: docs/policies.md
|
|
524
893
|
|
|
@@ -639,18 +1008,408 @@ Every call returns a promise with the result of its own run. An error rejects on
|
|
|
639
1008
|
- When a running call aborts, its slot is freed at once and its result is dropped.
|
|
640
1009
|
- Pass `signal` into what `fn` does. A `fn` that ignores it can overlap with the next run.
|
|
641
1010
|
|
|
642
|
-
|
|
643
|
-
Source: docs/effects.md
|
|
644
|
-
|
|
645
|
-
# Effects and cancellation
|
|
1011
|
+
## Use a policy without a bus
|
|
646
1012
|
|
|
647
|
-
|
|
1013
|
+
**Problem.** You want a debounced input handler or a "latest request wins" fetch, but your code has no bus. A policy needs a context on every call, so `debounce(300, search)("re")` does not work on its own.
|
|
648
1014
|
|
|
649
|
-
|
|
1015
|
+
**When.** One component or module needs a policy and nothing else from the library. If you already have a bus, `bus.on(event, policy)` is simpler.
|
|
650
1016
|
|
|
651
|
-
|
|
1017
|
+
`withPolicy` takes the result of any policy and returns a plain function. It needs no bus and no context argument, and it keeps the state of the policy between calls.
|
|
652
1018
|
|
|
653
|
-
|
|
1019
|
+
```js
|
|
1020
|
+
import { debounce, withPolicy } from "nanoeffects/policies";
|
|
1021
|
+
|
|
1022
|
+
// debounce — waits for 300 ms of quiet, then runs the callback
|
|
1023
|
+
const policy = debounce(300, async (query, { signal }) => {
|
|
1024
|
+
await api.search(query, { signal });
|
|
1025
|
+
});
|
|
1026
|
+
|
|
1027
|
+
// withPolicy — a plain function: no bus, no context argument
|
|
1028
|
+
const onChange = withPolicy(policy);
|
|
1029
|
+
|
|
1030
|
+
onChange("re");
|
|
1031
|
+
onChange("redux"); // only this call runs, 300 ms after it
|
|
1032
|
+
|
|
1033
|
+
// cancel — drops a pending call and aborts a running one
|
|
1034
|
+
onChange.cancel();
|
|
1035
|
+
```
|
|
1036
|
+
|
|
1037
|
+
- The callback has the same `(payload, ctx)` shape as for a bus. `ctx.signal`, `ctx.abort` and `ctx.run` work. `ctx.emit` and `ctx.on` do nothing, because there is no bus.
|
|
1038
|
+
- Every call returns a promise with the result. A call the policy drops resolves with `undefined`.
|
|
1039
|
+
- `cancel()` aborts every call that has not finished. Calls made after it work again.
|
|
1040
|
+
- Where `Symbol.dispose` exists, `using onChange = withPolicy(policy)` calls `cancel()` at the end of the scope.
|
|
1041
|
+
|
|
1042
|
+
In a React component, use [`usePolicy`](react.md#keep-a-debounced-handler-between-renders) instead: it keeps the function between renders.
|
|
1043
|
+
|
|
1044
|
+
---
|
|
1045
|
+
Source: docs/cancel-stale-requests.md
|
|
1046
|
+
|
|
1047
|
+
# Cancel stale requests and fix race conditions
|
|
1048
|
+
|
|
1049
|
+
Read this page if an older response can overwrite a newer one: a search box that shows the results of the previous query, a filter that flickers, a form that submits twice. It shows the cause, the fix with `latest`, and how to add a pause, a retry and a timeout. It needs only the bus from the [README](../README.md).
|
|
1050
|
+
|
|
1051
|
+
## See the problem
|
|
1052
|
+
|
|
1053
|
+
A user types `ab`, then `abc`. Two requests start. If the one for `ab` is slower, it finishes last and its results replace the right ones.
|
|
1054
|
+
|
|
1055
|
+
```js
|
|
1056
|
+
import { createBus } from "nanoeffects";
|
|
1057
|
+
|
|
1058
|
+
const bus = createBus(); // the bus: events and handlers
|
|
1059
|
+
|
|
1060
|
+
// Without a policy every "search" starts its own request, and the slowest one wins.
|
|
1061
|
+
bus.on("search", async (query) => {
|
|
1062
|
+
const items = await api.search(query);
|
|
1063
|
+
bus.emit("results", items);
|
|
1064
|
+
});
|
|
1065
|
+
```
|
|
1066
|
+
|
|
1067
|
+
Checking a counter by hand (`if (id !== lastId) return`) hides the old result, but the old request still runs. Calling `controller.abort()` by hand works, but you must keep the controller somewhere and abort it before each new call.
|
|
1068
|
+
|
|
1069
|
+
## Keep only the latest request
|
|
1070
|
+
|
|
1071
|
+
Wrap the handler in `latest`. A new `"search"` aborts the task of the previous one, and an aborted task delivers no result.
|
|
1072
|
+
|
|
1073
|
+
```js
|
|
1074
|
+
import { createBus } from "nanoeffects";
|
|
1075
|
+
import { latest } from "nanoeffects/policies";
|
|
1076
|
+
|
|
1077
|
+
const bus = createBus(); // the bus: events and handlers
|
|
1078
|
+
|
|
1079
|
+
// latest: a newer "search" aborts the older run
|
|
1080
|
+
bus.on(
|
|
1081
|
+
"search",
|
|
1082
|
+
latest(async (query, { signal }) => {
|
|
1083
|
+
// signal: pass it on so the browser cancels the request, not only drops the result
|
|
1084
|
+
const items = await api.search(query, { signal });
|
|
1085
|
+
bus.emit("results", items);
|
|
1086
|
+
}),
|
|
1087
|
+
);
|
|
1088
|
+
|
|
1089
|
+
bus.on("results", (items) => render(items));
|
|
1090
|
+
```
|
|
1091
|
+
|
|
1092
|
+
What to replace: `api.search` is your own request function. It must accept `{ signal }` and give it to `fetch`. Without it, `latest` still ignores the old result, but the request keeps running.
|
|
1093
|
+
|
|
1094
|
+
Result: `emit("search", "ab")` followed by `emit("search", "abc")` shows only the results for `abc`, and the request for `ab` is cancelled.
|
|
1095
|
+
|
|
1096
|
+
## Wait for a pause in typing
|
|
1097
|
+
|
|
1098
|
+
Add `debounce` outside `latest`. The search starts after 300 ms without a new keystroke.
|
|
1099
|
+
|
|
1100
|
+
```js
|
|
1101
|
+
import { createBus } from "nanoeffects";
|
|
1102
|
+
import { debounce, latest } from "nanoeffects/policies";
|
|
1103
|
+
|
|
1104
|
+
const bus = createBus(); // the bus: events and handlers
|
|
1105
|
+
|
|
1106
|
+
// latest: a newer search aborts the request in flight
|
|
1107
|
+
const searchLatest = latest(async (query, { signal }) => {
|
|
1108
|
+
const items = await api.search(query, { signal });
|
|
1109
|
+
bus.emit("results", items);
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
// debounce: run after 300 ms without a new "typed"
|
|
1113
|
+
bus.on("typed", debounce(300, searchLatest));
|
|
1114
|
+
```
|
|
1115
|
+
|
|
1116
|
+
## Retry a failed request and limit its time
|
|
1117
|
+
|
|
1118
|
+
Use `retry` and `timeout` around the request. The pause grows with each attempt.
|
|
1119
|
+
|
|
1120
|
+
```js
|
|
1121
|
+
import { createBus } from "nanoeffects";
|
|
1122
|
+
import { latest } from "nanoeffects/policies";
|
|
1123
|
+
import { backoff, retry, timeout } from "nanoeffects/effects";
|
|
1124
|
+
|
|
1125
|
+
const bus = createBus(); // the bus: events and handlers
|
|
1126
|
+
|
|
1127
|
+
// timeout: abort one attempt after 5 s
|
|
1128
|
+
const searchOnce = timeout(5000, (query, { signal }) => api.search(query, { signal }));
|
|
1129
|
+
|
|
1130
|
+
// retry: up to 4 attempts, backoff makes the pause longer each time
|
|
1131
|
+
const searchWithRetry = retry(searchOnce, {
|
|
1132
|
+
times: 4,
|
|
1133
|
+
delay: backoff({ base: 200, max: 5000, jitter: true }),
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
// latest: a newer search aborts the whole retry chain
|
|
1137
|
+
bus.on(
|
|
1138
|
+
"search",
|
|
1139
|
+
latest(async (query, ctx) => {
|
|
1140
|
+
const items = await ctx.run(searchWithRetry, query); // ctx.run: a child task, aborted with this one
|
|
1141
|
+
ctx.emit("results", items);
|
|
1142
|
+
}),
|
|
1143
|
+
);
|
|
1144
|
+
```
|
|
1145
|
+
|
|
1146
|
+
## Pick another policy for another race
|
|
1147
|
+
|
|
1148
|
+
| Situation | Policy |
|
|
1149
|
+
| --------- | ------ |
|
|
1150
|
+
| Only the newest result matters (search, filter, tab switch) | `latest` |
|
|
1151
|
+
| A second click while the first call runs must be ignored (submit, pay) | `leading` |
|
|
1152
|
+
| Every call must run, one after another (save, upload) | `queue` |
|
|
1153
|
+
| Calls come in bursts and you want one at the end | `debounce` |
|
|
1154
|
+
| Calls come in bursts and you want one per period | `throttle` |
|
|
1155
|
+
| Each user or item has its own race | add `{ key }`, for example `latest(handler, { key })` |
|
|
1156
|
+
|
|
1157
|
+
All policies and their options: [Policies](policies.md). The same step by step in a form: [Build a sign-up form in React](react.md).
|
|
1158
|
+
|
|
1159
|
+
---
|
|
1160
|
+
Source: docs/debounce-and-abort.md
|
|
1161
|
+
|
|
1162
|
+
# Debounce a request and abort the previous one
|
|
1163
|
+
|
|
1164
|
+
Read this page if you debounce user input (a search box, an autosave, a resize handler) and you also want the request in flight to stop when a new one starts. `debounce` alone waits for a pause. It does not cancel a request that already started. This page shows the two policies together, the order to put them in, and when to use `throttle` instead.
|
|
1165
|
+
|
|
1166
|
+
## Know what each policy does
|
|
1167
|
+
|
|
1168
|
+
| Policy | What it does | What it does not do |
|
|
1169
|
+
| ------ | ------------ | ------------------- |
|
|
1170
|
+
| `debounce(ms, fn)` | runs `fn` after `ms` without a new call | does not cancel a run that already started |
|
|
1171
|
+
| `latest(fn)` | aborts the previous run when a new one starts | does not wait for a pause |
|
|
1172
|
+
|
|
1173
|
+
Use both for a search box. `debounce` sends fewer requests. `latest` cancels the slow one when the user types again after the pause.
|
|
1174
|
+
|
|
1175
|
+
## Combine them
|
|
1176
|
+
|
|
1177
|
+
Put `debounce` outside and `latest` inside. The event waits for a pause first. When the run starts, it aborts the previous run.
|
|
1178
|
+
|
|
1179
|
+
```js
|
|
1180
|
+
import { createBus } from "nanoeffects";
|
|
1181
|
+
import { debounce, latest } from "nanoeffects/policies";
|
|
1182
|
+
|
|
1183
|
+
const bus = createBus(); // the bus: events and handlers
|
|
1184
|
+
|
|
1185
|
+
// latest: a newer run aborts the older request
|
|
1186
|
+
const searchLatest = latest(async (query, { signal }) => {
|
|
1187
|
+
// signal: pass it on so the browser cancels the request itself
|
|
1188
|
+
const items = await api.search(query, { signal });
|
|
1189
|
+
bus.emit("results", items);
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1192
|
+
// debounce: run searchLatest after 300 ms without a new "typed"
|
|
1193
|
+
bus.on("typed", debounce(300, searchLatest));
|
|
1194
|
+
|
|
1195
|
+
bus.on("results", (items) => render(items));
|
|
1196
|
+
```
|
|
1197
|
+
|
|
1198
|
+
What to replace: `api.search` is your own request function. It must accept `{ signal }` and give it to `fetch`. `render` shows the items.
|
|
1199
|
+
|
|
1200
|
+
Result: typing `a`, `ab`, `abc` within 300 ms sends one request for `abc`. If the user types `abcd` while that request runs, the request for `abc` is aborted and one for `abcd` starts after the pause.
|
|
1201
|
+
|
|
1202
|
+
## Run the first call at once
|
|
1203
|
+
|
|
1204
|
+
By default `debounce` waits even before the first call. For an input where the first key press should react at once, add `leading: true`. The first call of a burst runs immediately, and the last one runs after the pause.
|
|
1205
|
+
|
|
1206
|
+
```js
|
|
1207
|
+
import { createBus } from "nanoeffects";
|
|
1208
|
+
import { debounce } from "nanoeffects/policies";
|
|
1209
|
+
|
|
1210
|
+
const bus = createBus(); // the bus: events and handlers
|
|
1211
|
+
|
|
1212
|
+
const showSuggestions = (query) => console.log("suggest", query);
|
|
1213
|
+
|
|
1214
|
+
// debounce with leading: the first call runs at once, the last one after 300 ms of quiet
|
|
1215
|
+
bus.on("typed", debounce(300, showSuggestions, { leading: true }));
|
|
1216
|
+
```
|
|
1217
|
+
|
|
1218
|
+
`leading` and `trailing` follow lodash. All combinations: [Policies](policies.md#configure-debounce-and-throttle).
|
|
1219
|
+
|
|
1220
|
+
## Use throttle for continuous input
|
|
1221
|
+
|
|
1222
|
+
`debounce` never runs while calls keep coming. For a scroll or a drag you want updates during the movement. Use `throttle`: at most one run per period.
|
|
1223
|
+
|
|
1224
|
+
```js
|
|
1225
|
+
import { createBus } from "nanoeffects";
|
|
1226
|
+
import { throttle } from "nanoeffects/policies";
|
|
1227
|
+
|
|
1228
|
+
const bus = createBus(); // the bus: events and handlers
|
|
1229
|
+
|
|
1230
|
+
const saveScroll = (position) => localStorage.setItem("scroll", String(position));
|
|
1231
|
+
|
|
1232
|
+
// throttle: at most one run per 100 ms while "scrolled" keeps coming
|
|
1233
|
+
bus.on("scrolled", throttle(100, saveScroll));
|
|
1234
|
+
```
|
|
1235
|
+
|
|
1236
|
+
## Debounce without a bus
|
|
1237
|
+
|
|
1238
|
+
If the page has no bus, wrap the same pair with `withPolicy`. The result is a plain function that you call from an input handler.
|
|
1239
|
+
|
|
1240
|
+
```js
|
|
1241
|
+
import { debounce, latest, withPolicy } from "nanoeffects/policies";
|
|
1242
|
+
|
|
1243
|
+
// latest: a newer run aborts the older request
|
|
1244
|
+
const searchLatest = latest(async (query, { signal }) => {
|
|
1245
|
+
const items = await api.search(query, { signal });
|
|
1246
|
+
console.log(items);
|
|
1247
|
+
});
|
|
1248
|
+
|
|
1249
|
+
// withPolicy: debounce by 300 ms, called as a plain function
|
|
1250
|
+
const onTyped = withPolicy(debounce(300, searchLatest));
|
|
1251
|
+
|
|
1252
|
+
onTyped("a");
|
|
1253
|
+
onTyped("ab"); // only "ab" runs, 300 ms later
|
|
1254
|
+
|
|
1255
|
+
// cancel: drop a pending call and abort a running request, for example on page leave
|
|
1256
|
+
onTyped.cancel();
|
|
1257
|
+
```
|
|
1258
|
+
|
|
1259
|
+
In a React component use [`usePolicy`](react.md#keep-a-debounced-handler-between-renders), which also cancels on unmount.
|
|
1260
|
+
|
|
1261
|
+
## Check these when it does not cancel
|
|
1262
|
+
|
|
1263
|
+
- The request did not get `signal`. `latest` then drops the old result but the request goes on.
|
|
1264
|
+
- The order is reversed. Write `debounce(300, latest(handler))`, as above, so `debounce` receives the events and `latest` guards the request.
|
|
1265
|
+
- Several inputs share one event. Give each its own event name, or use `{ key }`, for example `latest(handler, { key: (input) => input.id })`.
|
|
1266
|
+
|
|
1267
|
+
Related: [Cancel stale requests](cancel-stale-requests.md), [Policies](policies.md).
|
|
1268
|
+
|
|
1269
|
+
---
|
|
1270
|
+
Source: docs/prevent-double-submit.md
|
|
1271
|
+
|
|
1272
|
+
# Prevent a double submit
|
|
1273
|
+
|
|
1274
|
+
Read this page if a second click on "Pay", "Sign up" or "Save" sends the request twice. It shows how to ignore the second click while the first request runs, how to do it per item, and when to queue the calls instead.
|
|
1275
|
+
|
|
1276
|
+
## See the problem
|
|
1277
|
+
|
|
1278
|
+
A user clicks twice. Both clicks run the handler, and the server gets two requests.
|
|
1279
|
+
|
|
1280
|
+
```js
|
|
1281
|
+
import { createBus } from "nanoeffects";
|
|
1282
|
+
|
|
1283
|
+
const bus = createBus(); // the bus: events and handlers
|
|
1284
|
+
|
|
1285
|
+
// Without a policy every "submitted" starts its own request.
|
|
1286
|
+
bus.on("submitted", async (form) => {
|
|
1287
|
+
const order = await api.pay(form);
|
|
1288
|
+
bus.emit("paid", order);
|
|
1289
|
+
});
|
|
1290
|
+
```
|
|
1291
|
+
|
|
1292
|
+
A `disabled` button helps only until the next render. A flag in a variable (`if (busy) return`) must be reset on every error path and after an abort.
|
|
1293
|
+
|
|
1294
|
+
## Ignore clicks while a request runs
|
|
1295
|
+
|
|
1296
|
+
Wrap the handler in `leading`. While one run is going, new calls are dropped. When the run ends, the next click works.
|
|
1297
|
+
|
|
1298
|
+
```js
|
|
1299
|
+
import { createBus } from "nanoeffects";
|
|
1300
|
+
import { leading } from "nanoeffects/policies";
|
|
1301
|
+
|
|
1302
|
+
const bus = createBus(); // the bus: events and handlers
|
|
1303
|
+
|
|
1304
|
+
// leading: a call is dropped while a previous run is still going
|
|
1305
|
+
bus.on(
|
|
1306
|
+
"submitted",
|
|
1307
|
+
leading(async (form, ctx) => {
|
|
1308
|
+
const order = await api.pay(form, { signal: ctx.signal });
|
|
1309
|
+
ctx.emit("paid", order);
|
|
1310
|
+
}),
|
|
1311
|
+
);
|
|
1312
|
+
```
|
|
1313
|
+
|
|
1314
|
+
What to replace: `api.pay` is your own request function.
|
|
1315
|
+
|
|
1316
|
+
Result: `emit("submitted", form)` twice in a row sends one request. The second call returns at once and does nothing.
|
|
1317
|
+
|
|
1318
|
+
`leading` frees its lock when the task ends or aborts. If the handler throws, the next click works again, with no flag to reset.
|
|
1319
|
+
|
|
1320
|
+
## Allow one run per item
|
|
1321
|
+
|
|
1322
|
+
A list of rows, each with its own "Delete" button, should not block the whole list. Give each item its own state with `key`.
|
|
1323
|
+
|
|
1324
|
+
```js
|
|
1325
|
+
import { createBus } from "nanoeffects";
|
|
1326
|
+
import { leading } from "nanoeffects/policies";
|
|
1327
|
+
|
|
1328
|
+
const bus = createBus(); // the bus: events and handlers
|
|
1329
|
+
|
|
1330
|
+
// leading with key: one running call per row id, other rows are not blocked
|
|
1331
|
+
bus.on(
|
|
1332
|
+
"row/deleted",
|
|
1333
|
+
leading(
|
|
1334
|
+
async (row, ctx) => {
|
|
1335
|
+
await api.deleteRow(row.id, { signal: ctx.signal });
|
|
1336
|
+
ctx.emit("row/removed", row.id);
|
|
1337
|
+
},
|
|
1338
|
+
{ key: (row) => row.id },
|
|
1339
|
+
),
|
|
1340
|
+
);
|
|
1341
|
+
```
|
|
1342
|
+
|
|
1343
|
+
## Retry only when the network failed
|
|
1344
|
+
|
|
1345
|
+
A retry after a failed payment can charge twice if the server did run the first request. Retry only on errors where the request did not reach the server, and make the server accept an idempotency key.
|
|
1346
|
+
|
|
1347
|
+
```js
|
|
1348
|
+
import { createBus } from "nanoeffects";
|
|
1349
|
+
import { leading } from "nanoeffects/policies";
|
|
1350
|
+
import { retry } from "nanoeffects/effects";
|
|
1351
|
+
|
|
1352
|
+
const bus = createBus(); // the bus: events and handlers
|
|
1353
|
+
|
|
1354
|
+
// fetch rejects with a TypeError when the network fails; an HTTP error is a plain Error
|
|
1355
|
+
const isNetworkError = (error) => error instanceof TypeError;
|
|
1356
|
+
|
|
1357
|
+
bus.on(
|
|
1358
|
+
"submitted",
|
|
1359
|
+
leading(async (form, ctx) => {
|
|
1360
|
+
const pay = () => api.pay(form, { signal: ctx.signal });
|
|
1361
|
+
// retry: up to 3 tries, 500 ms apart, only when the network failed
|
|
1362
|
+
const order = await retry(ctx, pay, { times: 3, delay: 500, shouldRetry: isNetworkError });
|
|
1363
|
+
ctx.emit("paid", order);
|
|
1364
|
+
}),
|
|
1365
|
+
);
|
|
1366
|
+
```
|
|
1367
|
+
|
|
1368
|
+
Backoff between tries: [Back off between retries](effects.md#back-off-between-retries).
|
|
1369
|
+
|
|
1370
|
+
## Guard a handler when you have no bus
|
|
1371
|
+
|
|
1372
|
+
`withPolicy` turns a policy into a plain function, so a click handler needs no bus.
|
|
1373
|
+
|
|
1374
|
+
```js
|
|
1375
|
+
import { leading, withPolicy } from "nanoeffects/policies";
|
|
1376
|
+
|
|
1377
|
+
// leading: ignore new calls while a run is going
|
|
1378
|
+
const policy = leading(async (form, { signal }) => {
|
|
1379
|
+
await api.save(form, { signal });
|
|
1380
|
+
});
|
|
1381
|
+
|
|
1382
|
+
// withPolicy: a plain function to call from onSubmit
|
|
1383
|
+
const submit = withPolicy(policy);
|
|
1384
|
+
|
|
1385
|
+
submit({ name: "Ada" }); // runs
|
|
1386
|
+
submit({ name: "Ada" }); // ignored while the first runs; resolves with undefined
|
|
1387
|
+
```
|
|
1388
|
+
|
|
1389
|
+
In a React component use [`usePolicy`](react.md#keep-a-debounced-handler-between-renders).
|
|
1390
|
+
|
|
1391
|
+
## Pick another policy when clicks must not be lost
|
|
1392
|
+
|
|
1393
|
+
| You want | Policy |
|
|
1394
|
+
| -------- | ------ |
|
|
1395
|
+
| Ignore a second click while the first runs (pay, sign up) | `leading` |
|
|
1396
|
+
| Run every click, one after another (save a draft, upload files) | `queue` |
|
|
1397
|
+
| Keep only the newest click and cancel the older (filters, tabs) | `latest` |
|
|
1398
|
+
|
|
1399
|
+
Related: [Policies](policies.md), [Build a sign-up form in React](react.md).
|
|
1400
|
+
|
|
1401
|
+
---
|
|
1402
|
+
Source: docs/effects.md
|
|
1403
|
+
|
|
1404
|
+
# Effects and cancellation
|
|
1405
|
+
|
|
1406
|
+
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.
|
|
1407
|
+
|
|
1408
|
+
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)).
|
|
1409
|
+
|
|
1410
|
+
## Wrap a handler with retry and timeout
|
|
1411
|
+
|
|
1412
|
+
`retry`, `timeout`, and `poll` have two forms:
|
|
654
1413
|
|
|
655
1414
|
- **Task wrapper** — take a function `(payload, ctx) => result` and return one. Pass them into `on` next to policies (no `ctx` in their arguments).
|
|
656
1415
|
- **Call inside a body** — take `ctx` first.
|
|
@@ -990,14 +1749,28 @@ Recipe without a UI framework: [`examples/modules.ts`](../examples/modules.ts).
|
|
|
990
1749
|
|
|
991
1750
|
## Return a value from `emit` with a command
|
|
992
1751
|
|
|
1752
|
+
The bus lives in one file and its events in another, so any module can import both without a cycle.
|
|
1753
|
+
|
|
993
1754
|
```js
|
|
1755
|
+
// bus.js
|
|
994
1756
|
import { createBus } from "nanoeffects";
|
|
995
|
-
import { createCommand } from "nanoeffects/actions";
|
|
996
1757
|
|
|
997
|
-
|
|
1758
|
+
// createBus — one bus for the app
|
|
1759
|
+
export const bus = createBus();
|
|
1760
|
+
```
|
|
1761
|
+
|
|
1762
|
+
```js
|
|
1763
|
+
// math.js
|
|
1764
|
+
import { createCommand } from "nanoeffects/actions";
|
|
998
1765
|
|
|
999
1766
|
// createCommand — emit runs the handler and returns its result
|
|
1000
|
-
const double = createCommand("math/double", (value) => value * 2);
|
|
1767
|
+
export const double = createCommand("math/double", (value) => value * 2);
|
|
1768
|
+
```
|
|
1769
|
+
|
|
1770
|
+
```js
|
|
1771
|
+
import { bus } from "./bus";
|
|
1772
|
+
import { double } from "./math";
|
|
1773
|
+
|
|
1001
1774
|
bus.emit(double(21)); // 42
|
|
1002
1775
|
```
|
|
1003
1776
|
|
|
@@ -1006,27 +1779,30 @@ bus.emit(double(21)); // 42
|
|
|
1006
1779
|
`nanoeffects/actions` has the pieces to name what a bus does, apart from the code that does it.
|
|
1007
1780
|
|
|
1008
1781
|
```js
|
|
1782
|
+
// user.js
|
|
1009
1783
|
import { createAction, createCommand, createJob } from "nanoeffects/actions";
|
|
1010
1784
|
|
|
1011
1785
|
// createAction — event with a payload (like RTK createAction)
|
|
1012
|
-
const userLoaded = createAction("user/loaded");
|
|
1013
|
-
const loadFailed = createAction("user/failed");
|
|
1786
|
+
export const userLoaded = createAction("user/loaded");
|
|
1787
|
+
export const loadFailed = createAction("user/failed");
|
|
1014
1788
|
|
|
1015
1789
|
// createCommand with a handler — emit returns what the handler returns
|
|
1016
|
-
const getUser = createCommand("user/get", (id, { signal }) => {
|
|
1790
|
+
export const getUser = createCommand("user/get", (id, { signal }) => {
|
|
1017
1791
|
return fetchUser(id, { signal });
|
|
1018
1792
|
});
|
|
1019
1793
|
|
|
1020
1794
|
// createCommand without a handler — a contract; register the work elsewhere
|
|
1021
|
-
const sendMail = createCommand("mail/send");
|
|
1795
|
+
export const sendMail = createCommand("mail/send");
|
|
1022
1796
|
|
|
1023
1797
|
// createJob — command that also emits success / fail actions
|
|
1024
|
-
const loadUser = createJob("user/load", fetchUser, {
|
|
1798
|
+
export const loadUser = createJob("user/load", fetchUser, {
|
|
1025
1799
|
success: userLoaded,
|
|
1026
1800
|
fail: "user/failed",
|
|
1027
1801
|
});
|
|
1028
1802
|
```
|
|
1029
1803
|
|
|
1804
|
+
These are the events of the user domain, in a file of their own (`user.js`). The bus is made in another file (`bus.js` below), and neither imports the other.
|
|
1805
|
+
|
|
1030
1806
|
### Actions
|
|
1031
1807
|
|
|
1032
1808
|
`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")`.
|
|
@@ -1047,22 +1823,22 @@ Calling a command builds `{ type, payload }` like `createAction`. `bus.emit(comm
|
|
|
1047
1823
|
**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.
|
|
1048
1824
|
|
|
1049
1825
|
```js
|
|
1826
|
+
// bus.js
|
|
1050
1827
|
import { createBus } from "nanoeffects";
|
|
1051
|
-
import {
|
|
1052
|
-
|
|
1053
|
-
commandsPlugin,
|
|
1054
|
-
createCommand,
|
|
1055
|
-
} from "nanoeffects/actions";
|
|
1828
|
+
import { bindHandler, commandsPlugin } from "nanoeffects/actions";
|
|
1829
|
+
import { sendMail } from "./user";
|
|
1056
1830
|
|
|
1057
|
-
const
|
|
1058
|
-
const getUser = createCommand("user/get");
|
|
1059
|
-
|
|
1060
|
-
const bus = createBus();
|
|
1831
|
+
export const bus = createBus();
|
|
1061
1832
|
|
|
1062
1833
|
// commandsPlugin — registry of handlers for this bus
|
|
1063
|
-
const commands = bus.use(
|
|
1834
|
+
export const commands = bus.use(
|
|
1064
1835
|
commandsPlugin([bindHandler(sendMail, smtpSend)]),
|
|
1065
1836
|
);
|
|
1837
|
+
```
|
|
1838
|
+
|
|
1839
|
+
```js
|
|
1840
|
+
import { commands } from "./bus";
|
|
1841
|
+
import { getUser } from "./user";
|
|
1066
1842
|
|
|
1067
1843
|
// later, e.g. in a test — latest binding wins
|
|
1068
1844
|
const unbind = commands.handle(getUser, async () => fakeUser);
|
|
@@ -1071,19 +1847,88 @@ unbind();
|
|
|
1071
1847
|
|
|
1072
1848
|
A command with no handler anywhere throws `No handler for command "..."` from `emit` (the call itself only builds the action).
|
|
1073
1849
|
|
|
1074
|
-
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
|
|
1850
|
+
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 `commandsPlugin` binds its list in that registry and returns it. There is no `commands` option in `createBus`; assign a custom runner with `bus.commands = { run }`.
|
|
1075
1851
|
|
|
1076
1852
|
### Jobs
|
|
1077
1853
|
|
|
1078
|
-
`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.
|
|
1854
|
+
`createJob(type, handler, { pending, success, fail, cancel })` makes a command that also sends its outcome as actions, when nobody waits for the result. It returns the command with `loadUser.pending`, `loadUser.success`, `loadUser.fail` and `loadUser.cancel` on it.
|
|
1079
1855
|
|
|
1856
|
+
- When the handler starts, emits `loadUser.pending(payload)`.
|
|
1080
1857
|
- On success, emits `loadUser.success(result)`.
|
|
1081
1858
|
- On failure, emits `loadUser.fail(error)`.
|
|
1082
|
-
-
|
|
1859
|
+
- When the task is aborted before the handler settles, emits `loadUser.cancel(reason)` instead of `success` or `fail`. After `pending`, exactly one of the three follows.
|
|
1860
|
+
- `pending` / `success` / `fail` / `cancel` are a string (job makes an action creator) or an existing action creator. Missing ones become `<type>/pending`, `<type>/success`, `<type>/fail` or `<type>/cancel`.
|
|
1083
1861
|
- `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.
|
|
1084
|
-
- Outcome actions are emitted as the task that ran the job (also for a handler bound with `commands.handle`).
|
|
1085
|
-
- A missing handler is not an outcome: `emit` throws `No handler for command "..."
|
|
1862
|
+
- Outcome actions are emitted as the task that ran the job (also for a handler bound with `commands.handle`). `success` and `fail` are not sent after abort; `cancel` is, once the handler settles.
|
|
1863
|
+
- A missing handler is not an outcome: `emit` throws `No handler for command "..."` and nothing is sent.
|
|
1086
1864
|
- Calling the job only builds `{ type, payload }`; the handler runs on `emit`.
|
|
1865
|
+
- The outcomes are events like any other: with `onUnhandled` set on the bus, it is called for a `pending` or a `cancel` that nobody listens to.
|
|
1866
|
+
|
|
1867
|
+
## Show a loading state with a job
|
|
1868
|
+
|
|
1869
|
+
**Problem.** A spinner needs a flag that is `true` while a request runs. Setting it by hand in the handler (`loading = true`, then `try` / `finally`) breaks when the task is aborted: `ctx.emit` does nothing after an abort, so a flag that is cleared through the bus stays `true`.
|
|
1870
|
+
|
|
1871
|
+
**When.** The UI shows a loading state for a request, and the request can be aborted (a page is left, `ctx.abort()` is called). Skip it when nothing shows the state.
|
|
1872
|
+
|
|
1873
|
+
A job sends one action when the handler starts and one when it ends, whatever way it ends. A store sets the flag on `pending` and clears it on `success`, `fail` and `cancel`.
|
|
1874
|
+
|
|
1875
|
+
```js
|
|
1876
|
+
// bus.js
|
|
1877
|
+
import { createBus } from "nanoeffects";
|
|
1878
|
+
|
|
1879
|
+
// createBus — the bus the app shares
|
|
1880
|
+
export const bus = createBus();
|
|
1881
|
+
```
|
|
1882
|
+
|
|
1883
|
+
```js
|
|
1884
|
+
// user.js
|
|
1885
|
+
import { createJob } from "nanoeffects/actions";
|
|
1886
|
+
|
|
1887
|
+
// createJob — a command that also sends pending, success, fail and cancel
|
|
1888
|
+
export const loadUser = createJob("user/load", fetchUser);
|
|
1889
|
+
```
|
|
1890
|
+
|
|
1891
|
+
```js
|
|
1892
|
+
// user-store.js
|
|
1893
|
+
import { bus } from "./bus";
|
|
1894
|
+
import { loadUser } from "./user";
|
|
1895
|
+
|
|
1896
|
+
export const state = { loading: false, user: null };
|
|
1897
|
+
|
|
1898
|
+
// pending — the handler starts: show the spinner
|
|
1899
|
+
bus.on(loadUser.pending, () => {
|
|
1900
|
+
state.loading = true;
|
|
1901
|
+
});
|
|
1902
|
+
|
|
1903
|
+
// success — the handler returned a user
|
|
1904
|
+
bus.on(loadUser.success, (user) => {
|
|
1905
|
+
state.loading = false;
|
|
1906
|
+
state.user = user;
|
|
1907
|
+
});
|
|
1908
|
+
|
|
1909
|
+
// fail — the handler threw
|
|
1910
|
+
bus.on(loadUser.fail, () => {
|
|
1911
|
+
state.loading = false;
|
|
1912
|
+
});
|
|
1913
|
+
|
|
1914
|
+
// cancel — the task was aborted: the only way to clear the flag then
|
|
1915
|
+
bus.on(loadUser.cancel, () => {
|
|
1916
|
+
state.loading = false;
|
|
1917
|
+
});
|
|
1918
|
+
```
|
|
1919
|
+
|
|
1920
|
+
```js
|
|
1921
|
+
// page.js
|
|
1922
|
+
import { bus } from "./bus";
|
|
1923
|
+
import { loadUser } from "./user";
|
|
1924
|
+
|
|
1925
|
+
// emit — runs the job; state.loading is true as soon as it returns
|
|
1926
|
+
bus.emit(loadUser("1"));
|
|
1927
|
+
```
|
|
1928
|
+
|
|
1929
|
+
`cancel` is sent when the handler settles, not at the moment of the abort. A handler that stops on `ctx.signal` (as `fetch` does) settles at once. A handler that ignores the signal sends `cancel` when it finishes, and one that never finishes sends nothing.
|
|
1930
|
+
|
|
1931
|
+
**Limit: policies.** A policy such as `latest`, `leading`, `throttle` or `queue` wraps the handler, inside the job. The job does not know what the policy decides, so `pending` is sent for every call. A call that the policy drops, and a run that `latest` replaces, then close with `success(undefined)`, not with `cancel`. A flag set by `pending` is exact for a job without a policy, and for an abort of the task or of its parent. With a policy, count the calls or use the result of `emit`.
|
|
1087
1932
|
|
|
1088
1933
|
## Mount domains with modules
|
|
1089
1934
|
|
|
@@ -1094,18 +1939,20 @@ The registry lives in `nanoeffects/actions`, so a bus without one carries none o
|
|
|
1094
1939
|
`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)).
|
|
1095
1940
|
|
|
1096
1941
|
```js
|
|
1097
|
-
|
|
1098
|
-
import {
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1942
|
+
// user.js
|
|
1943
|
+
import { createCommand } from "nanoeffects/actions";
|
|
1944
|
+
|
|
1945
|
+
export const getUser = createCommand("user/get");
|
|
1946
|
+
export const saveUser = createCommand("user/save", persistUser);
|
|
1947
|
+
```
|
|
1103
1948
|
|
|
1104
|
-
|
|
1105
|
-
|
|
1949
|
+
```js
|
|
1950
|
+
// user-module.js
|
|
1951
|
+
import { createModule } from "nanoeffects/actions";
|
|
1952
|
+
import { getUser, saveUser } from "./user";
|
|
1106
1953
|
|
|
1107
1954
|
// createModule — one domain; unmount undoes everything below
|
|
1108
|
-
const userModule = createModule("user", (m) => {
|
|
1955
|
+
export const userModule = createModule("user", (m) => {
|
|
1109
1956
|
m.handle(getUser, fetchUser);
|
|
1110
1957
|
m.use(saveUser, authModule);
|
|
1111
1958
|
m.on("login", (user, ctx) => {
|
|
@@ -1115,10 +1962,22 @@ const userModule = createModule("user", (m) => {
|
|
|
1115
1962
|
/* background work; ends when the module unmounts */
|
|
1116
1963
|
});
|
|
1117
1964
|
});
|
|
1965
|
+
```
|
|
1118
1966
|
|
|
1119
|
-
|
|
1967
|
+
```js
|
|
1968
|
+
// bus.js
|
|
1969
|
+
import { createBus } from "nanoeffects";
|
|
1970
|
+
import { modulePlugin } from "nanoeffects/actions";
|
|
1971
|
+
import { userModule } from "./user-module";
|
|
1972
|
+
|
|
1973
|
+
export const bus = createBus();
|
|
1120
1974
|
// modulePlugin — mount modules on the bus
|
|
1121
|
-
const app = bus.use(modulePlugin([userModule]));
|
|
1975
|
+
export const app = bus.use(modulePlugin([userModule]));
|
|
1976
|
+
```
|
|
1977
|
+
|
|
1978
|
+
```js
|
|
1979
|
+
import { app } from "./bus";
|
|
1980
|
+
import { getUser } from "./user";
|
|
1122
1981
|
|
|
1123
1982
|
const unmount = app.mount(orderModule); // later, e.g. after a lazy import
|
|
1124
1983
|
app.commands.handle(getUser, fakeUser); // in a test
|
|
@@ -1129,6 +1988,7 @@ app.dispose(); // unmounts every module
|
|
|
1129
1988
|
- **A module is known by its name.** Mounting a module whose name is taken replaces the mounted one (see [Hot reload](hmr.md)).
|
|
1130
1989
|
- **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`.
|
|
1131
1990
|
- A disposed app cannot mount modules.
|
|
1991
|
+
- **The given modules are mounted on the first use** of the app or the bus (the first `bus.emit`, or a call of `mount`, `unmount`, `isMounted`, `dispose`, or the read of `commands`), so the plugins you use after `modulePlugin` still reach them. The `bus.on` calls made in between are held and made right after, so the listeners of a module stay ahead of them. See [using plugins in any order](plugins.md#use-them-in-any-order).
|
|
1132
1992
|
- Module context = task context plus `handle`, `use`, and `spawn`:
|
|
1133
1993
|
- `handle` — give a command a handler (contract, or override).
|
|
1134
1994
|
- `use` — bring sub-modules and commands that carry a handler; the returned function undoes that call.
|
|
@@ -1140,17 +2000,29 @@ app.dispose(); // unmounts every module
|
|
|
1140
2000
|
A command can need its own client, a module its own logger. Pass a scope, a plain object whose keys go on `ctx` (`$effects` binds functions to the task), as the last argument of `createCommand`, `createAction`, `createModule` and `createJob`, and turn it on with `scopePlugin`:
|
|
1141
2001
|
|
|
1142
2002
|
```js
|
|
2003
|
+
// bus.js
|
|
1143
2004
|
import { createBus } from "nanoeffects";
|
|
1144
|
-
import { createCommand } from "nanoeffects/actions";
|
|
1145
2005
|
import { scopePlugin } from "nanoeffects/scope";
|
|
1146
2006
|
|
|
1147
2007
|
// scopePlugin — makes the scope of a command work on this bus
|
|
1148
|
-
const bus = createBus().use(scopePlugin());
|
|
2008
|
+
export const bus = createBus().use(scopePlugin());
|
|
2009
|
+
```
|
|
2010
|
+
|
|
2011
|
+
```js
|
|
2012
|
+
// user.js
|
|
2013
|
+
import { createCommand } from "nanoeffects/actions";
|
|
1149
2014
|
|
|
1150
2015
|
// the third argument — fields of this command only
|
|
1151
|
-
const getUser = createCommand(
|
|
1152
|
-
|
|
1153
|
-
})
|
|
2016
|
+
export const getUser = createCommand(
|
|
2017
|
+
"user/get",
|
|
2018
|
+
(id, { api }) => api.getUser(id),
|
|
2019
|
+
{ api },
|
|
2020
|
+
);
|
|
2021
|
+
```
|
|
2022
|
+
|
|
2023
|
+
```js
|
|
2024
|
+
import { bus } from "./bus";
|
|
2025
|
+
import { getUser } from "./user";
|
|
1154
2026
|
|
|
1155
2027
|
// a test replaces them for one call
|
|
1156
2028
|
const fakeApi = { getUser: async (id) => ({ id, name: "test" }) };
|
|
@@ -1181,7 +2053,7 @@ If you do not have a store yet, use `createBus` from the [Guide](guide.md).
|
|
|
1181
2053
|
- [React: sharing a value across components](#react-sharing-a-value-across-components)
|
|
1182
2054
|
- [Callbacks on every change](#callbacks-on-every-change)
|
|
1183
2055
|
- [Stop watching a store](#stop-watching-a-store)
|
|
1184
|
-
- [
|
|
2056
|
+
- [Change the store from any handler](#change-the-store-from-any-handler)
|
|
1185
2057
|
|
|
1186
2058
|
## Wire Redux Toolkit to a bus
|
|
1187
2059
|
|
|
@@ -1192,23 +2064,40 @@ Redux’s `dispatch` → reducers updates **state**. It does not give you first-
|
|
|
1192
2064
|
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).
|
|
1193
2065
|
|
|
1194
2066
|
```js
|
|
1195
|
-
|
|
2067
|
+
// bus.js
|
|
1196
2068
|
import { createBus } from "nanoeffects";
|
|
1197
|
-
import { latest } from "nanoeffects/policies";
|
|
1198
|
-
import { createEffectsMiddleware } from "nanoeffects/redux";
|
|
1199
2069
|
|
|
1200
|
-
const
|
|
1201
|
-
|
|
2070
|
+
export const bus = createBus(); // the bus of your app; it can exist before the store
|
|
2071
|
+
```
|
|
2072
|
+
|
|
2073
|
+
```js
|
|
2074
|
+
// user.js
|
|
2075
|
+
import { createAction } from "@reduxjs/toolkit";
|
|
1202
2076
|
|
|
1203
|
-
const
|
|
2077
|
+
export const userRequested = createAction("user/requested"); // RTK
|
|
2078
|
+
export const userLoaded = createAction("user/loaded");
|
|
2079
|
+
```
|
|
2080
|
+
|
|
2081
|
+
```js
|
|
2082
|
+
// store.js
|
|
2083
|
+
import { configureStore } from "@reduxjs/toolkit";
|
|
2084
|
+
import { createEffectsMiddleware } from "nanoeffects/redux";
|
|
2085
|
+
import { bus } from "./bus";
|
|
1204
2086
|
|
|
1205
2087
|
// createEffectsMiddleware — actions reach the bus after reducers; tasks get ctx.store
|
|
1206
2088
|
const effects = createEffectsMiddleware(bus);
|
|
1207
2089
|
|
|
1208
|
-
const store = configureStore({
|
|
2090
|
+
export const store = configureStore({
|
|
1209
2091
|
reducer,
|
|
1210
2092
|
middleware: (getDefault) => getDefault().concat(effects),
|
|
1211
2093
|
});
|
|
2094
|
+
```
|
|
2095
|
+
|
|
2096
|
+
```js
|
|
2097
|
+
// user-handlers.js
|
|
2098
|
+
import { latest } from "nanoeffects/policies";
|
|
2099
|
+
import { bus } from "./bus";
|
|
2100
|
+
import { userLoaded, userRequested } from "./user";
|
|
1212
2101
|
|
|
1213
2102
|
// latest — a newer run aborts the older one
|
|
1214
2103
|
bus.on(
|
|
@@ -1259,28 +2148,44 @@ const effects = createEffectsMiddleware(bus);
|
|
|
1259
2148
|
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:
|
|
1260
2149
|
|
|
1261
2150
|
- **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.
|
|
1262
|
-
- **The event changes the store.** The handler reports its result as an event. A listener of the bus
|
|
2151
|
+
- **The event changes the store.** The handler reports its result as an event. A listener of the bus gets the store from `ctx` and changes it with a mutation.
|
|
1263
2152
|
|
|
1264
2153
|
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`.
|
|
1265
2154
|
|
|
1266
2155
|
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.
|
|
1267
2156
|
|
|
1268
|
-
The bus
|
|
2157
|
+
The bus gets the store as context: `contextPlugin({ store })` puts it on every task as `ctx.store` ([Context fields](context.md)). A handler then changes the store with its mutations and never reaches for the store by name. Only `store.js` is different for each store below. It exports a `store` with the same three members:
|
|
2158
|
+
|
|
2159
|
+
- `setQuery(query)` and `setResults(items)` are the mutations. The user's input calls the first, the handler calls the second.
|
|
2160
|
+
- `onQueryChanged(callback)` tells you about a change of `query`, and returns the function that stops it.
|
|
2161
|
+
|
|
2162
|
+
The bus, the events, the handlers and the link from the store to the bus are the same for every store, each in a file of its own: the bus knows no events, and the events know no bus.
|
|
1269
2163
|
|
|
1270
2164
|
```js
|
|
1271
|
-
//
|
|
1272
|
-
import { createBus } from "nanoeffects";
|
|
1273
|
-
import {
|
|
1274
|
-
import {
|
|
2165
|
+
// bus.js
|
|
2166
|
+
import { contextPlugin, createBus } from "nanoeffects";
|
|
2167
|
+
import { emitterPlugin } from "nanoeffects/actions";
|
|
2168
|
+
import { store } from "./store";
|
|
1275
2169
|
|
|
1276
|
-
export const bus = createBus()
|
|
2170
|
+
export const bus = createBus()
|
|
2171
|
+
.use(emitterPlugin()) // adds bus.emitter
|
|
2172
|
+
.use(contextPlugin({ store })); // ctx.store on every task
|
|
2173
|
+
```
|
|
2174
|
+
|
|
2175
|
+
```js
|
|
2176
|
+
// search.js
|
|
2177
|
+
import { createAction } from "nanoeffects/actions";
|
|
1277
2178
|
|
|
1278
2179
|
// createAction — plain action creators; calling one only builds { type, payload }
|
|
1279
2180
|
export const queryChanged = createAction("search/queryChanged");
|
|
1280
2181
|
export const searchLoaded = createAction("search/loaded");
|
|
2182
|
+
```
|
|
1281
2183
|
|
|
1282
|
-
|
|
1283
|
-
|
|
2184
|
+
```js
|
|
2185
|
+
// search-handlers.js
|
|
2186
|
+
import { debounce, latest } from "nanoeffects/policies";
|
|
2187
|
+
import { bus } from "./bus";
|
|
2188
|
+
import { queryChanged, searchLoaded } from "./search";
|
|
1284
2189
|
|
|
1285
2190
|
// debounce — wait 300 ms of quiet; latest — abort the search of the previous query
|
|
1286
2191
|
bus.on(
|
|
@@ -1294,141 +2199,199 @@ bus.on(
|
|
|
1294
2199
|
}),
|
|
1295
2200
|
),
|
|
1296
2201
|
);
|
|
2202
|
+
|
|
2203
|
+
// event → store: the store comes from ctx, and a mutation changes it
|
|
2204
|
+
bus.on(searchLoaded, (items, { store }) => {
|
|
2205
|
+
store.setResults(items);
|
|
2206
|
+
});
|
|
2207
|
+
```
|
|
2208
|
+
|
|
2209
|
+
```js
|
|
2210
|
+
// connect.js
|
|
2211
|
+
import { bus } from "./bus";
|
|
2212
|
+
import { queryChanged } from "./search";
|
|
2213
|
+
import { store } from "./store";
|
|
2214
|
+
|
|
2215
|
+
// store → event: bus.emitter turns the callback into an emit of queryChanged
|
|
2216
|
+
export const stopQuery = store.onQueryChanged(bus.emitter(queryChanged));
|
|
1297
2217
|
```
|
|
1298
2218
|
|
|
1299
2219
|
Write `results` and never the field you watch: a handler that changed `query` would start itself again. Find your store below.
|
|
1300
2220
|
|
|
1301
2221
|
### Nanostores
|
|
1302
2222
|
|
|
1303
|
-
`listen` calls back with the new value
|
|
2223
|
+
`action` makes a named mutation of an atom. `listen` calls back with the new value; `subscribe` would also call at once, with the current value, so use `listen`.
|
|
1304
2224
|
|
|
1305
2225
|
```js
|
|
1306
|
-
|
|
1307
|
-
import {
|
|
2226
|
+
// store.js
|
|
2227
|
+
import { action, atom } from "nanostores";
|
|
1308
2228
|
|
|
1309
2229
|
const $query = atom("");
|
|
1310
2230
|
const $results = atom([]);
|
|
1311
2231
|
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
$
|
|
1318
|
-
}
|
|
2232
|
+
export const store = {
|
|
2233
|
+
// action — a named mutation of an atom
|
|
2234
|
+
setQuery: action($query, "setQuery", (target, query) => target.set(query)),
|
|
2235
|
+
setResults: action($results, "setResults", (target, items) => target.set(items)),
|
|
2236
|
+
// listen — calls back with the new query on every change
|
|
2237
|
+
onQueryChanged: (callback) => $query.listen(callback),
|
|
2238
|
+
};
|
|
1319
2239
|
```
|
|
1320
2240
|
|
|
1321
2241
|
### MobX
|
|
1322
2242
|
|
|
1323
|
-
`reaction` watches an expression and calls back with its value.
|
|
2243
|
+
`makeAutoObservable` turns the methods of a class into actions, which is where MobX wants changes to happen. `reaction` watches an expression and calls back with its value.
|
|
2244
|
+
|
|
2245
|
+
<!-- docs-check: fork -->
|
|
1324
2246
|
|
|
1325
2247
|
```js
|
|
1326
|
-
|
|
1327
|
-
import {
|
|
2248
|
+
// store.js
|
|
2249
|
+
import { makeAutoObservable, reaction } from "mobx";
|
|
1328
2250
|
|
|
1329
|
-
|
|
2251
|
+
class SearchStore {
|
|
2252
|
+
query = "";
|
|
2253
|
+
results = [];
|
|
1330
2254
|
|
|
1331
|
-
|
|
1332
|
-
|
|
2255
|
+
constructor() {
|
|
2256
|
+
makeAutoObservable(this); // the methods below become actions
|
|
2257
|
+
}
|
|
1333
2258
|
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
2259
|
+
setQuery(query) {
|
|
2260
|
+
this.query = query;
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
setResults(items) {
|
|
2264
|
+
this.results = items;
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
// reaction — watches the expression and calls back with its value
|
|
2268
|
+
onQueryChanged(callback) {
|
|
2269
|
+
return reaction(() => this.query, callback);
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
export const store = new SearchStore();
|
|
1340
2274
|
```
|
|
1341
2275
|
|
|
1342
2276
|
### Valtio
|
|
1343
2277
|
|
|
1344
|
-
`subscribeKey` watches one field of a proxy state.
|
|
2278
|
+
`subscribeKey` watches one field of a proxy state. A mutation assigns to the proxy.
|
|
2279
|
+
|
|
2280
|
+
<!-- docs-check: fork -->
|
|
1345
2281
|
|
|
1346
2282
|
```js
|
|
2283
|
+
// store.js
|
|
1347
2284
|
import { proxy } from "valtio";
|
|
1348
2285
|
import { subscribeKey } from "valtio/utils";
|
|
1349
|
-
import { bus, emitQueryChanged, searchLoaded } from "./search-bus";
|
|
1350
2286
|
|
|
1351
2287
|
const state = proxy({ query: "", results: [] });
|
|
1352
2288
|
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
}
|
|
2289
|
+
export const store = {
|
|
2290
|
+
setQuery(query) {
|
|
2291
|
+
state.query = query;
|
|
2292
|
+
},
|
|
2293
|
+
setResults(items) {
|
|
2294
|
+
state.results = items;
|
|
2295
|
+
},
|
|
2296
|
+
// subscribeKey — watches one field
|
|
2297
|
+
onQueryChanged: (callback) => subscribeKey(state, "query", callback),
|
|
2298
|
+
};
|
|
1360
2299
|
```
|
|
1361
2300
|
|
|
1362
2301
|
### Jotai
|
|
1363
2302
|
|
|
1364
|
-
`sub` gives no value, so read it yourself.
|
|
2303
|
+
`sub` gives no value, so read it yourself. A write-only atom is the mutation, and `set` runs it.
|
|
2304
|
+
|
|
2305
|
+
<!-- docs-check: fork -->
|
|
1365
2306
|
|
|
1366
2307
|
```js
|
|
2308
|
+
// store.js
|
|
1367
2309
|
import { atom, createStore } from "jotai";
|
|
1368
|
-
import { bus, emitQueryChanged, searchLoaded } from "./search-bus";
|
|
1369
2310
|
|
|
1370
2311
|
const queryAtom = atom("");
|
|
1371
2312
|
const resultsAtom = atom([]);
|
|
1372
|
-
const
|
|
1373
|
-
|
|
1374
|
-
//
|
|
1375
|
-
const
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
const
|
|
1379
|
-
|
|
1380
|
-
|
|
2313
|
+
const jotai = createStore();
|
|
2314
|
+
|
|
2315
|
+
// write-only atoms: the mutations
|
|
2316
|
+
const setQueryAtom = atom(null, (_get, set, query) => set(queryAtom, query));
|
|
2317
|
+
const setResultsAtom = atom(null, (_get, set, items) => set(resultsAtom, items));
|
|
2318
|
+
|
|
2319
|
+
export const store = {
|
|
2320
|
+
setQuery: (query) => jotai.set(setQueryAtom, query),
|
|
2321
|
+
setResults: (items) => jotai.set(setResultsAtom, items),
|
|
2322
|
+
// sub calls back without a value; get reads it
|
|
2323
|
+
onQueryChanged: (callback) =>
|
|
2324
|
+
jotai.sub(queryAtom, () => callback(jotai.get(queryAtom))),
|
|
2325
|
+
};
|
|
1381
2326
|
```
|
|
1382
2327
|
|
|
1383
2328
|
### Zustand
|
|
1384
2329
|
|
|
1385
|
-
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.
|
|
2330
|
+
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. The mutations are functions in the state.
|
|
2331
|
+
|
|
2332
|
+
<!-- docs-check: fork -->
|
|
1386
2333
|
|
|
1387
2334
|
```js
|
|
2335
|
+
// store.js
|
|
1388
2336
|
import { create } from "zustand";
|
|
1389
2337
|
import { subscribeWithSelector } from "zustand/middleware";
|
|
1390
|
-
import { bus, emitQueryChanged, searchLoaded } from "./search-bus";
|
|
1391
2338
|
|
|
1392
|
-
const
|
|
1393
|
-
subscribeWithSelector(() => ({
|
|
2339
|
+
const useSearch = create(
|
|
2340
|
+
subscribeWithSelector((set) => ({
|
|
2341
|
+
query: "",
|
|
2342
|
+
results: [],
|
|
2343
|
+
// the mutations are part of the state
|
|
2344
|
+
setQuery: (query) => set({ query }),
|
|
2345
|
+
setResults: (items) => set({ results: items }),
|
|
2346
|
+
})),
|
|
1394
2347
|
);
|
|
1395
2348
|
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
//
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
});
|
|
2349
|
+
export const store = {
|
|
2350
|
+
setQuery: (query) => useSearch.getState().setQuery(query),
|
|
2351
|
+
setResults: (items) => useSearch.getState().setResults(items),
|
|
2352
|
+
// a selector: called back with state.query only when it changes
|
|
2353
|
+
onQueryChanged: (callback) => useSearch.subscribe((state) => state.query, callback),
|
|
2354
|
+
};
|
|
1403
2355
|
```
|
|
1404
2356
|
|
|
1405
2357
|
### Pinia
|
|
1406
2358
|
|
|
1407
|
-
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.
|
|
2359
|
+
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. The mutations are the actions of the store.
|
|
2360
|
+
|
|
2361
|
+
<!-- docs-check: fork -->
|
|
1408
2362
|
|
|
1409
2363
|
```js
|
|
1410
|
-
|
|
2364
|
+
// store.js
|
|
1411
2365
|
import { defineStore } from "pinia";
|
|
1412
|
-
import {
|
|
2366
|
+
import { watch } from "vue";
|
|
1413
2367
|
|
|
1414
|
-
// defineStore — Pinia: the state of one store
|
|
2368
|
+
// defineStore — Pinia: the state and the actions of one store
|
|
1415
2369
|
const useSearch = defineStore("search", {
|
|
1416
2370
|
state: () => ({ query: "", results: [] }),
|
|
2371
|
+
actions: {
|
|
2372
|
+
setQuery(query) {
|
|
2373
|
+
this.query = query;
|
|
2374
|
+
},
|
|
2375
|
+
setResults(items) {
|
|
2376
|
+
this.results = items;
|
|
2377
|
+
},
|
|
2378
|
+
},
|
|
1417
2379
|
});
|
|
1418
2380
|
|
|
1419
2381
|
const search = useSearch(); // call it after the app has installed Pinia
|
|
1420
2382
|
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
//
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
});
|
|
2383
|
+
export const store = {
|
|
2384
|
+
setQuery: (query) => search.setQuery(query),
|
|
2385
|
+
setResults: (items) => search.setResults(items),
|
|
2386
|
+
// watch — calls back with the new query when search.query changes
|
|
2387
|
+
onQueryChanged: (callback) => watch(() => search.query, callback),
|
|
2388
|
+
};
|
|
1428
2389
|
```
|
|
1429
2390
|
|
|
1430
2391
|
### React: sharing a value across components
|
|
1431
2392
|
|
|
2393
|
+
<!-- docs-check: new -->
|
|
2394
|
+
|
|
1432
2395
|
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.
|
|
1433
2396
|
|
|
1434
2397
|
| The value lives in | Use when |
|
|
@@ -1441,20 +2404,30 @@ A value of your UI (a query, a filter, a name) often has two jobs. Several compo
|
|
|
1441
2404
|
|
|
1442
2405
|
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.
|
|
1443
2406
|
|
|
1444
|
-
First the bus. `reactPlugin()` adds `bus.useEmitter`, and destructuring it gives a plain hook that you export:
|
|
2407
|
+
First the bus, the events and the handlers, each in its own file. `reactPlugin()` adds `bus.useEmitter`, and destructuring it gives a plain hook that you export from the bus file:
|
|
1445
2408
|
|
|
1446
2409
|
```js
|
|
1447
|
-
//
|
|
2410
|
+
// bus.js
|
|
1448
2411
|
import { createBus } from "nanoeffects";
|
|
1449
|
-
import { createAction } from "nanoeffects/actions";
|
|
1450
|
-
import { debounce, latest } from "nanoeffects/policies";
|
|
1451
2412
|
import { reactPlugin } from "nanoeffects/react";
|
|
1452
2413
|
|
|
1453
2414
|
export const bus = createBus().use(reactPlugin()); // the bus; reactPlugin adds bus.useEmitter
|
|
1454
2415
|
export const { useEmitter } = bus; // a free hook: it does not read `this`
|
|
2416
|
+
```
|
|
2417
|
+
|
|
2418
|
+
```js
|
|
2419
|
+
// search.js
|
|
2420
|
+
import { createAction } from "nanoeffects/actions";
|
|
1455
2421
|
|
|
1456
2422
|
export const queryChanged = createAction("query/changed");
|
|
1457
2423
|
export const searchLoaded = createAction("search/loaded");
|
|
2424
|
+
```
|
|
2425
|
+
|
|
2426
|
+
```js
|
|
2427
|
+
// search-handlers.js
|
|
2428
|
+
import { debounce, latest } from "nanoeffects/policies";
|
|
2429
|
+
import { bus } from "./bus";
|
|
2430
|
+
import { queryChanged, searchLoaded } from "./search";
|
|
1458
2431
|
|
|
1459
2432
|
// debounce — wait 300 ms of quiet; latest — a newer query aborts the older request
|
|
1460
2433
|
bus.on(
|
|
@@ -1474,7 +2447,8 @@ Then the provider. It holds the state, calls `useEmitter` once, and listens for
|
|
|
1474
2447
|
```jsx
|
|
1475
2448
|
// SearchProvider.jsx
|
|
1476
2449
|
import { createContext, useContext, useEffect, useState } from "react";
|
|
1477
|
-
import { bus,
|
|
2450
|
+
import { bus, useEmitter } from "./bus";
|
|
2451
|
+
import { queryChanged, searchLoaded } from "./search";
|
|
1478
2452
|
|
|
1479
2453
|
const SearchContext = createContext(null);
|
|
1480
2454
|
|
|
@@ -1531,6 +2505,8 @@ Two limits to know:
|
|
|
1531
2505
|
|
|
1532
2506
|
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`:
|
|
1533
2507
|
|
|
2508
|
+
<!-- docs-check: new -->
|
|
2509
|
+
|
|
1534
2510
|
```jsx
|
|
1535
2511
|
// value-store.js
|
|
1536
2512
|
import { useSyncExternalStore } from "react";
|
|
@@ -1560,19 +2536,36 @@ export function createValue(initial) {
|
|
|
1560
2536
|
export const useValue = (store) => useSyncExternalStore(store.subscribe, store.get);
|
|
1561
2537
|
```
|
|
1562
2538
|
|
|
1563
|
-
```
|
|
1564
|
-
//
|
|
1565
|
-
import {
|
|
1566
|
-
|
|
1567
|
-
|
|
2539
|
+
```js
|
|
2540
|
+
// query.js
|
|
2541
|
+
import { createValue } from "./value-store";
|
|
2542
|
+
|
|
2543
|
+
export const query = createValue("");
|
|
2544
|
+
```
|
|
1568
2545
|
|
|
1569
|
-
|
|
2546
|
+
```js
|
|
2547
|
+
// bus.js
|
|
2548
|
+
import { contextPlugin, createBus } from "nanoeffects";
|
|
2549
|
+
import { emitterPlugin } from "nanoeffects/actions";
|
|
2550
|
+
import { query } from "./query";
|
|
1570
2551
|
|
|
1571
2552
|
// emitterPlugin adds bus.emitter; contextPlugin puts `query` on every task as ctx.query
|
|
1572
|
-
const bus = createBus().use(emitterPlugin()).use(contextPlugin({ query }));
|
|
2553
|
+
export const bus = createBus().use(emitterPlugin()).use(contextPlugin({ query }));
|
|
2554
|
+
```
|
|
2555
|
+
|
|
2556
|
+
```js
|
|
2557
|
+
// search.js
|
|
2558
|
+
import { createAction } from "nanoeffects/actions";
|
|
2559
|
+
|
|
2560
|
+
export const queryChanged = createAction("query/changed");
|
|
2561
|
+
export const refreshed = createAction("search/refreshed");
|
|
2562
|
+
```
|
|
1573
2563
|
|
|
1574
|
-
|
|
1575
|
-
|
|
2564
|
+
```js
|
|
2565
|
+
// search-handlers.js
|
|
2566
|
+
import { bus } from "./bus";
|
|
2567
|
+
import { query } from "./query";
|
|
2568
|
+
import { queryChanged, refreshed } from "./search";
|
|
1576
2569
|
|
|
1577
2570
|
// store → bus: bus.emitter turns the action into a callback; one place, any number of setters
|
|
1578
2571
|
const emitQueryChanged = bus.emitter(queryChanged);
|
|
@@ -1583,6 +2576,12 @@ bus.on(refreshed, async (_payload, ctx) => {
|
|
|
1583
2576
|
const items = await api.search(ctx.query.get(), { signal: ctx.signal });
|
|
1584
2577
|
console.log(items.length);
|
|
1585
2578
|
});
|
|
2579
|
+
```
|
|
2580
|
+
|
|
2581
|
+
```jsx
|
|
2582
|
+
// SearchBox.jsx
|
|
2583
|
+
import { query } from "./query";
|
|
2584
|
+
import { useValue } from "./value-store";
|
|
1586
2585
|
|
|
1587
2586
|
// any number of components read and write the same value
|
|
1588
2587
|
export function SearchBox() {
|
|
@@ -1595,16 +2594,31 @@ export function SearchBox() {
|
|
|
1595
2594
|
|
|
1596
2595
|
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:
|
|
1597
2596
|
|
|
2597
|
+
<!-- docs-check: new -->
|
|
2598
|
+
|
|
2599
|
+
```js
|
|
2600
|
+
// bus.js
|
|
2601
|
+
import { createBus } from "nanoeffects";
|
|
2602
|
+
import { emitterPlugin } from "nanoeffects/actions";
|
|
2603
|
+
|
|
2604
|
+
export const bus = createBus().use(emitterPlugin()); // adds bus.emitter
|
|
2605
|
+
```
|
|
2606
|
+
|
|
2607
|
+
```js
|
|
2608
|
+
// search.js
|
|
2609
|
+
import { createAction } from "nanoeffects/actions";
|
|
2610
|
+
|
|
2611
|
+
export const queryChanged = createAction("query/changed");
|
|
2612
|
+
```
|
|
2613
|
+
|
|
1598
2614
|
```jsx
|
|
1599
|
-
//
|
|
2615
|
+
// SearchBox.jsx
|
|
1600
2616
|
import { atom } from "nanostores";
|
|
1601
2617
|
import { useStore } from "@nanostores/react";
|
|
1602
|
-
import {
|
|
1603
|
-
import {
|
|
2618
|
+
import { bus } from "./bus";
|
|
2619
|
+
import { queryChanged } from "./search";
|
|
1604
2620
|
|
|
1605
|
-
const bus = createBus().use(emitterPlugin()); // adds bus.emitter
|
|
1606
2621
|
const $query = atom("");
|
|
1607
|
-
const queryChanged = createAction("query/changed");
|
|
1608
2622
|
|
|
1609
2623
|
// listen, not subscribe: listen skips the call with the current value at start
|
|
1610
2624
|
$query.listen(bus.emitter(queryChanged));
|
|
@@ -1619,16 +2633,28 @@ export function SearchBox() {
|
|
|
1619
2633
|
|
|
1620
2634
|
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.
|
|
1621
2635
|
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
2636
|
+
<!-- docs-check: new -->
|
|
2637
|
+
|
|
2638
|
+
```js
|
|
2639
|
+
// bus.js
|
|
1625
2640
|
import { createBus } from "nanoeffects";
|
|
2641
|
+
|
|
2642
|
+
export const bus = createBus(); // the bus: created once
|
|
2643
|
+
```
|
|
2644
|
+
|
|
2645
|
+
```js
|
|
2646
|
+
// search.js
|
|
1626
2647
|
import { createAction } from "nanoeffects/actions";
|
|
1627
2648
|
|
|
1628
|
-
const
|
|
2649
|
+
export const queryChanged = createAction("query/changed");
|
|
2650
|
+
export const queryCleared = createAction("query/cleared");
|
|
2651
|
+
```
|
|
1629
2652
|
|
|
1630
|
-
|
|
1631
|
-
|
|
2653
|
+
```jsx
|
|
2654
|
+
// Search.jsx
|
|
2655
|
+
import { useReducer } from "react";
|
|
2656
|
+
import { bus } from "./bus";
|
|
2657
|
+
import { queryChanged, queryCleared } from "./search";
|
|
1632
2658
|
|
|
1633
2659
|
function reducer(state, action) {
|
|
1634
2660
|
if (queryChanged.match(action)) {
|
|
@@ -1668,11 +2694,139 @@ The stores above call back only when the value changed, so the emitter does not
|
|
|
1668
2694
|
|
|
1669
2695
|
### Stop watching a store
|
|
1670
2696
|
|
|
1671
|
-
|
|
2697
|
+
`connect.js` keeps the function that `onQueryChanged` returned (`stopQuery`). Call it yourself, or hand it over: `onAbort(ctx, stopQuery)` inside a task, or `m.signal.addEventListener("abort", stopQuery)` in a module. To stop the other direction, remove the listener of `searchLoaded` with `bus.off(searchLoaded, handler)`, or register it in a module, which removes it on unmount.
|
|
2698
|
+
|
|
2699
|
+
### Change the store from any handler
|
|
2700
|
+
|
|
2701
|
+
A handler gets the store from `ctx.store` and calls a mutation, whatever it handles: not only the result of a search. The same rule holds: write the field you do not watch. For a MobX `flow`, `onAbort(ctx, () => promise.cancel())` cancels it with the task.
|
|
2702
|
+
|
|
2703
|
+
---
|
|
2704
|
+
Source: docs/query-libraries.md
|
|
2705
|
+
|
|
2706
|
+
# Use nanoeffects with React Query and SWR
|
|
2707
|
+
|
|
2708
|
+
Read this page if you fetch data with [React Query](https://tanstack.com/query) (TanStack Query) or [SWR](https://swr.vercel.app) and want `retry`, `timeout` and the abort of nanoeffects inside the request. It shows one command that both libraries call.
|
|
2709
|
+
|
|
2710
|
+
You need a React project, `nanoeffects`, and one of the two libraries. `api` below is your own client: replace it with your requests.
|
|
2711
|
+
|
|
2712
|
+
## Decide who owns what
|
|
2713
|
+
|
|
2714
|
+
The libraries and nanoeffects do different jobs, so each keeps its own.
|
|
2715
|
+
|
|
2716
|
+
| React Query or SWR | nanoeffects |
|
|
2717
|
+
| ------------------ | ----------- |
|
|
2718
|
+
| `isLoading`, `data`, `error` | `retry`, `timeout`, `latest` and other policies |
|
|
2719
|
+
| the cache, one request for the same key | the abort that reaches the request |
|
|
2720
|
+
| refetch on focus, on reconnect, on interval | the handler: the same code for a test, a worker and the UI |
|
|
2721
|
+
|
|
2722
|
+
`isLoading` and `data` come from the library, not from `emit`. `await bus.emit(...)` resolves once, with the result, so it cannot report that the request is still running.
|
|
2723
|
+
|
|
2724
|
+
## Make the command
|
|
2725
|
+
|
|
2726
|
+
A command is the request itself. It lives in files that know nothing about React: the bus, the command with its handler, and the component.
|
|
2727
|
+
|
|
2728
|
+
```js
|
|
2729
|
+
// bus.js
|
|
2730
|
+
import { createBus } from "nanoeffects";
|
|
2731
|
+
import { signalPlugin } from "nanoeffects/signal";
|
|
2732
|
+
|
|
2733
|
+
// createBus — the bus the app shares
|
|
2734
|
+
// signalPlugin — lets emit take { signal }, the signal of the library
|
|
2735
|
+
export const bus = createBus().use(signalPlugin());
|
|
2736
|
+
```
|
|
2737
|
+
|
|
2738
|
+
The handler tries the request up to three times and gives up after five seconds for each try. `ctx.signal` is the signal of the task: it aborts when the library aborts the request.
|
|
2739
|
+
|
|
2740
|
+
```js
|
|
2741
|
+
// user.js
|
|
2742
|
+
import { createCommand } from "nanoeffects/actions";
|
|
2743
|
+
import { retry, timeout } from "nanoeffects/effects";
|
|
2744
|
+
|
|
2745
|
+
// createCommand — a command with a handler: emit returns what the handler returns
|
|
2746
|
+
export const getUser = createCommand("user/get", async (id, ctx) => {
|
|
2747
|
+
// one try: the request, with the signal of timeout so it can stop it
|
|
2748
|
+
const load = ({ signal }) => api.getUser(id, { signal });
|
|
2749
|
+
// timeout — fail the try after 5 s
|
|
2750
|
+
const attempt = () => timeout(ctx, 5000, load);
|
|
2751
|
+
// retry — up to 3 tries, 500 ms apart
|
|
2752
|
+
return retry(ctx, attempt, { times: 3, delay: 500 });
|
|
2753
|
+
});
|
|
2754
|
+
```
|
|
2755
|
+
|
|
2756
|
+
## Call it from React Query
|
|
2757
|
+
|
|
2758
|
+
<!-- docs-check: continue -->
|
|
2759
|
+
|
|
2760
|
+
React Query gives `queryFn` a `signal` and aborts it when the query becomes inactive or out of date. Pass it to `emit`. The task is aborted with it, so `timeout` and `retry` stop, and so does the request.
|
|
2761
|
+
|
|
2762
|
+
```jsx
|
|
2763
|
+
// Profile.jsx
|
|
2764
|
+
import { useQuery } from "@tanstack/react-query";
|
|
2765
|
+
import { bus } from "./bus";
|
|
2766
|
+
import { getUser } from "./user";
|
|
2767
|
+
|
|
2768
|
+
export function Profile({ id }) {
|
|
2769
|
+
const { isLoading, data, error } = useQuery({
|
|
2770
|
+
queryKey: ["user", id],
|
|
2771
|
+
// emit — runs the command; { signal } ties its task to the signal of React Query
|
|
2772
|
+
queryFn: ({ signal }) => bus.emit(getUser(id), { signal }),
|
|
2773
|
+
});
|
|
2774
|
+
|
|
2775
|
+
if (isLoading) return <p>Loading…</p>;
|
|
2776
|
+
if (error) return <p>{error.message}</p>;
|
|
2777
|
+
return <p>{data.name}</p>;
|
|
2778
|
+
}
|
|
2779
|
+
```
|
|
2780
|
+
|
|
2781
|
+
The command rejects when all tries fail, so React Query shows the `error`. React Query counts its own `retry` too: set `retry: false` in `useQuery` when the handler already retries, or the tries multiply.
|
|
2782
|
+
|
|
2783
|
+
## Call it from SWR
|
|
2784
|
+
|
|
2785
|
+
<!-- docs-check: continue -->
|
|
2786
|
+
|
|
2787
|
+
SWR does not pass a signal to the fetcher, so there is nothing to give to `emit`. The command still brings `retry` and `timeout`.
|
|
2788
|
+
|
|
2789
|
+
```jsx
|
|
2790
|
+
// Profile.jsx
|
|
2791
|
+
import useSWR from "swr";
|
|
2792
|
+
import { bus } from "./bus";
|
|
2793
|
+
import { getUser } from "./user";
|
|
2794
|
+
|
|
2795
|
+
// the fetcher gets the key (here the id) and returns a promise
|
|
2796
|
+
const fetcher = (id) => bus.emit(getUser(id));
|
|
2797
|
+
|
|
2798
|
+
export function Profile({ id }) {
|
|
2799
|
+
const { isLoading, data, error } = useSWR(id, fetcher);
|
|
2800
|
+
|
|
2801
|
+
if (isLoading) return <p>Loading…</p>;
|
|
2802
|
+
if (error) return <p>{error.message}</p>;
|
|
2803
|
+
return <p>{data.name}</p>;
|
|
2804
|
+
}
|
|
2805
|
+
```
|
|
2806
|
+
|
|
2807
|
+
## Let the bus know the result
|
|
2808
|
+
|
|
2809
|
+
<!-- docs-check: continue -->
|
|
2810
|
+
|
|
2811
|
+
A command gives its result only to the caller. If a store or another module must react to a loaded user, the handler can say so:
|
|
2812
|
+
|
|
2813
|
+
```js
|
|
2814
|
+
// user-events.js
|
|
2815
|
+
import { createAction } from "nanoeffects/actions";
|
|
2816
|
+
|
|
2817
|
+
// createAction — an event with a payload
|
|
2818
|
+
export const userLoaded = createAction("user/loaded");
|
|
2819
|
+
```
|
|
1672
2820
|
|
|
1673
|
-
|
|
2821
|
+
Send it from the handler with `ctx.emit(userLoaded(user))`. Do not replace the command with `createJob` here: a job handles the error itself and resolves with `undefined`, so React Query would never see the failure. Use a job when nothing waits for the result and the app shows a loading state from the bus, see [Show a loading state with a job](actions.md#show-a-loading-state-with-a-job).
|
|
1674
2822
|
|
|
1675
|
-
|
|
2823
|
+
## Know the limits
|
|
2824
|
+
|
|
2825
|
+
- **Without the plugin, `{ signal }` is ignored.** The command runs, but the abort does not reach it. Call `bus.use(signalPlugin())` once, where the bus is made.
|
|
2826
|
+
- **Only the call forms take it.** `bus.emit(getUser(id), { signal })` works; `bus.emit("user/get", id, { signal })` does not.
|
|
2827
|
+
- **A handler that returns without a promise is finished with the call**, so a signal that aborts later does not reach it. Commands that fetch are async, so this only matters for a sync handler that sets up a callback.
|
|
2828
|
+
- **Give each request its own signal for an event.** For a command or a job the link ends when it settles. For `bus.emit(action, { signal })` it stays until the signal aborts.
|
|
2829
|
+
- **The signal reaches the tasks that this `emit` starts**, with the listeners of the command's type and the `ctx.run` children. A separate `bus.emit` that the handler makes is not linked: pass `{ signal: ctx.signal }` to it.
|
|
1676
2830
|
|
|
1677
2831
|
---
|
|
1678
2832
|
Source: docs/react.md
|
|
@@ -1695,19 +2849,26 @@ Each has a policy or an effect: `latest`, `leading`, `retry`. They live in one p
|
|
|
1695
2849
|
|
|
1696
2850
|
## Wire the bus once
|
|
1697
2851
|
|
|
1698
|
-
|
|
2852
|
+
Keep the bus in a file of its own, and the handlers of the form in another. Create the bus once, outside any component: a bus created in the body of a component would be a new bus, without subscribers, on every render.
|
|
1699
2853
|
|
|
1700
2854
|
```js
|
|
1701
|
-
//
|
|
2855
|
+
// bus.js
|
|
1702
2856
|
import { createBus } from "nanoeffects";
|
|
2857
|
+
|
|
2858
|
+
export const bus = createBus(); // the bus: created once, at the top of the module
|
|
2859
|
+
```
|
|
2860
|
+
|
|
2861
|
+
The handlers know the bus and the bus knows nothing of them. They register when `registration.js` is imported, once, when the app starts:
|
|
2862
|
+
|
|
2863
|
+
```js
|
|
2864
|
+
// registration.js
|
|
1703
2865
|
import { retry } from "nanoeffects/effects";
|
|
1704
2866
|
import { debounce, latest, leading } from "nanoeffects/policies";
|
|
2867
|
+
import { bus } from "./bus";
|
|
1705
2868
|
|
|
1706
2869
|
// fetch rejects with a TypeError when the network fails; an HTTP error is a plain Error
|
|
1707
2870
|
const isNetworkError = (error) => error instanceof TypeError;
|
|
1708
2871
|
|
|
1709
|
-
export const bus = createBus(); // the bus: created once, at the top of the module
|
|
1710
|
-
|
|
1711
2872
|
// debounce — wait 300 ms of quiet; latest — abort the check of the previous name
|
|
1712
2873
|
bus.on(
|
|
1713
2874
|
"username/typed",
|
|
@@ -1745,12 +2906,15 @@ The handlers know nothing about React. A test can emit `username/typed` and read
|
|
|
1745
2906
|
|
|
1746
2907
|
## Connect the form
|
|
1747
2908
|
|
|
2909
|
+
<!-- docs-check: continue -->
|
|
2910
|
+
|
|
1748
2911
|
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.
|
|
1749
2912
|
|
|
1750
2913
|
```jsx
|
|
1751
2914
|
// SignUpForm.jsx
|
|
1752
2915
|
import { useEffect, useState } from "react";
|
|
1753
|
-
import { bus } from "./
|
|
2916
|
+
import { bus } from "./bus"; // the bus of the previous snippet
|
|
2917
|
+
import "./registration"; // registers the handlers; the app does this once, when it starts
|
|
1754
2918
|
|
|
1755
2919
|
export function SignUpForm() {
|
|
1756
2920
|
const [name, setName] = useState("");
|
|
@@ -1813,6 +2977,8 @@ In development, React StrictMode runs the effect twice (subscribe, unsubscribe,
|
|
|
1813
2977
|
|
|
1814
2978
|
## Stop running work when the form closes
|
|
1815
2979
|
|
|
2980
|
+
<!-- docs-check: continue -->
|
|
2981
|
+
|
|
1816
2982
|
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`:
|
|
1817
2983
|
|
|
1818
2984
|
```js
|
|
@@ -1829,11 +2995,55 @@ const checkName = latest(async (name, ctx) => {
|
|
|
1829
2995
|
|
|
1830
2996
|
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.
|
|
1831
2997
|
|
|
2998
|
+
## Keep a debounced handler between renders
|
|
2999
|
+
|
|
3000
|
+
**Problem.** A component runs again on every render. A policy made in its body, such as `debounce(300, search)`, is made again each time and loses its timer. A policy made once keeps the callback of the first render, which reads old props and state.
|
|
3001
|
+
|
|
3002
|
+
**When.** A handler in a component needs a policy, and you do not need a bus: a search box, an autosave, a submit that must not fire twice.
|
|
3003
|
+
|
|
3004
|
+
`usePolicy(callback, make, deps)` keeps one function between renders. It calls the `callback` of the latest render, so an inline callback sees fresh state. It cancels pending calls when the component unmounts.
|
|
3005
|
+
|
|
3006
|
+
```jsx
|
|
3007
|
+
import { useState } from "react";
|
|
3008
|
+
import { debounce } from "nanoeffects/policies";
|
|
3009
|
+
import { usePolicy } from "nanoeffects/react";
|
|
3010
|
+
|
|
3011
|
+
export const Search = ({ wait }) => {
|
|
3012
|
+
const [items, setItems] = useState([]);
|
|
3013
|
+
|
|
3014
|
+
// usePolicy — a stable function; the first argument is the latest render's callback
|
|
3015
|
+
const onChange = usePolicy(
|
|
3016
|
+
async (query, { signal }) => {
|
|
3017
|
+
const found = await api.search(query, { signal });
|
|
3018
|
+
setItems(found);
|
|
3019
|
+
},
|
|
3020
|
+
// the second argument builds the policy once; task runs the callback above
|
|
3021
|
+
(task) => debounce(wait, task),
|
|
3022
|
+
// deps — list what the second argument reads; a new wait makes a new policy
|
|
3023
|
+
[wait],
|
|
3024
|
+
);
|
|
3025
|
+
|
|
3026
|
+
return (
|
|
3027
|
+
<div>
|
|
3028
|
+
<input onChange={(event) => onChange(event.target.value)} />
|
|
3029
|
+
<p>{items.length} found</p>
|
|
3030
|
+
</div>
|
|
3031
|
+
);
|
|
3032
|
+
};
|
|
3033
|
+
```
|
|
3034
|
+
|
|
3035
|
+
- `onChange` keeps its identity while `wait` stays the same, so it is safe in `useEffect` and in props.
|
|
3036
|
+
- A pending call is cancelled on unmount and when `deps` change.
|
|
3037
|
+
- Do not write `usePolicy(debounce(300, callback))`. The policy would be made on every render or keep a stale callback.
|
|
3038
|
+
- Without a React component, use [`withPolicy`](policies.md#use-a-policy-without-a-bus).
|
|
3039
|
+
|
|
1832
3040
|
## Where to go next
|
|
1833
3041
|
|
|
1834
3042
|
- The policies behind this form: [Policies](policies.md).
|
|
3043
|
+
- A policy as a plain function without a bus: [Use a policy without a bus](policies.md#use-a-policy-without-a-bus).
|
|
1835
3044
|
- `retry`, `timeout` and the other effects: [Effects](effects.md).
|
|
1836
3045
|
- A state library instead of `useState`: [Stores](stores.md).
|
|
3046
|
+
- React Query or SWR instead of your own state: [Use nanoeffects with React Query and SWR](query-libraries.md).
|
|
1837
3047
|
|
|
1838
3048
|
---
|
|
1839
3049
|
Source: docs/context.md
|
|
@@ -1913,27 +3123,29 @@ You give a scope to the thing that uses it. A call can replace it. Nothing else
|
|
|
1913
3123
|
Scopes are a plugin, so a bus that does not use them pays nothing.
|
|
1914
3124
|
|
|
1915
3125
|
```js
|
|
3126
|
+
// bus.js
|
|
1916
3127
|
import { createBus } from "nanoeffects";
|
|
1917
3128
|
import { scopePlugin } from "nanoeffects/scope";
|
|
1918
3129
|
|
|
1919
3130
|
// scopePlugin — makes the scopes below work on this bus; returns the same bus
|
|
1920
|
-
const bus = createBus().use(scopePlugin());
|
|
3131
|
+
export const bus = createBus().use(scopePlugin());
|
|
1921
3132
|
```
|
|
1922
3133
|
|
|
3134
|
+
The bus is in one file and the commands and actions in files named after their domain, so the next example imports `bus` from here. Each section after it keeps its own `bus.js` and domain file, as if it were an app of its own.
|
|
3135
|
+
|
|
1923
3136
|
- Without `scopePlugin` a scope is **ignored**: no error and no warning. If a field is missing from `ctx`, check this first.
|
|
1924
|
-
- Use it
|
|
3137
|
+
- Use it in any order with the other plugins: a scope wins over `effectsPlugin`, `contextPlugin` and `createBus({ context })`.
|
|
1925
3138
|
|
|
1926
3139
|
## Give a command its own fields
|
|
1927
3140
|
|
|
3141
|
+
<!-- docs-check: continue -->
|
|
3142
|
+
|
|
1928
3143
|
```js
|
|
1929
|
-
|
|
3144
|
+
// user.js
|
|
1930
3145
|
import { createCommand } from "nanoeffects/actions";
|
|
1931
|
-
import { scopePlugin } from "nanoeffects/scope";
|
|
1932
|
-
|
|
1933
|
-
const bus = createBus().use(scopePlugin());
|
|
1934
3146
|
|
|
1935
3147
|
// createCommand — third argument is the scope of this command
|
|
1936
|
-
const getUser = createCommand(
|
|
3148
|
+
export const getUser = createCommand(
|
|
1937
3149
|
"user/get",
|
|
1938
3150
|
(id, { api, cache }) => {
|
|
1939
3151
|
// api and cache are on ctx for this command only
|
|
@@ -1941,6 +3153,11 @@ const getUser = createCommand(
|
|
|
1941
3153
|
},
|
|
1942
3154
|
{ api, cache: new Map() },
|
|
1943
3155
|
);
|
|
3156
|
+
```
|
|
3157
|
+
|
|
3158
|
+
```js
|
|
3159
|
+
import { bus } from "./bus";
|
|
3160
|
+
import { getUser } from "./user";
|
|
1944
3161
|
|
|
1945
3162
|
const user = await bus.emit(getUser("1")); // the handler has api and cache
|
|
1946
3163
|
```
|
|
@@ -1952,18 +3169,15 @@ The listeners of `"user/get"` get the same fields when the command is emitted, b
|
|
|
1952
3169
|
An effect is a function with `{ signal }` as its first parameter. In `$effects` it is bound to the task, so it stops with the task.
|
|
1953
3170
|
|
|
1954
3171
|
```js
|
|
1955
|
-
|
|
3172
|
+
// feed.js
|
|
1956
3173
|
import { createCommand } from "nanoeffects/actions";
|
|
1957
3174
|
import { delay, race } from "nanoeffects/effects";
|
|
1958
|
-
import { scopePlugin } from "nanoeffects/scope";
|
|
1959
|
-
|
|
1960
|
-
const bus = createBus().use(scopePlugin());
|
|
1961
3175
|
|
|
1962
3176
|
// your own effect: first parameter is the task (only its signal is used)
|
|
1963
3177
|
const fetchJson = (ctx, url) =>
|
|
1964
3178
|
fetch(url, { signal: ctx.signal }).then((response) => response.json());
|
|
1965
3179
|
|
|
1966
|
-
const loadFeed = createCommand(
|
|
3180
|
+
export const loadFeed = createCommand(
|
|
1967
3181
|
"feed/load",
|
|
1968
3182
|
(url, { fetchJson, race, delay }) =>
|
|
1969
3183
|
// race, delay, fetchJson — bound to this task: no ctx argument
|
|
@@ -1974,6 +3188,19 @@ const loadFeed = createCommand(
|
|
|
1974
3188
|
// $effects — built-in and your own functions share one object
|
|
1975
3189
|
{ $effects: { fetchJson, race, delay } },
|
|
1976
3190
|
);
|
|
3191
|
+
```
|
|
3192
|
+
|
|
3193
|
+
```js
|
|
3194
|
+
// bus.js
|
|
3195
|
+
import { createBus } from "nanoeffects";
|
|
3196
|
+
import { scopePlugin } from "nanoeffects/scope";
|
|
3197
|
+
|
|
3198
|
+
export const bus = createBus().use(scopePlugin());
|
|
3199
|
+
```
|
|
3200
|
+
|
|
3201
|
+
```js
|
|
3202
|
+
import { bus } from "./bus";
|
|
3203
|
+
import { loadFeed } from "./feed";
|
|
1977
3204
|
|
|
1978
3205
|
const result = await bus.emit(loadFeed("/feed"));
|
|
1979
3206
|
```
|
|
@@ -1987,14 +3214,27 @@ const result = await bus.emit(loadFeed("/feed"));
|
|
|
1987
3214
|
The second argument of `emit` is a scope for the tasks of **that** call. It is the usual way to give a test a fake.
|
|
1988
3215
|
|
|
1989
3216
|
```js
|
|
3217
|
+
// bus.js
|
|
1990
3218
|
import { createBus } from "nanoeffects";
|
|
1991
|
-
import { createCommand } from "nanoeffects/actions";
|
|
1992
3219
|
import { scopePlugin } from "nanoeffects/scope";
|
|
1993
3220
|
|
|
1994
|
-
const bus = createBus().use(scopePlugin());
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
3221
|
+
export const bus = createBus().use(scopePlugin());
|
|
3222
|
+
```
|
|
3223
|
+
|
|
3224
|
+
```js
|
|
3225
|
+
// user.js
|
|
3226
|
+
import { createCommand } from "nanoeffects/actions";
|
|
3227
|
+
|
|
3228
|
+
export const getUser = createCommand(
|
|
3229
|
+
"user/get",
|
|
3230
|
+
(id, { api }) => api.getUser(id),
|
|
3231
|
+
{ api },
|
|
3232
|
+
);
|
|
3233
|
+
```
|
|
3234
|
+
|
|
3235
|
+
```js
|
|
3236
|
+
import { bus } from "./bus";
|
|
3237
|
+
import { getUser } from "./user";
|
|
1998
3238
|
|
|
1999
3239
|
// the real api for this call
|
|
2000
3240
|
await bus.emit(getUser("1"));
|
|
@@ -2014,23 +3254,30 @@ Rules for the second argument:
|
|
|
2014
3254
|
## Give an action, a job or a module its own fields
|
|
2015
3255
|
|
|
2016
3256
|
```js
|
|
2017
|
-
|
|
3257
|
+
// user.js
|
|
3258
|
+
import { createAction, createJob } from "nanoeffects/actions";
|
|
2018
3259
|
|
|
2019
3260
|
// createAction — the listeners of this action's calls get the fields
|
|
2020
|
-
const userLoaded = createAction("user/loaded", { store });
|
|
3261
|
+
export const userLoaded = createAction("user/loaded", { store });
|
|
2021
3262
|
|
|
2022
3263
|
// createJob — the scope is the fourth argument, after the outcomes;
|
|
2023
3264
|
// the handler and the listeners of the outcome get its fields
|
|
2024
|
-
const loadUser = createJob(
|
|
3265
|
+
export const loadUser = createJob(
|
|
2025
3266
|
"user/load",
|
|
2026
3267
|
fetchUser,
|
|
2027
3268
|
{ success: userLoaded, fail: "user/failed" },
|
|
2028
3269
|
{ logger },
|
|
2029
3270
|
);
|
|
3271
|
+
```
|
|
3272
|
+
|
|
3273
|
+
```js
|
|
3274
|
+
// audit-module.js
|
|
3275
|
+
import { createModule } from "nanoeffects/actions";
|
|
3276
|
+
import { userLoaded } from "./user";
|
|
2030
3277
|
|
|
2031
3278
|
// createModule — third argument; everything the module owns gets the fields:
|
|
2032
3279
|
// m.on listeners, m.handle handlers, m.spawn tasks
|
|
2033
|
-
const auditModule = createModule(
|
|
3280
|
+
export const auditModule = createModule(
|
|
2034
3281
|
"audit",
|
|
2035
3282
|
(m) => {
|
|
2036
3283
|
m.on(userLoaded, (user, { logger }) => logger.info(user.id));
|
|
@@ -2039,95 +3286,386 @@ const auditModule = createModule(
|
|
|
2039
3286
|
);
|
|
2040
3287
|
```
|
|
2041
3288
|
|
|
2042
|
-
If the outcome action (`success` or `fail`) has a scope of its own, its listeners see the job's fields with the action's own fields over them; the handler of the job sees only the job's.
|
|
3289
|
+
If the outcome action (`success` or `fail`) has a scope of its own, its listeners see the job's fields with the action's own fields over them; the handler of the job sees only the job's.
|
|
3290
|
+
|
|
3291
|
+
A module scope does not leak: a listener registered outside the module does not see it.
|
|
3292
|
+
|
|
3293
|
+
## Which value wins
|
|
3294
|
+
|
|
3295
|
+
When several places set the same field, the strongest one wins:
|
|
3296
|
+
|
|
3297
|
+
| Strength | Where it is set |
|
|
3298
|
+
| -------- | --------------- |
|
|
3299
|
+
| 1 (strongest) | the second argument of `emit` |
|
|
3300
|
+
| 2 | the scope of the action, command or job |
|
|
3301
|
+
| 3 | the scope of the module that owns the handler |
|
|
3302
|
+
| 4 (weakest) | the bus: `createBus({ context })`, `contextPlugin`, `effectsPlugin` |
|
|
3303
|
+
|
|
3304
|
+
The fields of the library (`signal`, `abort`, `emit`, `on`, `off`, `run`, `type`, `payload`) are never replaced. A child task (`ctx.run`, an attempt of `retry`) has the fields of its parent.
|
|
3305
|
+
|
|
3306
|
+
## Keep a generic effect generic
|
|
3307
|
+
|
|
3308
|
+
In `$effects` a function loses its first parameter, and TypeScript cannot keep a generic through that step: `<T>(items: T[]) => T` becomes `(items: unknown[]) => unknown`. JavaScript code is not affected. If you want the exact type, use one of two ways.
|
|
3309
|
+
|
|
3310
|
+
`effect(fn)`: mark the function where you declare it, then put it in the scope like any other value. No annotation, so a JavaScript editor shows the exact hint too.
|
|
3311
|
+
|
|
3312
|
+
```js
|
|
3313
|
+
// pick.js
|
|
3314
|
+
import { createCommand } from "nanoeffects/actions";
|
|
3315
|
+
import { effect } from "nanoeffects/scope";
|
|
3316
|
+
|
|
3317
|
+
// effect — marks the function, so a scope binds it to the task; the editor keeps its generic
|
|
3318
|
+
const first = effect((ctx, items) => items[0]);
|
|
3319
|
+
|
|
3320
|
+
export const pick = createCommand("pick", (items, { first }) => first(items), {
|
|
3321
|
+
first, // a plain key of the scope, not an entry of `$effects`
|
|
3322
|
+
});
|
|
3323
|
+
```
|
|
3324
|
+
|
|
3325
|
+
Call `effect` where you declare the function, as above, and not inline in the scope: TypeScript does not infer the scope from a generic call written there.
|
|
3326
|
+
|
|
3327
|
+
`Effect<F>` (TypeScript): declare how the effect looks **after** it is bound, and keep it in `$effects`.
|
|
3328
|
+
|
|
3329
|
+
```ts
|
|
3330
|
+
import { createCommand } from "nanoeffects/actions";
|
|
3331
|
+
import type { Effect } from "nanoeffects/effects";
|
|
3332
|
+
|
|
3333
|
+
// First — checker-only: how a handler calls the effect (no ctx)
|
|
3334
|
+
type First = <T>(items: T[]) => T;
|
|
3335
|
+
|
|
3336
|
+
// Effect<First> — the function takes the task first and is typed as First once bound
|
|
3337
|
+
const first: Effect<First> = (ctx, items) => items[0];
|
|
3338
|
+
|
|
3339
|
+
const pick = createCommand(
|
|
3340
|
+
"pick",
|
|
3341
|
+
(id: string, { first }) => first([id]), // string
|
|
3342
|
+
{ $effects: { first } },
|
|
3343
|
+
);
|
|
3344
|
+
```
|
|
3345
|
+
|
|
3346
|
+
`Effect<F>` and `effect` change what the checker sees. At run time the function is the same.
|
|
3347
|
+
|
|
3348
|
+
## Type the fields (optional)
|
|
3349
|
+
|
|
3350
|
+
A handler of a command, job or module with a scope sees the fields of the scope in `ctx`, typed from the values you gave. You write no `WithContext`.
|
|
3351
|
+
|
|
3352
|
+
```ts
|
|
3353
|
+
import { createCommand } from "nanoeffects/actions";
|
|
3354
|
+
|
|
3355
|
+
type User = { id: string; name: string };
|
|
3356
|
+
|
|
3357
|
+
const getUser = createCommand(
|
|
3358
|
+
"user/get",
|
|
3359
|
+
async (id: string, ctx) => {
|
|
3360
|
+
ctx.cache; // Map<string, User>, from the scope below
|
|
3361
|
+
return ctx.cache.get(id);
|
|
3362
|
+
},
|
|
3363
|
+
{ cache: new Map<string, User>() },
|
|
3364
|
+
);
|
|
3365
|
+
```
|
|
3366
|
+
|
|
3367
|
+
- A field of a scope replaces a field of the same name from the bus in the type too.
|
|
3368
|
+
- An override in `emit` must match the type of the field it replaces: `emit(getUser("1"), { cache: 1 })` is an error.
|
|
3369
|
+
- A field that no scope and no bus declares has no type to check against, so `emit(call, { unknown: 1 })` is an error in TypeScript.
|
|
3370
|
+
- `bus.on(creator, fn)` for an action with a scope types `ctx` with its fields.
|
|
3371
|
+
|
|
3372
|
+
## Limits
|
|
3373
|
+
|
|
3374
|
+
- No plugin, no scope: see [Turn scopes on](#turn-scopes-on).
|
|
3375
|
+
- The fields of the bus are not in the handler of a command made with `createCommand(…, scope)` unless you type `ctx` yourself; the fields of the scope are.
|
|
3376
|
+
- The string form of `emit` takes no scope.
|
|
3377
|
+
- `effect(fn)` works only as a value of a scope, not as an entry of `$effects`.
|
|
3378
|
+
- A scope on a command, action, job or module is typed without the events of a bus, so `take` in its `$effects` takes any event name.
|
|
3379
|
+
|
|
3380
|
+
---
|
|
3381
|
+
Source: docs/oop.md
|
|
3382
|
+
|
|
3383
|
+
# Use the bus from a class
|
|
3384
|
+
|
|
3385
|
+
Use this page when **your domain logic lives in classes**: a service, a store or a controller that needs the bus inside its methods. A class made with `extendBus` has `on`, `emit` and the other methods of the bus, and remembers what each instance subscribed to, so `dispose()` ends it all.
|
|
3386
|
+
|
|
3387
|
+
If your logic is plain functions, stay with [modules](actions.md#mount-domains-with-modules): a module is the same idea without a class.
|
|
3388
|
+
|
|
3389
|
+
## The problem without it
|
|
3390
|
+
|
|
3391
|
+
A class that listens to the bus keeps a reference to it, and keeps every unsubscribe function in a list to call when the object goes away. Forget one and the old object keeps handling events. `extendBus` does both for you.
|
|
3392
|
+
|
|
3393
|
+
## Give a class the methods of a bus
|
|
3394
|
+
|
|
3395
|
+
`extendBus(bus, Base)` returns a subclass of `Base`. Its instances have the methods of the bus, and the arguments of `new` go to the constructor of `Base`.
|
|
3396
|
+
|
|
3397
|
+
```js
|
|
3398
|
+
// bus.js
|
|
3399
|
+
import { createBus } from "nanoeffects";
|
|
3400
|
+
|
|
3401
|
+
// createBus — the bus that the services of the app share
|
|
3402
|
+
export const bus = createBus();
|
|
3403
|
+
```
|
|
3404
|
+
|
|
3405
|
+
```js
|
|
3406
|
+
// search.js
|
|
3407
|
+
import { createAction } from "nanoeffects/actions";
|
|
3408
|
+
|
|
3409
|
+
// createAction — the events of the search domain
|
|
3410
|
+
export const queryChanged = createAction("search/query-changed");
|
|
3411
|
+
export const resultsLoaded = createAction("search/results-loaded");
|
|
3412
|
+
```
|
|
3413
|
+
|
|
3414
|
+
```js
|
|
3415
|
+
// search-service.js
|
|
3416
|
+
import { extendBus } from "nanoeffects/oop";
|
|
3417
|
+
import { bus } from "./bus";
|
|
3418
|
+
import { queryChanged, resultsLoaded } from "./search";
|
|
3419
|
+
|
|
3420
|
+
class SearchServiceBase {
|
|
3421
|
+
constructor(api) {
|
|
3422
|
+
this.api = api;
|
|
3423
|
+
// this.on — a subscription of this instance; dispose() removes it
|
|
3424
|
+
this.on(queryChanged, (query, ctx) => this.search(query, ctx)); // an arrow keeps this
|
|
3425
|
+
}
|
|
3426
|
+
|
|
3427
|
+
async search(query, { signal, emit }) {
|
|
3428
|
+
const items = await this.api.search(query, { signal });
|
|
3429
|
+
emit(resultsLoaded(items));
|
|
3430
|
+
}
|
|
3431
|
+
}
|
|
3432
|
+
|
|
3433
|
+
// extendBus — a subclass whose instances have on, once, off, emit and dispose
|
|
3434
|
+
export const SearchService = extendBus(bus, SearchServiceBase);
|
|
3435
|
+
```
|
|
3436
|
+
|
|
3437
|
+
```js
|
|
3438
|
+
import { queryChanged } from "./search";
|
|
3439
|
+
import { SearchService } from "./search-service";
|
|
3440
|
+
|
|
3441
|
+
const service = new SearchService(api); // the arguments go to SearchServiceBase
|
|
3442
|
+
service.emit(queryChanged("nano")); // the same bus as bus.emit
|
|
3443
|
+
service.dispose(); // its subscriptions are gone
|
|
3444
|
+
```
|
|
3445
|
+
|
|
3446
|
+
What you get:
|
|
3447
|
+
|
|
3448
|
+
- The class keeps the name of `Base`, and its instances are instances of `Base`.
|
|
3449
|
+
- `on`, `once`, `off` and `emit` act on the bus. They can be destructured: `const { emit } = service`.
|
|
3450
|
+
- The other methods of the bus, such as `emitter` from [`emitterPlugin`](plugins.md), are there too. Only the plugins used **before** `extendBus` count: the names are taken when it runs.
|
|
3451
|
+
- `bus` is the bus, and `Class.bus` is the same one.
|
|
3452
|
+
- `this.on` works in the constructor of `Base`. The methods exist on the prototype of the subclass, which is the prototype of the instance already.
|
|
3453
|
+
|
|
3454
|
+
## Let the class make its bus
|
|
3455
|
+
|
|
3456
|
+
Leave the bus out and the class gets its own, shared by all of its instances.
|
|
3457
|
+
|
|
3458
|
+
```js
|
|
3459
|
+
// notifier.js
|
|
3460
|
+
import { extendBus } from "nanoeffects/oop";
|
|
3461
|
+
|
|
3462
|
+
class NotifierBase {}
|
|
3463
|
+
|
|
3464
|
+
// extendBus without a bus — a new bus for this class; two classes have two buses
|
|
3465
|
+
export const Notifier = extendBus(NotifierBase);
|
|
3466
|
+
|
|
3467
|
+
const first = new Notifier();
|
|
3468
|
+
const second = new Notifier();
|
|
3469
|
+
second.on("notified", (message) => console.log(message));
|
|
3470
|
+
first.emit("notified", "hello"); // the instances share Notifier.bus
|
|
3471
|
+
```
|
|
3472
|
+
|
|
3473
|
+
Pass a bus when you need plugins (`contextPlugin`, `scopePlugin`) or when several classes talk to each other.
|
|
3474
|
+
|
|
3475
|
+
## End the subscriptions with `dispose`
|
|
3476
|
+
|
|
3477
|
+
`dispose()` removes every subscription made through `this.on` and `this.once` of that instance, and nothing else. It is safe to call twice. `instance[Symbol.dispose]()` does the same, so `using service = new SearchService(api)` works where the runtime supports it.
|
|
3478
|
+
|
|
3479
|
+
```js
|
|
3480
|
+
// dispose.js
|
|
3481
|
+
import { createBus } from "nanoeffects";
|
|
3482
|
+
import { extendBus } from "nanoeffects/oop";
|
|
3483
|
+
|
|
3484
|
+
const bus = createBus();
|
|
3485
|
+
const Service = extendBus(bus, class {});
|
|
3486
|
+
|
|
3487
|
+
const service = new Service();
|
|
3488
|
+
const stop = service.on("ping", () => console.log("service heard it"));
|
|
3489
|
+
bus.on("ping", () => console.log("the bus heard it")); // not a subscription of the service
|
|
3490
|
+
|
|
3491
|
+
service.dispose();
|
|
3492
|
+
bus.emit("ping"); // only "the bus heard it"
|
|
3493
|
+
stop(); // the function on returned still works, and does nothing now
|
|
3494
|
+
```
|
|
3495
|
+
|
|
3496
|
+
A subscription that you removed yourself (the function `on` returned, `this.off`, or a `once` that fired) is forgotten, so the instance holds nothing for it.
|
|
3497
|
+
|
|
3498
|
+
## Keep a method of your own
|
|
3499
|
+
|
|
3500
|
+
The class wins any clash of names. If `Base` has `emit`, `dispose`, `on` or `bus`, yours stays and the bus method is not added. A `Base` with its own `dispose` would never end the subscriptions, so call `disposeBus(this)` from there.
|
|
3501
|
+
|
|
3502
|
+
```js
|
|
3503
|
+
// cache.js
|
|
3504
|
+
import { createBus } from "nanoeffects";
|
|
3505
|
+
import { disposeBus, extendBus } from "nanoeffects/oop";
|
|
3506
|
+
|
|
3507
|
+
const bus = createBus();
|
|
3508
|
+
|
|
3509
|
+
class CacheBase {
|
|
3510
|
+
dispose() {
|
|
3511
|
+
this.items.clear(); // your own cleanup
|
|
3512
|
+
disposeBus(this); // disposeBus — ends the subscriptions of this instance
|
|
3513
|
+
}
|
|
3514
|
+
|
|
3515
|
+
items = new Map();
|
|
3516
|
+
}
|
|
3517
|
+
|
|
3518
|
+
export const Cache = extendBus(bus, CacheBase);
|
|
3519
|
+
```
|
|
3520
|
+
|
|
3521
|
+
`disposeBus` does nothing for a value that is not an instance of such a class.
|
|
3522
|
+
|
|
3523
|
+
## Subscribe a method with `@On` and `@Once`
|
|
3524
|
+
|
|
3525
|
+
A decorator turns a method into a handler of an event for each instance, with `this` bound. You write no `this.on` in the constructor, and `dispose()` ends the subscription too.
|
|
3526
|
+
|
|
3527
|
+
The bus and the events are the same as above, in TypeScript:
|
|
3528
|
+
|
|
3529
|
+
```ts
|
|
3530
|
+
// bus.ts
|
|
3531
|
+
import { createBus } from "nanoeffects";
|
|
3532
|
+
|
|
3533
|
+
export const bus = createBus();
|
|
3534
|
+
```
|
|
3535
|
+
|
|
3536
|
+
```ts
|
|
3537
|
+
// search.ts
|
|
3538
|
+
import { createAction } from "nanoeffects/actions";
|
|
3539
|
+
|
|
3540
|
+
export const queryChanged = createAction<string>("search/query-changed");
|
|
3541
|
+
export const resultsLoaded = createAction<string[]>("search/results-loaded");
|
|
3542
|
+
```
|
|
3543
|
+
|
|
3544
|
+
```ts
|
|
3545
|
+
// search-service.ts
|
|
3546
|
+
import { latest } from "nanoeffects/policies";
|
|
3547
|
+
import { extendBus, On, Once } from "nanoeffects/oop";
|
|
3548
|
+
import { bus } from "./bus";
|
|
3549
|
+
import { queryChanged, resultsLoaded } from "./search";
|
|
3550
|
+
|
|
3551
|
+
class SearchServiceBase {
|
|
3552
|
+
// On — this method handles queryChanged for each instance, this is the instance;
|
|
3553
|
+
// latest — each new query aborts the one before it
|
|
3554
|
+
@On(queryChanged, latest)
|
|
3555
|
+
async search(query: string, { signal, emit }: { signal: AbortSignal; emit: (event: unknown) => void }) {
|
|
3556
|
+
const items = await api.search(query, { signal });
|
|
3557
|
+
emit(resultsLoaded(items));
|
|
3558
|
+
}
|
|
2043
3559
|
|
|
2044
|
-
|
|
3560
|
+
// Once — runs for the first event only
|
|
3561
|
+
@Once("search/ready")
|
|
3562
|
+
ready() {
|
|
3563
|
+
console.log("first ready");
|
|
3564
|
+
}
|
|
3565
|
+
}
|
|
2045
3566
|
|
|
2046
|
-
|
|
3567
|
+
export const SearchService = extendBus(bus, SearchServiceBase);
|
|
3568
|
+
```
|
|
2047
3569
|
|
|
2048
|
-
|
|
3570
|
+
Things to know:
|
|
2049
3571
|
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
3572
|
+
- The event is a name, an action creator or a command, as for `bus.on`. For a creator or a command the payload of the method is checked against it.
|
|
3573
|
+
- The second argument, `wrap`, takes the bound handler and returns the one to subscribe: `latest`, or `(handler) => debounce(300, handler)`. A policy needs it: `latest(this.search)` wraps the function without `this`, and the bound handler is what `wrap` gets.
|
|
3574
|
+
- The subscriptions are made right after the constructor of `Base`. An event that is emitted inside that constructor does not reach them.
|
|
3575
|
+
- A subclass inherits them. A method that a subclass overrides is subscribed only if the override is decorated.
|
|
3576
|
+
- On a class that `extendBus` did not make, a decorator does nothing.
|
|
3577
|
+
- Both the decorators of the language and `experimentalDecorators` work. Your compiler has to support the one you use: TypeScript 5 or newer for the first, and the `experimentalDecorators` option for the second.
|
|
2056
3578
|
|
|
2057
|
-
|
|
3579
|
+
## Decorate the class with `@WithBus`
|
|
2058
3580
|
|
|
2059
|
-
|
|
3581
|
+
`@WithBus(bus)` does what `extendBus(bus, Base)` does, as a class decorator. `@WithBus()` makes a bus for the class.
|
|
2060
3582
|
|
|
2061
|
-
|
|
3583
|
+
<!-- docs-check: continue -->
|
|
2062
3584
|
|
|
2063
|
-
|
|
3585
|
+
```ts
|
|
3586
|
+
// search-service.ts
|
|
3587
|
+
import { type BusMethods, On, WithBus } from "nanoeffects/oop";
|
|
3588
|
+
import { bus } from "./bus";
|
|
3589
|
+
import { queryChanged } from "./search";
|
|
3590
|
+
|
|
3591
|
+
// TypeScript does not change the type of a decorated class, so say what it gets;
|
|
3592
|
+
// checker only: at run time WithBus already adds the methods
|
|
3593
|
+
interface SearchService extends BusMethods<typeof bus> {}
|
|
3594
|
+
|
|
3595
|
+
// WithBus — replaces the class by extendBus(bus, SearchService)
|
|
3596
|
+
@WithBus(bus)
|
|
3597
|
+
class SearchService {
|
|
3598
|
+
@On(queryChanged)
|
|
3599
|
+
search(query: string) {
|
|
3600
|
+
console.log(query);
|
|
3601
|
+
}
|
|
3602
|
+
}
|
|
2064
3603
|
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
import { effect } from "nanoeffects/scope";
|
|
3604
|
+
export { SearchService };
|
|
3605
|
+
```
|
|
2068
3606
|
|
|
2069
|
-
|
|
2070
|
-
const first = effect((ctx, items) => items[0]);
|
|
3607
|
+
Prefer `extendBus(bus, Base)` when you can: its types come from the call and need no `interface`.
|
|
2071
3608
|
|
|
2072
|
-
|
|
2073
|
-
first, // a plain key of the scope, not an entry of `$effects`
|
|
2074
|
-
});
|
|
2075
|
-
```
|
|
3609
|
+
## Pass a method by hand
|
|
2076
3610
|
|
|
2077
|
-
|
|
3611
|
+
A handler given to `this.on` is called as the bus calls it, with no `this`. A method passed bare loses `this`, as in plain JavaScript:
|
|
2078
3612
|
|
|
2079
|
-
|
|
3613
|
+
```js
|
|
3614
|
+
// handlers.js
|
|
3615
|
+
import { createBus } from "nanoeffects";
|
|
3616
|
+
import { extendBus } from "nanoeffects/oop";
|
|
2080
3617
|
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
import type { Effect } from "nanoeffects/effects";
|
|
3618
|
+
class BaseWithHandler {
|
|
3619
|
+
name = "handler";
|
|
2084
3620
|
|
|
2085
|
-
|
|
2086
|
-
|
|
3621
|
+
handle() {
|
|
3622
|
+
console.log(this?.name);
|
|
3623
|
+
}
|
|
2087
3624
|
|
|
2088
|
-
|
|
2089
|
-
|
|
3625
|
+
listen() {
|
|
3626
|
+
this.on("ping", this.handle); // this is lost: prints undefined
|
|
3627
|
+
this.on("ping", () => this.handle()); // an arrow keeps it
|
|
3628
|
+
}
|
|
3629
|
+
}
|
|
2090
3630
|
|
|
2091
|
-
const
|
|
2092
|
-
"pick",
|
|
2093
|
-
(id: string, { first }) => first([id]), // string
|
|
2094
|
-
{ $effects: { first } },
|
|
2095
|
-
);
|
|
3631
|
+
export const Listener = extendBus(createBus(), BaseWithHandler);
|
|
2096
3632
|
```
|
|
2097
3633
|
|
|
2098
|
-
|
|
3634
|
+
Use an arrow or a decorated method. The library does not bind `this` for you: a policy such as `latest(this.handle)` wraps the function, so binding would work in half of the cases.
|
|
2099
3635
|
|
|
2100
|
-
## Type the
|
|
3636
|
+
## Type the instances (optional)
|
|
2101
3637
|
|
|
2102
|
-
|
|
3638
|
+
`extendBus` returns a class that takes the arguments of `Base`, and its instances are `Base` plus `BusMethods<typeof bus>`: the function members of the bus (`on`, `emit`, and those of plugins used before) with `dispose` and `bus`. A member of `Base` with the same name keeps its own type.
|
|
2103
3639
|
|
|
2104
3640
|
```ts
|
|
2105
|
-
|
|
3641
|
+
// service.ts
|
|
3642
|
+
import { createBus } from "nanoeffects";
|
|
3643
|
+
import { extendBus } from "nanoeffects/oop";
|
|
2106
3644
|
|
|
2107
|
-
type
|
|
3645
|
+
type Events = { "order/placed": { id: string } };
|
|
2108
3646
|
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
async (id: string, ctx) => {
|
|
2112
|
-
ctx.cache; // Map<string, User>, from the scope below
|
|
2113
|
-
return ctx.cache.get(id);
|
|
2114
|
-
},
|
|
2115
|
-
{ cache: new Map<string, User>() },
|
|
2116
|
-
);
|
|
2117
|
-
```
|
|
3647
|
+
// Events — checker only: the payloads of the bus
|
|
3648
|
+
const bus = createBus<Events>();
|
|
2118
3649
|
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
- A field that no scope and no bus declares has no type to check against, so `emit(call, { unknown: 1 })` is an error in TypeScript.
|
|
2122
|
-
- `bus.on(creator, fn)` for an action with a scope types `ctx` with its fields.
|
|
3650
|
+
class OrderServiceBase {
|
|
3651
|
+
name: string;
|
|
2123
3652
|
|
|
2124
|
-
|
|
3653
|
+
constructor(name: string) {
|
|
3654
|
+
this.name = name;
|
|
3655
|
+
}
|
|
3656
|
+
}
|
|
2125
3657
|
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
3658
|
+
export const OrderService = extendBus(bus, OrderServiceBase);
|
|
3659
|
+
|
|
3660
|
+
const service = new OrderService("main"); // the constructor of OrderServiceBase
|
|
3661
|
+
service.emit("order/placed", { id: "o1" }); // checked against Events
|
|
3662
|
+
```
|
|
3663
|
+
|
|
3664
|
+
## What is not done
|
|
3665
|
+
|
|
3666
|
+
- `this` is not bound in a handler that you pass by hand (see above).
|
|
3667
|
+
- The methods of a plugin used after `extendBus` are not on the class. Use the plugins first, then `extendBus`.
|
|
3668
|
+
- One bus for each instance, mixing the methods into an object that exists, and decorators for fields or parameters are not part of this entry.
|
|
2131
3669
|
|
|
2132
3670
|
---
|
|
2133
3671
|
Source: docs/typescript.md
|
|
@@ -2408,7 +3946,7 @@ nanoeffects has no store. `createStore` can be replaced by any store you like, f
|
|
|
2408
3946
|
| event call `loaded(user)` | `bus.emit("loaded", user)` |
|
|
2409
3947
|
| `createEffect(handler)` | a handler: `bus.on(event, handler)` |
|
|
2410
3948
|
| effect with `done` and `fail` | [`createJob`](actions.md): emits the success and fail actions |
|
|
2411
|
-
| `effect.pending` |
|
|
3949
|
+
| `effect.pending` | `job.pending`, `job.success`, `job.fail` and `job.cancel`: set and clear the flag in your store ([Show a loading state](actions.md#show-a-loading-state-with-a-job)) |
|
|
2412
3950
|
| `sample({ clock, fn, target })` | `bus.on(clock, handler)` and `ctx.emit(target, …)` |
|
|
2413
3951
|
| `sample({ source: $store, clock })` | read the store from a [context field](context.md) in the handler |
|
|
2414
3952
|
| `sample({ filter })` | an `if` in the handler |
|
|
@@ -2461,16 +3999,26 @@ The store (`$items`) is not here. Keep it in Effector and update it from the `fo
|
|
|
2461
3999
|
|
|
2462
4000
|
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.
|
|
2463
4001
|
|
|
2464
|
-
```
|
|
2465
|
-
//
|
|
2466
|
-
import { create } from "zustand";
|
|
4002
|
+
```js
|
|
4003
|
+
// bus.js
|
|
2467
4004
|
import { createBus } from "nanoeffects";
|
|
2468
|
-
|
|
4005
|
+
|
|
4006
|
+
export const bus = createBus(); // the bus: only the bus
|
|
4007
|
+
```
|
|
4008
|
+
|
|
4009
|
+
```js
|
|
4010
|
+
// search-store.js
|
|
4011
|
+
import { create } from "zustand";
|
|
2469
4012
|
|
|
2470
4013
|
// create — Zustand: the store replaces $items
|
|
2471
|
-
const useSearchStore = create(() => ({ items: [] }));
|
|
4014
|
+
export const useSearchStore = create(() => ({ items: [] }));
|
|
4015
|
+
```
|
|
2472
4016
|
|
|
2473
|
-
|
|
4017
|
+
```js
|
|
4018
|
+
// search-handlers.js
|
|
4019
|
+
import { debounce, latest } from "nanoeffects/policies";
|
|
4020
|
+
import { bus } from "./bus";
|
|
4021
|
+
import { useSearchStore } from "./search-store";
|
|
2474
4022
|
|
|
2475
4023
|
// latest — a newer query aborts the request in flight; debounce — wait 300 ms of quiet
|
|
2476
4024
|
const search = latest(async (query, { signal }) => {
|
|
@@ -2479,6 +4027,12 @@ const search = latest(async (query, { signal }) => {
|
|
|
2479
4027
|
});
|
|
2480
4028
|
|
|
2481
4029
|
bus.on("query-changed", debounce(300, search));
|
|
4030
|
+
```
|
|
4031
|
+
|
|
4032
|
+
```jsx
|
|
4033
|
+
// Search.jsx
|
|
4034
|
+
import { bus } from "./bus";
|
|
4035
|
+
import { useSearchStore } from "./search-store";
|
|
2482
4036
|
|
|
2483
4037
|
export function Search() {
|
|
2484
4038
|
const items = useSearchStore((state) => state.items); // Zustand: a selector reads items
|
|
@@ -2554,13 +4108,18 @@ formSubmitted.watch((event) => {
|
|
|
2554
4108
|
});
|
|
2555
4109
|
```
|
|
2556
4110
|
|
|
2557
|
-
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:
|
|
4111
|
+
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 and the handler, each in its own file:
|
|
2558
4112
|
|
|
2559
4113
|
```js
|
|
2560
|
-
//
|
|
4114
|
+
// bus.js
|
|
2561
4115
|
import { createBus } from "nanoeffects";
|
|
2562
4116
|
|
|
2563
|
-
export const bus = createBus(); // the bus:
|
|
4117
|
+
export const bus = createBus(); // the bus: only the bus
|
|
4118
|
+
```
|
|
4119
|
+
|
|
4120
|
+
```js
|
|
4121
|
+
// form-handlers.js
|
|
4122
|
+
import { bus } from "./bus";
|
|
2564
4123
|
|
|
2565
4124
|
// bus.on — the job of sendFormFx: runs when the form is submitted
|
|
2566
4125
|
bus.on("form/submitted", (values) => {
|
|
@@ -2573,7 +4132,8 @@ Then the form. `useState` does the work of `$form`, `fieldUpdate` and `prepend`;
|
|
|
2573
4132
|
```jsx
|
|
2574
4133
|
// App.jsx
|
|
2575
4134
|
import { useState } from "react";
|
|
2576
|
-
import { bus } from "./
|
|
4135
|
+
import { bus } from "./bus"; // the bus of the previous snippet
|
|
4136
|
+
import "./form-handlers"; // registers the handler; the app does this once, when it starts
|
|
2577
4137
|
|
|
2578
4138
|
function Field({ name, type, label, value, onChange }) {
|
|
2579
4139
|
return (
|
|
@@ -2643,16 +4203,27 @@ Retrying a failed network request, and cancelling when the form closes, are in [
|
|
|
2643
4203
|
|
|
2644
4204
|
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.
|
|
2645
4205
|
|
|
2646
|
-
<!-- docs-check: skip - needs effector, which the project does not depend on -->
|
|
2647
4206
|
```js
|
|
2648
|
-
|
|
4207
|
+
// bus.js
|
|
2649
4208
|
import { createBus } from "nanoeffects";
|
|
2650
|
-
import {
|
|
2651
|
-
|
|
4209
|
+
import { emitterPlugin } from "nanoeffects/actions";
|
|
4210
|
+
|
|
4211
|
+
export const bus = createBus().use(emitterPlugin()); // adds bus.emitter
|
|
4212
|
+
```
|
|
4213
|
+
|
|
4214
|
+
```js
|
|
4215
|
+
// search.js
|
|
4216
|
+
import { createAction } from "nanoeffects/actions";
|
|
2652
4217
|
|
|
2653
|
-
const
|
|
4218
|
+
export const queryChanged = createAction("query/changed"); // a plain action creator
|
|
4219
|
+
```
|
|
2654
4220
|
|
|
2655
|
-
|
|
4221
|
+
<!-- docs-check: skip - needs effector, which the project does not depend on -->
|
|
4222
|
+
```js
|
|
4223
|
+
import { createEvent, createStore } from "effector";
|
|
4224
|
+
import { latest } from "nanoeffects/policies";
|
|
4225
|
+
import { bus } from "./bus";
|
|
4226
|
+
import { queryChanged } from "./search";
|
|
2656
4227
|
|
|
2657
4228
|
const found = createEvent(); // Effector: the result comes back as an event
|
|
2658
4229
|
const $items = createStore([]).on(found, (_, items) => items);
|
|
@@ -2673,13 +4244,114 @@ bus.on(
|
|
|
2673
4244
|
## What is different
|
|
2674
4245
|
|
|
2675
4246
|
- **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)).
|
|
2676
|
-
-
|
|
4247
|
+
- **`pending` is an action, not a store.** `createJob` sends `pending` when the handler starts, and `success`, `fail` or `cancel` when it ends; you keep the flag in your store ([Show a loading state](actions.md#show-a-loading-state-with-a-job)). With a policy such as `latest` around the handler the flag is not exact yet, which that section explains. A running task is also visible through [Tracing](tracing.md).
|
|
2677
4248
|
- **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.
|
|
2678
4249
|
- **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.
|
|
2679
4250
|
- **No graph.** `sample` and `forward` build a static graph. A bus subscribes handlers at runtime and the call order is the order of `on`.
|
|
2680
4251
|
- **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).
|
|
2681
4252
|
- **Scopes are buses.** `fork()` becomes a `createBus()` per scope (a test, a request on the server).
|
|
2682
4253
|
|
|
4254
|
+
---
|
|
4255
|
+
Source: docs/migrating-from-mitt.md
|
|
4256
|
+
|
|
4257
|
+
# Coming from mitt, nanoevents or EventEmitter
|
|
4258
|
+
|
|
4259
|
+
Read this page if your app uses a small event emitter (`mitt`, `nanoevents`, Node's `EventEmitter`, `tiny-emitter`) and its handlers start to do async work: requests, timers, retries. It maps the calls, shows what you gain, and shows what to check before you switch.
|
|
4260
|
+
|
|
4261
|
+
## When to switch
|
|
4262
|
+
|
|
4263
|
+
An emitter calls a function and forgets it. That is enough while handlers are synchronous. Switch when handlers start async work and you hit one of these:
|
|
4264
|
+
|
|
4265
|
+
- an older request finishes after a newer one and overwrites its result;
|
|
4266
|
+
- you keep `AbortController` objects by hand;
|
|
4267
|
+
- you write debounce, throttle or retry around handlers yourself;
|
|
4268
|
+
- you cannot tell which event started which request.
|
|
4269
|
+
|
|
4270
|
+
If handlers stay synchronous, keep your emitter. nanoeffects costs about 1.4 kB gzip (1.3 kB brotli), close to the small emitters.
|
|
4271
|
+
|
|
4272
|
+
## Find the equivalent
|
|
4273
|
+
|
|
4274
|
+
| mitt / EventEmitter | nanoeffects |
|
|
4275
|
+
| ------------------- | ----------- |
|
|
4276
|
+
| `emitter.on("a", handler)` | `bus.on("a", handler)` |
|
|
4277
|
+
| `emitter.off("a", handler)` | call the function that `bus.on` returned, or `bus.off("a")` for all handlers of `a` |
|
|
4278
|
+
| `emitter.emit("a", payload)` | `bus.emit("a", payload)` |
|
|
4279
|
+
| `emitter.once("a", handler)` | `bus.once("a", handler)` |
|
|
4280
|
+
| `on("*", (type, payload) => …)` | `bus.on("*", (payload, ctx) => …)`: the event name is `ctx.type` (see the [Guide](guide.md)) |
|
|
4281
|
+
| `emitter.all.clear()` | `bus.off()` |
|
|
4282
|
+
|
|
4283
|
+
Two differences to know:
|
|
4284
|
+
|
|
4285
|
+
- `bus.on` returns the unsubscribe function. You do not need to keep the handler to remove it.
|
|
4286
|
+
- The handler gets a second argument, the task context: `(payload, ctx)`. You can ignore it until you need `ctx.signal` or `ctx.emit`.
|
|
4287
|
+
|
|
4288
|
+
## Move a handler
|
|
4289
|
+
|
|
4290
|
+
Before, with `mitt`:
|
|
4291
|
+
|
|
4292
|
+
<!-- docs-check: skip - needs mitt, which the project does not depend on -->
|
|
4293
|
+
```js
|
|
4294
|
+
import mitt from "mitt";
|
|
4295
|
+
|
|
4296
|
+
const emitter = mitt();
|
|
4297
|
+
|
|
4298
|
+
emitter.on("search", async (query) => {
|
|
4299
|
+
const items = await api.search(query);
|
|
4300
|
+
emitter.emit("results", items);
|
|
4301
|
+
});
|
|
4302
|
+
|
|
4303
|
+
emitter.emit("search", "abc");
|
|
4304
|
+
```
|
|
4305
|
+
|
|
4306
|
+
After, with nanoeffects. The first step changes only the import and the constructor:
|
|
4307
|
+
|
|
4308
|
+
```js
|
|
4309
|
+
import { createBus } from "nanoeffects";
|
|
4310
|
+
|
|
4311
|
+
const bus = createBus(); // event bus; each handler call is a task
|
|
4312
|
+
|
|
4313
|
+
bus.on("search", async (query) => {
|
|
4314
|
+
const items = await api.search(query);
|
|
4315
|
+
bus.emit("results", items);
|
|
4316
|
+
});
|
|
4317
|
+
|
|
4318
|
+
bus.emit("search", "abc");
|
|
4319
|
+
```
|
|
4320
|
+
|
|
4321
|
+
The second step fixes the race. Wrap the handler in `latest` and pass the signal to the request:
|
|
4322
|
+
|
|
4323
|
+
```js
|
|
4324
|
+
import { createBus } from "nanoeffects";
|
|
4325
|
+
import { latest } from "nanoeffects/policies";
|
|
4326
|
+
|
|
4327
|
+
const bus = createBus(); // event bus; each handler call is a task
|
|
4328
|
+
|
|
4329
|
+
// latest: a newer "search" aborts the older run
|
|
4330
|
+
bus.on(
|
|
4331
|
+
"search",
|
|
4332
|
+
latest(async (query, { signal }) => {
|
|
4333
|
+
const items = await api.search(query, { signal }); // signal cancels the request
|
|
4334
|
+
bus.emit("results", items);
|
|
4335
|
+
}),
|
|
4336
|
+
);
|
|
4337
|
+
|
|
4338
|
+
bus.emit("search", "abc");
|
|
4339
|
+
```
|
|
4340
|
+
|
|
4341
|
+
More cases (pause, retry, timeout): [Cancel stale requests](cancel-stale-requests.md).
|
|
4342
|
+
|
|
4343
|
+
## Check the differences
|
|
4344
|
+
|
|
4345
|
+
| Topic | Emitter | nanoeffects |
|
|
4346
|
+
| ----- | ------- | ----------- |
|
|
4347
|
+
| Handler order | order of subscription | order of subscription |
|
|
4348
|
+
| Error in a handler | depends on the library | the first error stops the rest and is thrown from `emit`; set `onError` to isolate handlers ([Errors](errors.md)) |
|
|
4349
|
+
| Async handler rejects | usually an unhandled rejection | not handled without `onError`; set `onError` to catch it |
|
|
4350
|
+
| Event without a listener | ignored | ignored; set `onUnhandled` to be told |
|
|
4351
|
+
| Types | event map in a type parameter | event map in a type parameter ([TypeScript](typescript.md)) |
|
|
4352
|
+
|
|
4353
|
+
The exact rules of `emit`, abort and ordering: [Semantics](semantics.md).
|
|
4354
|
+
|
|
2683
4355
|
---
|
|
2684
4356
|
Source: docs/migrating-from-rxjs.md
|
|
2685
4357
|
|
|
@@ -2775,16 +4447,27 @@ Keep RxJS where a stream is how you think, and let events cross to the bus in ei
|
|
|
2775
4447
|
|
|
2776
4448
|
**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).
|
|
2777
4449
|
|
|
2778
|
-
<!-- docs-check: skip - needs rxjs, which the project does not depend on -->
|
|
2779
4450
|
```js
|
|
2780
|
-
|
|
4451
|
+
// bus.js
|
|
2781
4452
|
import { createBus } from "nanoeffects";
|
|
2782
|
-
import {
|
|
2783
|
-
import { latest } from "nanoeffects/policies";
|
|
4453
|
+
import { emitterPlugin } from "nanoeffects/actions";
|
|
2784
4454
|
|
|
2785
|
-
const bus = createBus().use(emitterPlugin()); // adds bus.emitter
|
|
4455
|
+
export const bus = createBus().use(emitterPlugin()); // adds bus.emitter
|
|
4456
|
+
```
|
|
4457
|
+
|
|
4458
|
+
```js
|
|
4459
|
+
// filters.js
|
|
4460
|
+
import { createAction } from "nanoeffects/actions";
|
|
2786
4461
|
|
|
2787
|
-
const filtersChanged = createAction("filters/changed"); // a plain action creator
|
|
4462
|
+
export const filtersChanged = createAction("filters/changed"); // a plain action creator
|
|
4463
|
+
```
|
|
4464
|
+
|
|
4465
|
+
<!-- docs-check: skip - needs rxjs, which the project does not depend on -->
|
|
4466
|
+
```js
|
|
4467
|
+
import { combineLatest } from "rxjs";
|
|
4468
|
+
import { latest } from "nanoeffects/policies";
|
|
4469
|
+
import { bus } from "./bus";
|
|
4470
|
+
import { filtersChanged } from "./filters";
|
|
2788
4471
|
|
|
2789
4472
|
// RxJS: combine two streams into one value; nanoeffects does not do that
|
|
2790
4473
|
const subscription = combineLatest([query$, category$]).subscribe(
|
|
@@ -2965,28 +4648,142 @@ Source: docs/observable.md
|
|
|
2965
4648
|
|
|
2966
4649
|
# Turn events into Observables
|
|
2967
4650
|
|
|
2968
|
-
Use this page
|
|
4651
|
+
Use this page when your app already works with Observables or RxJS and you want a bus event as a stream. If you start from scratch, prefer `bus.on` with policies: they cancel work and show up in traces.
|
|
2969
4652
|
|
|
2970
4653
|
`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.
|
|
2971
4654
|
|
|
4655
|
+
## Turn an event into a stream
|
|
4656
|
+
|
|
4657
|
+
Problem: a search box emits `typed` on every key. You want to wait for a pause and skip repeats with operators you already know.
|
|
4658
|
+
|
|
2972
4659
|
<!-- docs-check: skip - needs rxjs, which the project does not depend on -->
|
|
2973
4660
|
```js
|
|
2974
|
-
import {
|
|
2975
|
-
import { debounceTime, distinctUntilChanged, switchMap } from "rxjs/operators";
|
|
4661
|
+
import { createBus } from "nanoeffects";
|
|
2976
4662
|
import { createObserve } from "nanoeffects/observable";
|
|
4663
|
+
import { from } from "rxjs";
|
|
4664
|
+
import { debounceTime, distinctUntilChanged } from "rxjs/operators";
|
|
4665
|
+
|
|
4666
|
+
const bus = createBus(); // the bus: events and handlers
|
|
4667
|
+
const observe = createObserve(bus); // observe(event) gives an Observable per event name
|
|
2977
4668
|
|
|
2978
|
-
const
|
|
4669
|
+
const typed$ = from(observe("typed")); // RxJS wraps it with from()
|
|
2979
4670
|
|
|
2980
|
-
|
|
2981
|
-
.pipe(debounceTime(300), distinctUntilChanged()
|
|
2982
|
-
.subscribe((
|
|
4671
|
+
const subscription = typed$
|
|
4672
|
+
.pipe(debounceTime(300), distinctUntilChanged())
|
|
4673
|
+
.subscribe((query) => bus.emit("search", query)); // back into the bus
|
|
2983
4674
|
```
|
|
2984
4675
|
|
|
2985
4676
|
- `observe(event)` returns an Observable of the payloads of that event. Each `subscribe` is a `bus.on` of its own; `unsubscribe()` removes it.
|
|
2986
4677
|
- `subscribe` takes a function or an object with `next`.
|
|
2987
|
-
-
|
|
2988
|
-
|
|
2989
|
-
|
|
4678
|
+
- Types follow the event map: `observe("typed")` is an `Observable<string>` when `typed` carries a string.
|
|
4679
|
+
|
|
4680
|
+
## Send a stream into the bus
|
|
4681
|
+
|
|
4682
|
+
Problem: `.subscribe((value) => bus.emit(action(value)))` repeats the bus and the action in every subscription. `bus.emitter(action)` from `emitterPlugin` returns that callback, so it is the argument of `subscribe`. It works with any source that takes a `next` function: an RxJS stream, a store callback, a DOM listener.
|
|
4683
|
+
|
|
4684
|
+
```js
|
|
4685
|
+
// bus.js
|
|
4686
|
+
import { createBus } from "nanoeffects";
|
|
4687
|
+
import { emitterPlugin } from "nanoeffects/actions";
|
|
4688
|
+
|
|
4689
|
+
export const bus = createBus().use(emitterPlugin()); // adds bus.emitter
|
|
4690
|
+
```
|
|
4691
|
+
|
|
4692
|
+
```js
|
|
4693
|
+
// search.js
|
|
4694
|
+
import { createAction } from "nanoeffects/actions";
|
|
4695
|
+
|
|
4696
|
+
export const queryChanged = createAction("search/queryChanged"); // a plain action creator
|
|
4697
|
+
```
|
|
4698
|
+
|
|
4699
|
+
<!-- docs-check: skip - needs rxjs, which the project does not depend on -->
|
|
4700
|
+
```js
|
|
4701
|
+
import { fromEvent } from "rxjs";
|
|
4702
|
+
import { debounceTime, distinctUntilChanged, map } from "rxjs/operators";
|
|
4703
|
+
import { bus } from "./bus";
|
|
4704
|
+
import { queryChanged } from "./search";
|
|
4705
|
+
|
|
4706
|
+
const input = document.querySelector("#search"); // the search box
|
|
4707
|
+
|
|
4708
|
+
const subscription = fromEvent(input, "input")
|
|
4709
|
+
.pipe(
|
|
4710
|
+
map((event) => event.target.value),
|
|
4711
|
+
debounceTime(300),
|
|
4712
|
+
distinctUntilChanged(),
|
|
4713
|
+
)
|
|
4714
|
+
.subscribe(bus.emitter(queryChanged)); // calls bus.emit(queryChanged(value))
|
|
4715
|
+
|
|
4716
|
+
bus.on(queryChanged, (query) => console.log("search for", query));
|
|
4717
|
+
```
|
|
4718
|
+
|
|
4719
|
+
The Observable can come from anywhere, not only from `createObserve`. `subscription.unsubscribe()` stops it; the bus does not know about it. Pass the callback to `subscribe` as it is, and do not call `bus.emitter(...)` inside the operators on every value.
|
|
4720
|
+
|
|
4721
|
+
## Do the work in a handler, not in `switchMap`
|
|
4722
|
+
|
|
4723
|
+
An Observable is good for shaping values. A request is better in a handler with a policy, because the handler has a `signal` and the trace shows it. Split it: RxJS ends at the value, the bus starts the work.
|
|
4724
|
+
|
|
4725
|
+
<!-- docs-check: skip - needs rxjs, which the project does not depend on -->
|
|
4726
|
+
```js
|
|
4727
|
+
import { latest } from "nanoeffects/policies";
|
|
4728
|
+
|
|
4729
|
+
// RxJS: shape the values (debounce, skip repeats), then hand them to the bus
|
|
4730
|
+
from(observe("typed"))
|
|
4731
|
+
.pipe(debounceTime(300), distinctUntilChanged())
|
|
4732
|
+
.subscribe((query) => bus.emit("search", query));
|
|
4733
|
+
|
|
4734
|
+
// nanoeffects: one cancellable request; a newer "search" aborts the old one
|
|
4735
|
+
bus.on(
|
|
4736
|
+
"search",
|
|
4737
|
+
latest(async (query, { signal }) => {
|
|
4738
|
+
const items = await api.search(query, { signal });
|
|
4739
|
+
bus.emit("results", items);
|
|
4740
|
+
}),
|
|
4741
|
+
);
|
|
4742
|
+
```
|
|
4743
|
+
|
|
4744
|
+
`switchMap` drops the old inner stream. `latest` aborts the old task through `signal`, so a `fetch` that got the signal really stops.
|
|
4745
|
+
|
|
4746
|
+
## Convert back to a stream or a promise
|
|
4747
|
+
|
|
4748
|
+
| You need | Write |
|
|
4749
|
+
| -------- | ----- |
|
|
4750
|
+
| A stream of events, with operators | `from(observe("event"))` |
|
|
4751
|
+
| The next event as a promise | `firstValueFrom(from(observe("event")))`; inside a task `take(ctx, "event")` does the same and respects abort |
|
|
4752
|
+
| The events of a bus as an RxJS `Observable` class (for `instanceof` or `pipe` as a method) | `new Observable((subscriber) => observe("event").subscribe(subscriber))` |
|
|
4753
|
+
| A task's abort as a stream | `fromEvent(ctx.signal, "abort")`, for `takeUntil` |
|
|
4754
|
+
| The latest value to new subscribers | `from(observe("event")).pipe(shareReplay(1))`; the Observable replays nothing by itself |
|
|
4755
|
+
|
|
4756
|
+
Events that fired before `subscribe` are lost: the Observable is hot, like `bus.on`. Add `startWith(initial)` or `shareReplay` when a late subscriber needs a value.
|
|
4757
|
+
|
|
4758
|
+
## End the subscription
|
|
4759
|
+
|
|
4760
|
+
A subscription from `observe` is not tied to a task. Nothing ends it for you. Pick one:
|
|
4761
|
+
|
|
4762
|
+
<!-- docs-check: skip - needs rxjs, which the project does not depend on -->
|
|
4763
|
+
```js
|
|
4764
|
+
import { fromEvent, from } from "rxjs";
|
|
4765
|
+
import { takeUntil } from "rxjs/operators";
|
|
4766
|
+
import { onAbort } from "nanoeffects/effects";
|
|
4767
|
+
|
|
4768
|
+
bus.on("screen-opened", (_, ctx) => {
|
|
4769
|
+
// takeUntil: stop the stream when the task aborts
|
|
4770
|
+
from(observe("typed"))
|
|
4771
|
+
.pipe(takeUntil(fromEvent(ctx.signal, "abort")))
|
|
4772
|
+
.subscribe((query) => bus.emit("search", query));
|
|
4773
|
+
|
|
4774
|
+
// or: onAbort calls unsubscribe for you
|
|
4775
|
+
const subscription = observe("scrolled").subscribe(updatePosition);
|
|
4776
|
+
onAbort(ctx, subscription.unsubscribe);
|
|
4777
|
+
});
|
|
4778
|
+
```
|
|
4779
|
+
|
|
4780
|
+
Outside a task, call `subscription.unsubscribe()` yourself when the screen goes away, or use the signal of a module.
|
|
4781
|
+
|
|
4782
|
+
## Know the limits
|
|
4783
|
+
|
|
4784
|
+
- The stream never completes and never fails: events are the only thing that arrives. An error thrown by your subscriber is yours; the bus handles it only if `onError` is set ([Errors](errors.md)).
|
|
4785
|
+
- `observe` reads the bus when you call `subscribe`, not when you create it. Create it once, call it as often as needed.
|
|
4786
|
+
- Operators that keep state (`scan`, `buffer`, `window`) live in RxJS. The bus keeps no values. For the full split of work see [Use nanoeffects next to RxJS](migrating-from-rxjs.md).
|
|
2990
4787
|
|
|
2991
4788
|
---
|
|
2992
4789
|
Source: docs/hmr.md
|
|
@@ -3014,7 +4811,7 @@ A `bus.on` outside a module returns the function that unsubscribes: hand it to t
|
|
|
3014
4811
|
|
|
3015
4812
|
## Report leaks with `devPlugin`
|
|
3016
4813
|
|
|
3017
|
-
`bus.use(devPlugin(
|
|
4814
|
+
`bus.use(devPlugin())` reports what is easy to miss while developing:
|
|
3018
4815
|
|
|
3019
4816
|
```js
|
|
3020
4817
|
import { createBus } from "nanoeffects";
|
|
@@ -3022,13 +4819,14 @@ import { modulePlugin } from "nanoeffects/actions";
|
|
|
3022
4819
|
import { devPlugin } from "nanoeffects/dev";
|
|
3023
4820
|
|
|
3024
4821
|
const bus = createBus();
|
|
3025
|
-
|
|
4822
|
+
bus.use(modulePlugin([userModule]));
|
|
3026
4823
|
// devPlugin — warn on piled-up listeners; log module mount/replace/unmount
|
|
3027
|
-
|
|
4824
|
+
// (it finds the app of modulePlugin on the bus, in either order of the two)
|
|
4825
|
+
bus.use(devPlugin());
|
|
3028
4826
|
```
|
|
3029
4827
|
|
|
3030
4828
|
- **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.
|
|
3031
|
-
- **Modules.**
|
|
4829
|
+
- **Modules.** For the app of `modulePlugin` (or the `app` option) 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.
|
|
3032
4830
|
- **Options.** `leaks: false` and `modules: false` switch a diagnostic off; `maxListeners` sets the limit; `log` receives the messages instead of the console.
|
|
3033
4831
|
- **Disconnect.** `bus.use(devPlugin(...))` returns the function that stops the diagnostics and puts `bus.on` and `app.mount` back.
|
|
3034
4832
|
|
|
@@ -3036,6 +4834,6 @@ It changes no behavior: it wraps `bus.on`, `app.mount`, and `app.unmount` and ad
|
|
|
3036
4834
|
|
|
3037
4835
|
### Keep the import in production
|
|
3038
4836
|
|
|
3039
|
-
`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(
|
|
4837
|
+
`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())` can stay without a guard: it touches nothing and costs a few bytes.
|
|
3040
4838
|
|
|
3041
4839
|
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.
|