nanoeffects 0.8.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.
Files changed (49) hide show
  1. package/README.md +2 -2
  2. package/dist/actions.d.ts +38 -22
  3. package/dist/actions.js +1 -1
  4. package/dist/bus-CkTHUAvq.js +1 -0
  5. package/dist/context-COgyNlrC.js +1 -0
  6. package/dist/effects.js +1 -1
  7. package/dist/index.js +1 -1
  8. package/dist/notrace/actions.d.ts +38 -22
  9. package/dist/notrace/actions.js +1 -1
  10. package/dist/notrace/bus-BmCTo8BG.js +1 -0
  11. package/dist/notrace/context-yYU3_9lq.js +1 -0
  12. package/dist/notrace/{delay-B_W1tnkS.js → delay-BosJKFjg.js} +1 -1
  13. package/dist/notrace/effects.js +1 -1
  14. package/dist/notrace/index.js +1 -1
  15. package/dist/notrace/policies.d.ts +30 -2
  16. package/dist/notrace/policies.js +1 -1
  17. package/dist/oop.js +1 -1
  18. package/dist/policies.d.ts +2 -1
  19. package/dist/policies.js +1 -1
  20. package/dist/react.d.ts +33 -1
  21. package/dist/react.js +1 -1
  22. package/dist/scope.js +1 -1
  23. package/dist/signal.d.ts +51 -0
  24. package/dist/signal.js +1 -0
  25. package/dist/with-policy-CqUS-VWW.d.ts +30 -0
  26. package/dist/with-policy-DBV9dh2F.js +1 -0
  27. package/docs/README.md +6 -0
  28. package/docs/actions.md +73 -4
  29. package/docs/cancel-stale-requests.md +111 -0
  30. package/docs/debounce-and-abort.md +106 -0
  31. package/docs/guide.md +1 -1
  32. package/docs/migrating-from-effector.md +2 -2
  33. package/docs/migrating-from-mitt.md +97 -0
  34. package/docs/motivation.md +41 -0
  35. package/docs/observable.md +124 -10
  36. package/docs/package.md +4 -2
  37. package/docs/plugins.md +20 -0
  38. package/docs/policies.md +33 -0
  39. package/docs/prevent-double-submit.md +128 -0
  40. package/docs/query-libraries.md +124 -0
  41. package/docs/react.md +44 -0
  42. package/llms-full.txt +939 -26
  43. package/llms.txt +9 -3
  44. package/package.json +25 -4
  45. package/skills/nanoeffects/SKILL.md +52 -0
  46. package/dist/bus-BAs9x7IQ.js +0 -1
  47. package/dist/context-CK0ZomWt.js +0 -1
  48. package/dist/notrace/bus-Bdk0YyFQ.js +0 -1
  49. 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 two plain-text files for assistants that write code with it:
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`. Both match the installed version.
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).
@@ -931,15 +998,405 @@ bus.emit("save", draftB); // waits for draftA, then runs
931
998
  | `limit` | how many calls may wait |
932
999
  | `overflow` | when the line is full: `"drop-new"` (default) or `"drop-old"` |
933
1000
 
934
- A dropped call resolves with `undefined`. `queue(fn, { limit: 1, overflow: "drop-old" })` keeps only the latest waiting call. `limit: 0` lets nothing wait.
1001
+ A dropped call resolves with `undefined`. `queue(fn, { limit: 1, overflow: "drop-old" })` keeps only the latest waiting call. `limit: 0` lets nothing wait.
1002
+
1003
+ Every call returns a promise with the result of its own run. An error rejects only that call; the queue continues.
1004
+
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).
935
1390
 
936
- Every call returns a promise with the result of its own run. An error rejects only that call; the queue continues.
1391
+ ## Pick another policy when clicks must not be lost
937
1392
 
938
- **Abort:**
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
- - A waiting call whose task aborts is skipped and resolves with `undefined`.
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,84 @@ 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.fail` on it.
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
- - `success` / `fail` are a string (job makes an action creator) or an existing action creator. Missing ones become `<type>/success` or `<type>/fail`.
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`). Nothing is sent after abort.
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, once the handler settles.
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 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`.
1406
1932
 
1407
1933
  ## Mount domains with modules
1408
1934
 
@@ -2174,6 +2700,134 @@ The stores above call back only when the value changed, so the emitter does not
2174
2700
 
2175
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.
2176
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
+ ```
2820
+
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).
2822
+
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.
2830
+
2177
2831
  ---
2178
2832
  Source: docs/react.md
2179
2833
 
@@ -2341,11 +2995,55 @@ const checkName = latest(async (name, ctx) => {
2341
2995
 
2342
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.
2343
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
+
2344
3040
  ## Where to go next
2345
3041
 
2346
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).
2347
3044
  - `retry`, `timeout` and the other effects: [Effects](effects.md).
2348
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).
2349
3047
 
2350
3048
  ---
2351
3049
  Source: docs/context.md
@@ -3248,7 +3946,7 @@ nanoeffects has no store. `createStore` can be replaced by any store you like, f
3248
3946
  | event call `loaded(user)` | `bus.emit("loaded", user)` |
3249
3947
  | `createEffect(handler)` | a handler: `bus.on(event, handler)` |
3250
3948
  | effect with `done` and `fail` | [`createJob`](actions.md): emits the success and fail actions |
3251
- | `effect.pending` | not provided: keep a flag in your store, or read it from [Tracing](tracing.md) |
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)) |
3252
3950
  | `sample({ clock, fn, target })` | `bus.on(clock, handler)` and `ctx.emit(target, …)` |
3253
3951
  | `sample({ source: $store, clock })` | read the store from a [context field](context.md) in the handler |
3254
3952
  | `sample({ filter })` | an `if` in the handler |
@@ -3546,13 +4244,114 @@ bus.on(
3546
4244
  ## What is different
3547
4245
 
3548
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)).
3549
- - **No `pending`.** A running task is visible through [Tracing](tracing.md), not through a store. A flag in your store set at the start and the end of the handler does the job.
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).
3550
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.
3551
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.
3552
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`.
3553
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).
3554
4252
  - **Scopes are buses.** `fork()` becomes a `createBus()` per scope (a test, a request on the server).
3555
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
+
3556
4355
  ---
3557
4356
  Source: docs/migrating-from-rxjs.md
3558
4357
 
@@ -3849,28 +4648,142 @@ Source: docs/observable.md
3849
4648
 
3850
4649
  # Turn events into Observables
3851
4650
 
3852
- Use this page only when you already work in Observables / RxJS. Prefer `bus.on` with policies for cancellation and tracing.
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.
3853
4652
 
3854
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.
3855
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
+
3856
4659
  <!-- docs-check: skip - needs rxjs, which the project does not depend on -->
3857
4660
  ```js
3858
- import { from } from "rxjs";
3859
- import { debounceTime, distinctUntilChanged, switchMap } from "rxjs/operators";
4661
+ import { createBus } from "nanoeffects";
3860
4662
  import { createObserve } from "nanoeffects/observable";
4663
+ import { from } from "rxjs";
4664
+ import { debounceTime, distinctUntilChanged } from "rxjs/operators";
3861
4665
 
3862
- const observe = createObserve(bus); // Observable per event name
4666
+ const bus = createBus(); // the bus: events and handlers
4667
+ const observe = createObserve(bus); // observe(event) gives an Observable per event name
3863
4668
 
3864
- from(observe("typed"))
3865
- .pipe(debounceTime(300), distinctUntilChanged(), switchMap(searchRequest))
3866
- .subscribe((items) => bus.emit("results", items));
4669
+ const typed$ = from(observe("typed")); // RxJS wraps it with from()
4670
+
4671
+ const subscription = typed$
4672
+ .pipe(debounceTime(300), distinctUntilChanged())
4673
+ .subscribe((query) => bus.emit("search", query)); // back into the bus
3867
4674
  ```
3868
4675
 
3869
4676
  - `observe(event)` returns an Observable of the payloads of that event. Each `subscribe` is a `bus.on` of its own; `unsubscribe()` removes it.
3870
4677
  - `subscribe` takes a function or an object with `next`.
3871
- - The stream never completes and never fails: events are the only thing that arrives. Errors of the subscriber’s handler belong to the subscriber.
3872
- - A subscription made this way is not tied to a task. End it yourself: `onAbort(ctx, subscription.unsubscribe)` inside a task, or with the signal of a module.
3873
- - For cancellation, policies, and tracing, keep to `bus.on` with `latest`, `debounce`, `queue` — they know about tasks. The Observable is for places where a stream is already how you work.
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).
3874
4787
 
3875
4788
  ---
3876
4789
  Source: docs/hmr.md