nanoeffects 0.8.0 → 0.10.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/actions.d.ts +38 -22
- package/dist/actions.js +1 -1
- package/dist/bus-CkTHUAvq.js +1 -0
- package/dist/context-COgyNlrC.js +1 -0
- package/dist/effects.js +1 -1
- package/dist/index.js +1 -1
- package/dist/lifecycle-key-8iX8RUVD.js +1 -0
- package/dist/notrace/actions.d.ts +38 -22
- package/dist/notrace/actions.js +1 -1
- package/dist/notrace/bus-BmCTo8BG.js +1 -0
- 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.js +1 -1
- package/dist/notrace/index.js +1 -1
- package/dist/notrace/lifecycle-key-8iX8RUVD.js +1 -0
- package/dist/notrace/policies.d.ts +30 -2
- package/dist/notrace/policies.js +1 -1
- package/dist/oop.js +1 -1
- package/dist/policies.d.ts +2 -1
- package/dist/policies.js +1 -1
- package/dist/react.d.ts +33 -1
- package/dist/react.js +1 -1
- package/dist/scope.js +1 -1
- package/dist/signal.d.ts +51 -0
- package/dist/signal.js +1 -0
- package/dist/with-policy-CqUS-VWW.d.ts +30 -0
- package/dist/with-policy-DBV9dh2F.js +1 -0
- package/docs/README.md +6 -0
- package/docs/actions.md +80 -4
- package/docs/cancel-stale-requests.md +111 -0
- package/docs/debounce-and-abort.md +106 -0
- package/docs/guide.md +1 -1
- package/docs/migrating-from-effector.md +2 -2
- package/docs/migrating-from-mitt.md +97 -0
- package/docs/motivation.md +41 -0
- package/docs/observable.md +124 -10
- package/docs/package.md +4 -2
- package/docs/plugins.md +20 -0
- 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 +44 -0
- package/llms-full.txt +944 -24
- package/llms.txt +9 -3
- package/package.json +25 -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
|
---
|
|
@@ -234,7 +234,7 @@ const searchLatest = latest(async (query, { signal }) => {
|
|
|
234
234
|
bus.on("search", debounce(300, searchLatest));
|
|
235
235
|
```
|
|
236
236
|
|
|
237
|
-
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).
|
|
238
238
|
|
|
239
239
|
## 5. Add retry and timeout
|
|
240
240
|
|
|
@@ -500,6 +500,51 @@ More in [Errors](errors.md).
|
|
|
500
500
|
| Guarantees | [Semantics](semantics.md) |
|
|
501
501
|
| Which import | [Package entries](package.md) |
|
|
502
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
|
+
|
|
503
548
|
---
|
|
504
549
|
Source: docs/package.md
|
|
505
550
|
|
|
@@ -514,6 +559,7 @@ Imports are split so you only load what you use. `createBus` stays small; the re
|
|
|
514
559
|
| `nanoeffects/effects` | `retry`, `timeout`, `poll`, `delay`, `take`, `all`, `allSettled`, `race`, `effectsPlugin` |
|
|
515
560
|
| `nanoeffects/actions` | `createAction`, `createCommand`, `createJob`, `createModule`, `modulePlugin`, `commandsPlugin` |
|
|
516
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` |
|
|
517
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()` |
|
|
518
564
|
| `nanoeffects/scope` | `scopePlugin`, `effect`: fields for one command, action, job, module or `emit` (the types `Scope`, `Effect`) |
|
|
519
565
|
| `nanoeffects/redux` | `createEffectsMiddleware` |
|
|
@@ -526,12 +572,13 @@ The `...Plugin` names above are used with `bus.use`. [Plugins](plugins.md) lists
|
|
|
526
572
|
|
|
527
573
|
## Give the docs to an AI assistant
|
|
528
574
|
|
|
529
|
-
The package ships
|
|
575
|
+
The package ships plain-text files for assistants that write code with it:
|
|
530
576
|
|
|
531
577
|
- `llms.txt`: a short index. It states the rules that generated code often gets wrong and links to every page of these docs.
|
|
532
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/`.
|
|
533
580
|
|
|
534
|
-
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.
|
|
535
582
|
|
|
536
583
|
---
|
|
537
584
|
Source: docs/plugins.md
|
|
@@ -630,6 +677,7 @@ Add one of these when you meet its need. None of them is required.
|
|
|
630
677
|
| Plugin | What it gives you | Import | `bus.use` returns |
|
|
631
678
|
| ------ | ----------------- | ------ | ----------------- |
|
|
632
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` |
|
|
633
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 |
|
|
634
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` |
|
|
635
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` |
|
|
@@ -656,6 +704,25 @@ const fakeApi = { getUser: async (id) => ({ id, name: "test" }) };
|
|
|
656
704
|
bus.emit(getUser("1"), { api: fakeApi }); // a fake api for this call only
|
|
657
705
|
```
|
|
658
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
|
+
|
|
659
726
|
### Listen to a group of events: `matchPlugin`
|
|
660
727
|
|
|
661
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).
|
|
@@ -935,11 +1002,401 @@ A dropped call resolves with `undefined`. `queue(fn, { limit: 1, overflow: "drop
|
|
|
935
1002
|
|
|
936
1003
|
Every call returns a promise with the result of its own run. An error rejects only that call; the queue continues.
|
|
937
1004
|
|
|
938
|
-
**Abort:**
|
|
1005
|
+
**Abort:**
|
|
1006
|
+
|
|
1007
|
+
- A waiting call whose task aborts is skipped and resolves with `undefined`.
|
|
1008
|
+
- When a running call aborts, its slot is freed at once and its result is dropped.
|
|
1009
|
+
- Pass `signal` into what `fn` does. A `fn` that ignores it can overlap with the next run.
|
|
1010
|
+
|
|
1011
|
+
## Use a policy without a bus
|
|
1012
|
+
|
|
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.
|
|
1014
|
+
|
|
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.
|
|
1016
|
+
|
|
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.
|
|
1018
|
+
|
|
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` |
|
|
939
1398
|
|
|
940
|
-
|
|
941
|
-
- When a running call aborts, its slot is freed at once and its result is dropped.
|
|
942
|
-
- Pass `signal` into what `fn` does. A `fn` that ignores it can overlap with the next run.
|
|
1399
|
+
Related: [Policies](policies.md), [Build a sign-up form in React](react.md).
|
|
943
1400
|
|
|
944
1401
|
---
|
|
945
1402
|
Source: docs/effects.md
|
|
@@ -1394,15 +1851,91 @@ The registry lives in `nanoeffects/actions`, so a bus without one carries none o
|
|
|
1394
1851
|
|
|
1395
1852
|
### Jobs
|
|
1396
1853
|
|
|
1397
|
-
`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.
|
|
1398
1855
|
|
|
1856
|
+
- When the handler starts, emits `loadUser.pending(payload)`.
|
|
1399
1857
|
- On success, emits `loadUser.success(result)`.
|
|
1400
1858
|
- On failure, emits `loadUser.fail(error)`.
|
|
1401
|
-
-
|
|
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`.
|
|
1402
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.
|
|
1403
|
-
- Outcome actions are emitted as the task that ran the job (also for a handler bound with `commands.handle`).
|
|
1404
|
-
- 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, at the moment of the abort.
|
|
1863
|
+
- A missing handler is not an outcome: `emit` throws `No handler for command "..."` and nothing is sent.
|
|
1405
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 at the moment of the abort, with the abort reason. A handler that ignores the signal and finishes later sends nothing more, so the flag is already `false`.
|
|
1930
|
+
|
|
1931
|
+
**With a policy.** A policy such as `latest`, `leading`, `debounce`, `throttle` or `queue` around the handler tells the job what it does with each call:
|
|
1932
|
+
|
|
1933
|
+
- `pending` is sent when the handler really starts: at once for `leading`, after the wait for `debounce`, when its turn comes for `queue`.
|
|
1934
|
+
- A call that the policy drops before it starts sends nothing, not even `pending`. A second click that `leading` ignores does not clear the flag of the first.
|
|
1935
|
+
- A run that `latest` replaces sends `cancel` before the next call sends `pending`.
|
|
1936
|
+
- Every `pending` is followed by exactly one of `success`, `fail` and `cancel`, and nothing is sent without `pending`.
|
|
1937
|
+
|
|
1938
|
+
A flag is exact when calls do not overlap, or when `latest` replaces them. If several runs can go at once (`queue` with `concurrency` above 1, or a job without a policy called many times), keep a counter: add one on `pending` and take one off on `success`, `fail` and `cancel`. A policy that you write yourself, or that you hide in your own function, does not report to the job; `pending` is then sent at the call, as for a job with no policy.
|
|
1406
1939
|
|
|
1407
1940
|
## Mount domains with modules
|
|
1408
1941
|
|
|
@@ -2174,6 +2707,134 @@ The stores above call back only when the value changed, so the emitter does not
|
|
|
2174
2707
|
|
|
2175
2708
|
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.
|
|
2176
2709
|
|
|
2710
|
+
---
|
|
2711
|
+
Source: docs/query-libraries.md
|
|
2712
|
+
|
|
2713
|
+
# Use nanoeffects with React Query and SWR
|
|
2714
|
+
|
|
2715
|
+
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.
|
|
2716
|
+
|
|
2717
|
+
You need a React project, `nanoeffects`, and one of the two libraries. `api` below is your own client: replace it with your requests.
|
|
2718
|
+
|
|
2719
|
+
## Decide who owns what
|
|
2720
|
+
|
|
2721
|
+
The libraries and nanoeffects do different jobs, so each keeps its own.
|
|
2722
|
+
|
|
2723
|
+
| React Query or SWR | nanoeffects |
|
|
2724
|
+
| ------------------ | ----------- |
|
|
2725
|
+
| `isLoading`, `data`, `error` | `retry`, `timeout`, `latest` and other policies |
|
|
2726
|
+
| the cache, one request for the same key | the abort that reaches the request |
|
|
2727
|
+
| refetch on focus, on reconnect, on interval | the handler: the same code for a test, a worker and the UI |
|
|
2728
|
+
|
|
2729
|
+
`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.
|
|
2730
|
+
|
|
2731
|
+
## Make the command
|
|
2732
|
+
|
|
2733
|
+
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.
|
|
2734
|
+
|
|
2735
|
+
```js
|
|
2736
|
+
// bus.js
|
|
2737
|
+
import { createBus } from "nanoeffects";
|
|
2738
|
+
import { signalPlugin } from "nanoeffects/signal";
|
|
2739
|
+
|
|
2740
|
+
// createBus — the bus the app shares
|
|
2741
|
+
// signalPlugin — lets emit take { signal }, the signal of the library
|
|
2742
|
+
export const bus = createBus().use(signalPlugin());
|
|
2743
|
+
```
|
|
2744
|
+
|
|
2745
|
+
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.
|
|
2746
|
+
|
|
2747
|
+
```js
|
|
2748
|
+
// user.js
|
|
2749
|
+
import { createCommand } from "nanoeffects/actions";
|
|
2750
|
+
import { retry, timeout } from "nanoeffects/effects";
|
|
2751
|
+
|
|
2752
|
+
// createCommand — a command with a handler: emit returns what the handler returns
|
|
2753
|
+
export const getUser = createCommand("user/get", async (id, ctx) => {
|
|
2754
|
+
// one try: the request, with the signal of timeout so it can stop it
|
|
2755
|
+
const load = ({ signal }) => api.getUser(id, { signal });
|
|
2756
|
+
// timeout — fail the try after 5 s
|
|
2757
|
+
const attempt = () => timeout(ctx, 5000, load);
|
|
2758
|
+
// retry — up to 3 tries, 500 ms apart
|
|
2759
|
+
return retry(ctx, attempt, { times: 3, delay: 500 });
|
|
2760
|
+
});
|
|
2761
|
+
```
|
|
2762
|
+
|
|
2763
|
+
## Call it from React Query
|
|
2764
|
+
|
|
2765
|
+
<!-- docs-check: continue -->
|
|
2766
|
+
|
|
2767
|
+
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.
|
|
2768
|
+
|
|
2769
|
+
```jsx
|
|
2770
|
+
// Profile.jsx
|
|
2771
|
+
import { useQuery } from "@tanstack/react-query";
|
|
2772
|
+
import { bus } from "./bus";
|
|
2773
|
+
import { getUser } from "./user";
|
|
2774
|
+
|
|
2775
|
+
export function Profile({ id }) {
|
|
2776
|
+
const { isLoading, data, error } = useQuery({
|
|
2777
|
+
queryKey: ["user", id],
|
|
2778
|
+
// emit — runs the command; { signal } ties its task to the signal of React Query
|
|
2779
|
+
queryFn: ({ signal }) => bus.emit(getUser(id), { signal }),
|
|
2780
|
+
});
|
|
2781
|
+
|
|
2782
|
+
if (isLoading) return <p>Loading…</p>;
|
|
2783
|
+
if (error) return <p>{error.message}</p>;
|
|
2784
|
+
return <p>{data.name}</p>;
|
|
2785
|
+
}
|
|
2786
|
+
```
|
|
2787
|
+
|
|
2788
|
+
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.
|
|
2789
|
+
|
|
2790
|
+
## Call it from SWR
|
|
2791
|
+
|
|
2792
|
+
<!-- docs-check: continue -->
|
|
2793
|
+
|
|
2794
|
+
SWR does not pass a signal to the fetcher, so there is nothing to give to `emit`. The command still brings `retry` and `timeout`.
|
|
2795
|
+
|
|
2796
|
+
```jsx
|
|
2797
|
+
// Profile.jsx
|
|
2798
|
+
import useSWR from "swr";
|
|
2799
|
+
import { bus } from "./bus";
|
|
2800
|
+
import { getUser } from "./user";
|
|
2801
|
+
|
|
2802
|
+
// the fetcher gets the key (here the id) and returns a promise
|
|
2803
|
+
const fetcher = (id) => bus.emit(getUser(id));
|
|
2804
|
+
|
|
2805
|
+
export function Profile({ id }) {
|
|
2806
|
+
const { isLoading, data, error } = useSWR(id, fetcher);
|
|
2807
|
+
|
|
2808
|
+
if (isLoading) return <p>Loading…</p>;
|
|
2809
|
+
if (error) return <p>{error.message}</p>;
|
|
2810
|
+
return <p>{data.name}</p>;
|
|
2811
|
+
}
|
|
2812
|
+
```
|
|
2813
|
+
|
|
2814
|
+
## Let the bus know the result
|
|
2815
|
+
|
|
2816
|
+
<!-- docs-check: continue -->
|
|
2817
|
+
|
|
2818
|
+
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:
|
|
2819
|
+
|
|
2820
|
+
```js
|
|
2821
|
+
// user-events.js
|
|
2822
|
+
import { createAction } from "nanoeffects/actions";
|
|
2823
|
+
|
|
2824
|
+
// createAction — an event with a payload
|
|
2825
|
+
export const userLoaded = createAction("user/loaded");
|
|
2826
|
+
```
|
|
2827
|
+
|
|
2828
|
+
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).
|
|
2829
|
+
|
|
2830
|
+
## Know the limits
|
|
2831
|
+
|
|
2832
|
+
- **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.
|
|
2833
|
+
- **Only the call forms take it.** `bus.emit(getUser(id), { signal })` works; `bus.emit("user/get", id, { signal })` does not.
|
|
2834
|
+
- **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.
|
|
2835
|
+
- **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.
|
|
2836
|
+
- **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.
|
|
2837
|
+
|
|
2177
2838
|
---
|
|
2178
2839
|
Source: docs/react.md
|
|
2179
2840
|
|
|
@@ -2341,11 +3002,55 @@ const checkName = latest(async (name, ctx) => {
|
|
|
2341
3002
|
|
|
2342
3003
|
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.
|
|
2343
3004
|
|
|
3005
|
+
## Keep a debounced handler between renders
|
|
3006
|
+
|
|
3007
|
+
**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.
|
|
3008
|
+
|
|
3009
|
+
**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.
|
|
3010
|
+
|
|
3011
|
+
`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.
|
|
3012
|
+
|
|
3013
|
+
```jsx
|
|
3014
|
+
import { useState } from "react";
|
|
3015
|
+
import { debounce } from "nanoeffects/policies";
|
|
3016
|
+
import { usePolicy } from "nanoeffects/react";
|
|
3017
|
+
|
|
3018
|
+
export const Search = ({ wait }) => {
|
|
3019
|
+
const [items, setItems] = useState([]);
|
|
3020
|
+
|
|
3021
|
+
// usePolicy — a stable function; the first argument is the latest render's callback
|
|
3022
|
+
const onChange = usePolicy(
|
|
3023
|
+
async (query, { signal }) => {
|
|
3024
|
+
const found = await api.search(query, { signal });
|
|
3025
|
+
setItems(found);
|
|
3026
|
+
},
|
|
3027
|
+
// the second argument builds the policy once; task runs the callback above
|
|
3028
|
+
(task) => debounce(wait, task),
|
|
3029
|
+
// deps — list what the second argument reads; a new wait makes a new policy
|
|
3030
|
+
[wait],
|
|
3031
|
+
);
|
|
3032
|
+
|
|
3033
|
+
return (
|
|
3034
|
+
<div>
|
|
3035
|
+
<input onChange={(event) => onChange(event.target.value)} />
|
|
3036
|
+
<p>{items.length} found</p>
|
|
3037
|
+
</div>
|
|
3038
|
+
);
|
|
3039
|
+
};
|
|
3040
|
+
```
|
|
3041
|
+
|
|
3042
|
+
- `onChange` keeps its identity while `wait` stays the same, so it is safe in `useEffect` and in props.
|
|
3043
|
+
- A pending call is cancelled on unmount and when `deps` change.
|
|
3044
|
+
- Do not write `usePolicy(debounce(300, callback))`. The policy would be made on every render or keep a stale callback.
|
|
3045
|
+
- Without a React component, use [`withPolicy`](policies.md#use-a-policy-without-a-bus).
|
|
3046
|
+
|
|
2344
3047
|
## Where to go next
|
|
2345
3048
|
|
|
2346
3049
|
- The policies behind this form: [Policies](policies.md).
|
|
3050
|
+
- A policy as a plain function without a bus: [Use a policy without a bus](policies.md#use-a-policy-without-a-bus).
|
|
2347
3051
|
- `retry`, `timeout` and the other effects: [Effects](effects.md).
|
|
2348
3052
|
- A state library instead of `useState`: [Stores](stores.md).
|
|
3053
|
+
- React Query or SWR instead of your own state: [Use nanoeffects with React Query and SWR](query-libraries.md).
|
|
2349
3054
|
|
|
2350
3055
|
---
|
|
2351
3056
|
Source: docs/context.md
|
|
@@ -3248,7 +3953,7 @@ nanoeffects has no store. `createStore` can be replaced by any store you like, f
|
|
|
3248
3953
|
| event call `loaded(user)` | `bus.emit("loaded", user)` |
|
|
3249
3954
|
| `createEffect(handler)` | a handler: `bus.on(event, handler)` |
|
|
3250
3955
|
| effect with `done` and `fail` | [`createJob`](actions.md): emits the success and fail actions |
|
|
3251
|
-
| `effect.pending` |
|
|
3956
|
+
| `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)) |
|
|
3252
3957
|
| `sample({ clock, fn, target })` | `bus.on(clock, handler)` and `ctx.emit(target, …)` |
|
|
3253
3958
|
| `sample({ source: $store, clock })` | read the store from a [context field](context.md) in the handler |
|
|
3254
3959
|
| `sample({ filter })` | an `if` in the handler |
|
|
@@ -3546,13 +4251,114 @@ bus.on(
|
|
|
3546
4251
|
## What is different
|
|
3547
4252
|
|
|
3548
4253
|
- **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)).
|
|
3549
|
-
-
|
|
4254
|
+
- **`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)). Policies report to the job, so the flag also follows `latest`, `leading`, `debounce`, `throttle` and `queue`; that section says when to count instead. A running task is also visible through [Tracing](tracing.md).
|
|
3550
4255
|
- **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.
|
|
3551
4256
|
- **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.
|
|
3552
4257
|
- **No graph.** `sample` and `forward` build a static graph. A bus subscribes handlers at runtime and the call order is the order of `on`.
|
|
3553
4258
|
- **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).
|
|
3554
4259
|
- **Scopes are buses.** `fork()` becomes a `createBus()` per scope (a test, a request on the server).
|
|
3555
4260
|
|
|
4261
|
+
---
|
|
4262
|
+
Source: docs/migrating-from-mitt.md
|
|
4263
|
+
|
|
4264
|
+
# Coming from mitt, nanoevents or EventEmitter
|
|
4265
|
+
|
|
4266
|
+
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.
|
|
4267
|
+
|
|
4268
|
+
## When to switch
|
|
4269
|
+
|
|
4270
|
+
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:
|
|
4271
|
+
|
|
4272
|
+
- an older request finishes after a newer one and overwrites its result;
|
|
4273
|
+
- you keep `AbortController` objects by hand;
|
|
4274
|
+
- you write debounce, throttle or retry around handlers yourself;
|
|
4275
|
+
- you cannot tell which event started which request.
|
|
4276
|
+
|
|
4277
|
+
If handlers stay synchronous, keep your emitter. nanoeffects costs about 1.4 kB gzip (1.3 kB brotli), close to the small emitters.
|
|
4278
|
+
|
|
4279
|
+
## Find the equivalent
|
|
4280
|
+
|
|
4281
|
+
| mitt / EventEmitter | nanoeffects |
|
|
4282
|
+
| ------------------- | ----------- |
|
|
4283
|
+
| `emitter.on("a", handler)` | `bus.on("a", handler)` |
|
|
4284
|
+
| `emitter.off("a", handler)` | call the function that `bus.on` returned, or `bus.off("a")` for all handlers of `a` |
|
|
4285
|
+
| `emitter.emit("a", payload)` | `bus.emit("a", payload)` |
|
|
4286
|
+
| `emitter.once("a", handler)` | `bus.once("a", handler)` |
|
|
4287
|
+
| `on("*", (type, payload) => …)` | `bus.on("*", (payload, ctx) => …)`: the event name is `ctx.type` (see the [Guide](guide.md)) |
|
|
4288
|
+
| `emitter.all.clear()` | `bus.off()` |
|
|
4289
|
+
|
|
4290
|
+
Two differences to know:
|
|
4291
|
+
|
|
4292
|
+
- `bus.on` returns the unsubscribe function. You do not need to keep the handler to remove it.
|
|
4293
|
+
- The handler gets a second argument, the task context: `(payload, ctx)`. You can ignore it until you need `ctx.signal` or `ctx.emit`.
|
|
4294
|
+
|
|
4295
|
+
## Move a handler
|
|
4296
|
+
|
|
4297
|
+
Before, with `mitt`:
|
|
4298
|
+
|
|
4299
|
+
<!-- docs-check: skip - needs mitt, which the project does not depend on -->
|
|
4300
|
+
```js
|
|
4301
|
+
import mitt from "mitt";
|
|
4302
|
+
|
|
4303
|
+
const emitter = mitt();
|
|
4304
|
+
|
|
4305
|
+
emitter.on("search", async (query) => {
|
|
4306
|
+
const items = await api.search(query);
|
|
4307
|
+
emitter.emit("results", items);
|
|
4308
|
+
});
|
|
4309
|
+
|
|
4310
|
+
emitter.emit("search", "abc");
|
|
4311
|
+
```
|
|
4312
|
+
|
|
4313
|
+
After, with nanoeffects. The first step changes only the import and the constructor:
|
|
4314
|
+
|
|
4315
|
+
```js
|
|
4316
|
+
import { createBus } from "nanoeffects";
|
|
4317
|
+
|
|
4318
|
+
const bus = createBus(); // event bus; each handler call is a task
|
|
4319
|
+
|
|
4320
|
+
bus.on("search", async (query) => {
|
|
4321
|
+
const items = await api.search(query);
|
|
4322
|
+
bus.emit("results", items);
|
|
4323
|
+
});
|
|
4324
|
+
|
|
4325
|
+
bus.emit("search", "abc");
|
|
4326
|
+
```
|
|
4327
|
+
|
|
4328
|
+
The second step fixes the race. Wrap the handler in `latest` and pass the signal to the request:
|
|
4329
|
+
|
|
4330
|
+
```js
|
|
4331
|
+
import { createBus } from "nanoeffects";
|
|
4332
|
+
import { latest } from "nanoeffects/policies";
|
|
4333
|
+
|
|
4334
|
+
const bus = createBus(); // event bus; each handler call is a task
|
|
4335
|
+
|
|
4336
|
+
// latest: a newer "search" aborts the older run
|
|
4337
|
+
bus.on(
|
|
4338
|
+
"search",
|
|
4339
|
+
latest(async (query, { signal }) => {
|
|
4340
|
+
const items = await api.search(query, { signal }); // signal cancels the request
|
|
4341
|
+
bus.emit("results", items);
|
|
4342
|
+
}),
|
|
4343
|
+
);
|
|
4344
|
+
|
|
4345
|
+
bus.emit("search", "abc");
|
|
4346
|
+
```
|
|
4347
|
+
|
|
4348
|
+
More cases (pause, retry, timeout): [Cancel stale requests](cancel-stale-requests.md).
|
|
4349
|
+
|
|
4350
|
+
## Check the differences
|
|
4351
|
+
|
|
4352
|
+
| Topic | Emitter | nanoeffects |
|
|
4353
|
+
| ----- | ------- | ----------- |
|
|
4354
|
+
| Handler order | order of subscription | order of subscription |
|
|
4355
|
+
| 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)) |
|
|
4356
|
+
| Async handler rejects | usually an unhandled rejection | not handled without `onError`; set `onError` to catch it |
|
|
4357
|
+
| Event without a listener | ignored | ignored; set `onUnhandled` to be told |
|
|
4358
|
+
| Types | event map in a type parameter | event map in a type parameter ([TypeScript](typescript.md)) |
|
|
4359
|
+
|
|
4360
|
+
The exact rules of `emit`, abort and ordering: [Semantics](semantics.md).
|
|
4361
|
+
|
|
3556
4362
|
---
|
|
3557
4363
|
Source: docs/migrating-from-rxjs.md
|
|
3558
4364
|
|
|
@@ -3849,28 +4655,142 @@ Source: docs/observable.md
|
|
|
3849
4655
|
|
|
3850
4656
|
# Turn events into Observables
|
|
3851
4657
|
|
|
3852
|
-
Use this page
|
|
4658
|
+
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.
|
|
3853
4659
|
|
|
3854
4660
|
`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.
|
|
3855
4661
|
|
|
4662
|
+
## Turn an event into a stream
|
|
4663
|
+
|
|
4664
|
+
Problem: a search box emits `typed` on every key. You want to wait for a pause and skip repeats with operators you already know.
|
|
4665
|
+
|
|
3856
4666
|
<!-- docs-check: skip - needs rxjs, which the project does not depend on -->
|
|
3857
4667
|
```js
|
|
3858
|
-
import {
|
|
3859
|
-
import { debounceTime, distinctUntilChanged, switchMap } from "rxjs/operators";
|
|
4668
|
+
import { createBus } from "nanoeffects";
|
|
3860
4669
|
import { createObserve } from "nanoeffects/observable";
|
|
4670
|
+
import { from } from "rxjs";
|
|
4671
|
+
import { debounceTime, distinctUntilChanged } from "rxjs/operators";
|
|
3861
4672
|
|
|
3862
|
-
const
|
|
4673
|
+
const bus = createBus(); // the bus: events and handlers
|
|
4674
|
+
const observe = createObserve(bus); // observe(event) gives an Observable per event name
|
|
3863
4675
|
|
|
3864
|
-
from(observe("typed"))
|
|
3865
|
-
|
|
3866
|
-
|
|
4676
|
+
const typed$ = from(observe("typed")); // RxJS wraps it with from()
|
|
4677
|
+
|
|
4678
|
+
const subscription = typed$
|
|
4679
|
+
.pipe(debounceTime(300), distinctUntilChanged())
|
|
4680
|
+
.subscribe((query) => bus.emit("search", query)); // back into the bus
|
|
3867
4681
|
```
|
|
3868
4682
|
|
|
3869
4683
|
- `observe(event)` returns an Observable of the payloads of that event. Each `subscribe` is a `bus.on` of its own; `unsubscribe()` removes it.
|
|
3870
4684
|
- `subscribe` takes a function or an object with `next`.
|
|
3871
|
-
-
|
|
3872
|
-
|
|
3873
|
-
|
|
4685
|
+
- Types follow the event map: `observe("typed")` is an `Observable<string>` when `typed` carries a string.
|
|
4686
|
+
|
|
4687
|
+
## Send a stream into the bus
|
|
4688
|
+
|
|
4689
|
+
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.
|
|
4690
|
+
|
|
4691
|
+
```js
|
|
4692
|
+
// bus.js
|
|
4693
|
+
import { createBus } from "nanoeffects";
|
|
4694
|
+
import { emitterPlugin } from "nanoeffects/actions";
|
|
4695
|
+
|
|
4696
|
+
export const bus = createBus().use(emitterPlugin()); // adds bus.emitter
|
|
4697
|
+
```
|
|
4698
|
+
|
|
4699
|
+
```js
|
|
4700
|
+
// search.js
|
|
4701
|
+
import { createAction } from "nanoeffects/actions";
|
|
4702
|
+
|
|
4703
|
+
export const queryChanged = createAction("search/queryChanged"); // a plain action creator
|
|
4704
|
+
```
|
|
4705
|
+
|
|
4706
|
+
<!-- docs-check: skip - needs rxjs, which the project does not depend on -->
|
|
4707
|
+
```js
|
|
4708
|
+
import { fromEvent } from "rxjs";
|
|
4709
|
+
import { debounceTime, distinctUntilChanged, map } from "rxjs/operators";
|
|
4710
|
+
import { bus } from "./bus";
|
|
4711
|
+
import { queryChanged } from "./search";
|
|
4712
|
+
|
|
4713
|
+
const input = document.querySelector("#search"); // the search box
|
|
4714
|
+
|
|
4715
|
+
const subscription = fromEvent(input, "input")
|
|
4716
|
+
.pipe(
|
|
4717
|
+
map((event) => event.target.value),
|
|
4718
|
+
debounceTime(300),
|
|
4719
|
+
distinctUntilChanged(),
|
|
4720
|
+
)
|
|
4721
|
+
.subscribe(bus.emitter(queryChanged)); // calls bus.emit(queryChanged(value))
|
|
4722
|
+
|
|
4723
|
+
bus.on(queryChanged, (query) => console.log("search for", query));
|
|
4724
|
+
```
|
|
4725
|
+
|
|
4726
|
+
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.
|
|
4727
|
+
|
|
4728
|
+
## Do the work in a handler, not in `switchMap`
|
|
4729
|
+
|
|
4730
|
+
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.
|
|
4731
|
+
|
|
4732
|
+
<!-- docs-check: skip - needs rxjs, which the project does not depend on -->
|
|
4733
|
+
```js
|
|
4734
|
+
import { latest } from "nanoeffects/policies";
|
|
4735
|
+
|
|
4736
|
+
// RxJS: shape the values (debounce, skip repeats), then hand them to the bus
|
|
4737
|
+
from(observe("typed"))
|
|
4738
|
+
.pipe(debounceTime(300), distinctUntilChanged())
|
|
4739
|
+
.subscribe((query) => bus.emit("search", query));
|
|
4740
|
+
|
|
4741
|
+
// nanoeffects: one cancellable request; a newer "search" aborts the old one
|
|
4742
|
+
bus.on(
|
|
4743
|
+
"search",
|
|
4744
|
+
latest(async (query, { signal }) => {
|
|
4745
|
+
const items = await api.search(query, { signal });
|
|
4746
|
+
bus.emit("results", items);
|
|
4747
|
+
}),
|
|
4748
|
+
);
|
|
4749
|
+
```
|
|
4750
|
+
|
|
4751
|
+
`switchMap` drops the old inner stream. `latest` aborts the old task through `signal`, so a `fetch` that got the signal really stops.
|
|
4752
|
+
|
|
4753
|
+
## Convert back to a stream or a promise
|
|
4754
|
+
|
|
4755
|
+
| You need | Write |
|
|
4756
|
+
| -------- | ----- |
|
|
4757
|
+
| A stream of events, with operators | `from(observe("event"))` |
|
|
4758
|
+
| The next event as a promise | `firstValueFrom(from(observe("event")))`; inside a task `take(ctx, "event")` does the same and respects abort |
|
|
4759
|
+
| The events of a bus as an RxJS `Observable` class (for `instanceof` or `pipe` as a method) | `new Observable((subscriber) => observe("event").subscribe(subscriber))` |
|
|
4760
|
+
| A task's abort as a stream | `fromEvent(ctx.signal, "abort")`, for `takeUntil` |
|
|
4761
|
+
| The latest value to new subscribers | `from(observe("event")).pipe(shareReplay(1))`; the Observable replays nothing by itself |
|
|
4762
|
+
|
|
4763
|
+
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.
|
|
4764
|
+
|
|
4765
|
+
## End the subscription
|
|
4766
|
+
|
|
4767
|
+
A subscription from `observe` is not tied to a task. Nothing ends it for you. Pick one:
|
|
4768
|
+
|
|
4769
|
+
<!-- docs-check: skip - needs rxjs, which the project does not depend on -->
|
|
4770
|
+
```js
|
|
4771
|
+
import { fromEvent, from } from "rxjs";
|
|
4772
|
+
import { takeUntil } from "rxjs/operators";
|
|
4773
|
+
import { onAbort } from "nanoeffects/effects";
|
|
4774
|
+
|
|
4775
|
+
bus.on("screen-opened", (_, ctx) => {
|
|
4776
|
+
// takeUntil: stop the stream when the task aborts
|
|
4777
|
+
from(observe("typed"))
|
|
4778
|
+
.pipe(takeUntil(fromEvent(ctx.signal, "abort")))
|
|
4779
|
+
.subscribe((query) => bus.emit("search", query));
|
|
4780
|
+
|
|
4781
|
+
// or: onAbort calls unsubscribe for you
|
|
4782
|
+
const subscription = observe("scrolled").subscribe(updatePosition);
|
|
4783
|
+
onAbort(ctx, subscription.unsubscribe);
|
|
4784
|
+
});
|
|
4785
|
+
```
|
|
4786
|
+
|
|
4787
|
+
Outside a task, call `subscription.unsubscribe()` yourself when the screen goes away, or use the signal of a module.
|
|
4788
|
+
|
|
4789
|
+
## Know the limits
|
|
4790
|
+
|
|
4791
|
+
- 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)).
|
|
4792
|
+
- `observe` reads the bus when you call `subscribe`, not when you create it. Create it once, call it as often as needed.
|
|
4793
|
+
- 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).
|
|
3874
4794
|
|
|
3875
4795
|
---
|
|
3876
4796
|
Source: docs/hmr.md
|