nanoeffects 0.28.0 → 0.30.1

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 (139) hide show
  1. package/README.md +6 -5
  2. package/dist/{action-creator-CcAEvUnw.d.ts → action-creator-6rlBTmH9.d.ts} +60 -33
  3. package/dist/actions.d.ts +18 -18
  4. package/dist/actions.js +1 -1
  5. package/dist/bus-DFlmNJ6h.js +1 -0
  6. package/dist/context-B5Z2RpJt.js +1 -0
  7. package/dist/context-types-DKNc5mkS.d.ts +31 -0
  8. package/dist/context.d.ts +8 -3
  9. package/dist/context.js +1 -1
  10. package/dist/{delay-18LJ8jSk.js → delay-5OhJZZQ0.js} +1 -1
  11. package/dist/dev.d.ts +44 -3
  12. package/dist/dev.js +2 -1
  13. package/dist/effects.d.ts +2 -2
  14. package/dist/effects.js +1 -1
  15. package/dist/emit-key-C6FL7Ewn.js +1 -0
  16. package/dist/guard.d.ts +59 -0
  17. package/dist/guard.js +1 -0
  18. package/dist/index.d.ts +2 -2
  19. package/dist/index.js +1 -1
  20. package/dist/match.d.ts +3 -3
  21. package/dist/match.js +1 -1
  22. package/dist/{module-C_oEXw1n.d.ts → module-G39MaVOt.d.ts} +38 -38
  23. package/dist/notrace/{action-creator-DxOvVo8I.d.ts → action-creator-BrMSO3RP.d.ts} +35 -32
  24. package/dist/notrace/actions.d.ts +52 -52
  25. package/dist/notrace/actions.js +1 -1
  26. package/dist/notrace/{bus-DmKn4SDD.js → bus-37nlVljc.js} +1 -1
  27. package/dist/notrace/effects.d.ts +2 -2
  28. package/dist/notrace/index.d.ts +2 -2
  29. package/dist/notrace/index.js +1 -1
  30. package/dist/notrace/{plugin-lJjjd-SN.d.ts → plugin-7GUL2EW5.d.ts} +1 -1
  31. package/dist/notrace/policies.d.ts +1 -1
  32. package/dist/notrace/redux.d.ts +1 -1
  33. package/dist/observable.d.ts +43 -1
  34. package/dist/observable.js +1 -1
  35. package/dist/onAbort-BgMfspK4.js +1 -0
  36. package/dist/oop.d.ts +3 -3
  37. package/dist/oop.js +1 -1
  38. package/dist/{plugin-CuvIgjnX.d.ts → plugin-BWNP23hr.d.ts} +2 -2
  39. package/dist/policies.d.ts +2 -2
  40. package/dist/policies.js +1 -1
  41. package/dist/production/dev.js +1 -1
  42. package/dist/react.d.ts +2 -2
  43. package/dist/react.js +1 -1
  44. package/dist/redux.d.ts +1 -1
  45. package/dist/scope.d.ts +9 -9
  46. package/dist/signal-field-key-CWT-3OXC.js +1 -0
  47. package/dist/signal.d.ts +34 -6
  48. package/dist/signal.js +1 -1
  49. package/dist/site-DLvUoXs3.js +1 -0
  50. package/dist/svelte.d.ts +1 -1
  51. package/dist/trace.d.ts +170 -6
  52. package/dist/trace.js +3 -1
  53. package/dist/{types-FV8fuvI9.d.ts → types-N_cdRr1r.d.ts} +2 -2
  54. package/dist/vue.d.ts +1 -1
  55. package/dist/{with-policy-D-SQRkPh.js → with-policy-CeM1rtES.js} +1 -1
  56. package/dist/{with-policy-3bhFLEXs.d.ts → with-policy-CthiBp_z.d.ts} +1 -1
  57. package/docs/README.md +65 -22
  58. package/docs/actions.md +56 -13
  59. package/docs/cancel-stale-requests.md +2 -0
  60. package/docs/chat-example.md +15 -1
  61. package/docs/chat-widget.md +9 -7
  62. package/docs/context.md +352 -8
  63. package/docs/debounce-and-abort.md +3 -1
  64. package/docs/debugging-loops.md +283 -0
  65. package/docs/effects.md +259 -131
  66. package/docs/errors.md +4 -2
  67. package/docs/examples.md +60 -0
  68. package/docs/guide.md +57 -22
  69. package/docs/hmr.md +35 -1
  70. package/docs/loop-guard.md +109 -0
  71. package/docs/migrating-from-effector.md +15 -2
  72. package/docs/migrating-from-mitt.md +6 -4
  73. package/docs/migrating-from-rxjs.md +2 -0
  74. package/docs/migrating-from-saga.md +2 -0
  75. package/docs/motivation.md +14 -21
  76. package/docs/observable.md +45 -0
  77. package/docs/oop.md +20 -1
  78. package/docs/package.md +25 -13
  79. package/docs/pass-the-signal.md +281 -0
  80. package/docs/plugins.md +90 -15
  81. package/docs/policies.md +73 -46
  82. package/docs/prevent-double-submit.md +3 -1
  83. package/docs/query-libraries.md +276 -6
  84. package/docs/react.md +50 -11
  85. package/docs/recipes.md +528 -0
  86. package/docs/ru/README.md +62 -0
  87. package/docs/ru/actions.md +412 -0
  88. package/docs/ru/cancel-stale-requests.md +106 -0
  89. package/docs/ru/chat-example.md +259 -0
  90. package/docs/ru/chat-widget.md +131 -0
  91. package/docs/ru/context.md +401 -0
  92. package/docs/ru/debounce-and-abort.md +105 -0
  93. package/docs/ru/debugging-loops.md +283 -0
  94. package/docs/ru/effects.md +538 -0
  95. package/docs/ru/errors.md +36 -0
  96. package/docs/ru/examples.md +60 -0
  97. package/docs/ru/guide.md +530 -0
  98. package/docs/ru/hmr.md +83 -0
  99. package/docs/ru/loop-guard.md +109 -0
  100. package/docs/ru/migrating-from-effector.md +339 -0
  101. package/docs/ru/migrating-from-mitt.md +99 -0
  102. package/docs/ru/migrating-from-rxjs.md +197 -0
  103. package/docs/ru/migrating-from-saga.md +97 -0
  104. package/docs/ru/motivation.md +47 -0
  105. package/docs/ru/observable.md +233 -0
  106. package/docs/ru/oop.md +431 -0
  107. package/docs/ru/package.md +42 -0
  108. package/docs/ru/pass-the-signal.md +281 -0
  109. package/docs/ru/plugins.md +398 -0
  110. package/docs/ru/policies.md +201 -0
  111. package/docs/ru/prevent-double-submit.md +122 -0
  112. package/docs/ru/query-libraries.md +392 -0
  113. package/docs/ru/react.md +309 -0
  114. package/docs/ru/recipes.md +528 -0
  115. package/docs/ru/semantics.md +19 -0
  116. package/docs/ru/server.md +204 -0
  117. package/docs/ru/starter.md +118 -0
  118. package/docs/ru/stores.md +673 -0
  119. package/docs/ru/svelte.md +78 -0
  120. package/docs/ru/tracing.md +466 -0
  121. package/docs/ru/typescript.md +170 -0
  122. package/docs/ru/vue.md +92 -0
  123. package/docs/semantics.md +12 -0
  124. package/docs/server.md +5 -3
  125. package/docs/starter.md +2 -0
  126. package/docs/stores.md +2 -0
  127. package/docs/svelte.md +3 -1
  128. package/docs/tracing.md +132 -7
  129. package/docs/typescript.md +3 -1
  130. package/docs/vue.md +3 -1
  131. package/llms-full.txt +2718 -545
  132. package/llms.txt +17 -14
  133. package/package.json +25 -8
  134. package/skills/nanoeffects/SKILL.md +2 -2
  135. package/dist/bus-DlpEluzd.js +0 -1
  136. package/dist/context-UcmrAIhP.js +0 -1
  137. package/dist/context-types-hCKgU8sC.d.ts +0 -14
  138. package/docs/scope.md +0 -266
  139. package/docs/stability.md +0 -90
package/llms-full.txt CHANGED
@@ -9,25 +9,44 @@ 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` and `bus.useEventState`; `usePolicy`; needs React), `nanoeffects/svelte` (`sveltePlugin`, adds `bus.eventStore`; needs Svelte), `nanoeffects/vue` (`vuePlugin`, adds `bus.watchEmitter` and `bus.useEventRef`; needs Vue), `nanoeffects/trace` (`createTracer`, loggers, `tracePlugin`), `nanoeffects/dev`.
13
- - `bus.emit(command(payload))` runs the command's handler and returns its result. Plain events return nothing.
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` and `bus.useEventState`; `usePolicy`; needs React), `nanoeffects/svelte` (`sveltePlugin`, adds `bus.eventStore`; needs Svelte), `nanoeffects/vue` (`vuePlugin`, adds `bus.watchEmitter` and `bus.useEventRef`; needs Vue), `nanoeffects/trace` (`createTracer`, loggers, `graphLogger`, `tracePlugin`), `nanoeffects/guard` (`loopGuardPlugin`), `nanoeffects/dev`.
13
+ - `bus.emit(action(payload))` runs the action's handler and returns its result. Plain events return nothing.
14
14
 
15
15
  ---
16
16
  Source: docs/guide.md
17
17
 
18
18
  # Guide
19
19
 
20
- Climb one step at a time: small bus → policies → effects → handlers and modules. Stop when your project has what it needs.
20
+ > **Levels A to D.** Read first: [README](../README.md) (install and the first example).
21
21
 
22
- | Your project | Stop after |
23
- | ------------ | ---------- |
24
- | Widget / prototype | §1–2 |
25
- | SPA with async UI | §3–4 |
26
- | Unreliable network / long flows | §5–6 |
27
- | Domain logic off the UI | §7–9 |
22
+ Climb one step at a time: small bus → policies → effects → handlers and modules. Stop when your project has what it needs. Install the package first: `npm install nanoeffects` (details in the [README](../README.md#install)). The levels A to D are described in the [map of the docs](README.md#the-ladder-from-a-to-f).
23
+
24
+ | Level | Your project | Stop after |
25
+ | ----- | ------------ | ---------- |
26
+ | A | Widget / prototype | §1–2 |
27
+ | B | SPA with async UI | §3–4 |
28
+ | C | Unreliable network / long flows | §5–6 |
29
+ | D | Domain logic off the UI | §7–9 |
28
30
 
29
31
  Runnable code for every step: [`examples/guide.ts`](../examples/guide.ts).
30
32
 
33
+ ## On this page
34
+
35
+ - [1. Create a bus](#1-create-a-bus)
36
+ - [Listen to every event](#listen-to-every-event)
37
+ - [Pick events with a function](#pick-events-with-a-function)
38
+ - [2. Use the handler context](#2-use-the-handler-context)
39
+ - [3. Abort the previous run with `latest`](#3-abort-the-previous-run-with-latest)
40
+ - [4. Pick another policy when overlap differs](#4-pick-another-policy-when-overlap-differs)
41
+ - [5. Add retry and timeout](#5-add-retry-and-timeout)
42
+ - [6. Run work in parallel or wait for an event](#6-run-work-in-parallel-or-wait-for-an-event)
43
+ - [7. Name events three ways](#7-name-events-three-ways)
44
+ - [8. Replace a handler on the fly](#8-replace-a-handler-on-the-fly)
45
+ - [9. Mount a domain as a module](#9-mount-a-domain-as-a-module)
46
+ - [10. Add types when you want checking (optional)](#10-add-types-when-you-want-checking-optional)
47
+ - [11. Isolate errors and warn on typos](#11-isolate-errors-and-warn-on-typos)
48
+ - [Where next](#where-next)
49
+
31
50
  ## 1. Create a bus
32
51
 
33
52
  `on` subscribes. `emit` calls the subscribers. `on` returns the unsubscribe function.
@@ -48,7 +67,12 @@ bus.emit("greet", "Bob"); // nobody listens
48
67
 
49
68
  Handlers run at once, in subscription order.
50
69
 
51
- `bus.once` is like `on`, but the handler runs at most once (it is removed before it is invoked). `bus.off("greet")` clears every listener of that event; `bus.off()` with no arguments clears **every** listener on the bus (including `"*"`). `ctx.off` is the same `off` as the bus’s — so `ctx.off()` also clears the whole bus. `bus.get("greet")` gives the listeners that event has right now; `bus.get("greet").length === 0` tells that nobody listens any more (`"*"` gives the any-event listeners, which a named event does not include; `bus.get()` with no name gives every listener of every event). It takes the event name as a string: for an action creator use `creator.type`.
70
+ Four more calls cover most of what you need from the bus itself:
71
+
72
+ - `bus.once(event, handler)` is like `on`, but the handler runs at most once (it is removed before it is invoked).
73
+ - `bus.off("greet")` clears every listener of that event.
74
+ - `bus.off()` with no arguments clears **every** listener on the bus, `"*"` included. `ctx.off` is the same `off` as the bus's, so `ctx.off()` also clears the whole bus.
75
+ - `bus.get("greet")` gives the listeners that event has right now, so `bus.get("greet").length === 0` tells that nobody listens any more. `"*"` gives the any-event listeners, which a named event does not include; `bus.get()` with no name gives every listener of every event. It takes the event name as a string: for an action creator use `creator.type`.
52
76
 
53
77
  ```js
54
78
  import { createBus } from "nanoeffects";
@@ -66,6 +90,8 @@ bus.off(); // clear every subscription
66
90
 
67
91
  ### Listen to every event
68
92
 
93
+ You can skip this and the next section on a first read.
94
+
69
95
  `bus.on("*", fn)` runs for every event of the bus. It is for logging, debugging, or forwarding events elsewhere. The handler gets the payload and the task context as usual, and `ctx.type` is the name of the event.
70
96
 
71
97
  ```js
@@ -88,7 +114,7 @@ unbind();
88
114
 
89
115
  ### Pick events with a function
90
116
 
91
- To listen to a group of events, such as every `user/*` event, `"*"` and a check of `ctx.type` do the job but leave the check to you. `matchPlugin` lets `on` take a function that picks the events. The function gets `{ type, payload }` of each event, and the handler runs when it returns true.
117
+ This is for level B and later. To listen to a group of events, such as every `user/*` event, `"*"` and a check of `ctx.type` do the job but leave the check to you. `matchPlugin` lets `on` take a function that picks the events. The function gets `{ type, payload }` of each event, and the handler runs when it returns true.
92
118
 
93
119
  <!-- docs-check: new -->
94
120
 
@@ -234,7 +260,7 @@ const searchLatest = latest(async (query, { signal }) => {
234
260
  bus.on("search", debounce(300, searchLatest));
235
261
  ```
236
262
 
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).
263
+ Full options: [Policies](policies.md). No bus? A policy also works as a plain function: [`withPolicy`: call a policy outside a bus](policies.md#withpolicy-call-a-policy-outside-a-bus).
238
264
 
239
265
  ## 5. Add retry and timeout
240
266
 
@@ -289,10 +315,11 @@ import { all, delay, race, take } from "nanoeffects/effects";
289
315
  const bus = createBus();
290
316
 
291
317
  // all — run together; one failure cancels the rest
318
+ // child — the context of that request: it has the `signal`, so the request stops with the group
292
319
  bus.on("search", async (id, ctx) => {
293
320
  const { user, posts } = await all(ctx, {
294
- user: ({ signal }) => api.user(id, { signal }),
295
- posts: ({ signal }) => api.posts(id, { signal }),
321
+ user: (child) => api.user(id, child),
322
+ posts: (child) => api.posts(id, child),
296
323
  });
297
324
  ctx.emit("loaded", { user, posts });
298
325
  });
@@ -363,9 +390,11 @@ bus.emit(double(21)); // 42
363
390
 
364
391
  Use strings for the plain case. Use objects when other code already builds actions. Give `createAction` a handler when you need a result from `emit`.
365
392
 
366
- ## 8. Bind a handler to an action later
393
+ ## 8. Replace a handler on the fly
367
394
 
368
- An action may carry its handler, as `double` above, or get it later from a registry — another file, or a fake in a test. An action with no handler is an event until one is bound.
395
+ **Problem.** The handler of an action is in the file of the feature, but a test needs a fake, or another layer owns the work. If the handler sits in `createAction`, the event file is tied to the work.
396
+
397
+ **What to do.** Create the action without a handler: it is an event. Bind the real handler with `setHandler` when the bus is made. Later, `actions.handle` binds a newer handler that wins until you remove it.
369
398
 
370
399
  ```js
371
400
  // user.js
@@ -377,26 +406,32 @@ export const getUser = createAction("user/get"); // no handler yet: an event
377
406
  ```js
378
407
  // bus.js
379
408
  import { createBus } from "nanoeffects";
380
- import { actionsPlugin } from "nanoeffects/actions";
409
+ import { actionsPlugin, setHandler } from "nanoeffects/actions";
410
+ import { getUser } from "./user";
381
411
 
382
412
  export const bus = createBus();
383
- // actionsPlugin — registry of handlers for this bus
384
- export const actions = bus.use(actionsPlugin());
413
+
414
+ // setHandler — binds the real handler to the action
415
+ const realUser = setHandler(getUser, (id) => api.user(id));
416
+
417
+ // actionsPlugin — the registry of bound handlers of this bus
418
+ export const actions = bus.use(actionsPlugin([realUser]));
385
419
  ```
386
420
 
387
421
  ```js
388
422
  import { bus, actions } from "./bus";
389
423
  import { getUser } from "./user";
390
424
 
391
- bus.emit(getUser("1")); // nothing is bound: only an event
425
+ const user = await bus.emit(getUser("1")); // the real handler answers
392
426
 
393
- const unregister = actions.handle(getUser, (id) => api.user(id));
394
- const user = await bus.emit(getUser("1"));
427
+ // actions.handle — a newer binding wins, for example a fake in a test
428
+ const restore = actions.handle(getUser, (id) => ({ id, name: "test" }));
429
+ const fake = await bus.emit(getUser("1")); // the fake answers
395
430
 
396
- unregister();
431
+ restore(); // the real handler answers again
397
432
  ```
398
433
 
399
- Each bus has its own registry. A [module](#9-mount-a-domain-as-a-module) can register handlers and remove them on unmount. More in [Actions](actions.md).
434
+ Each bus has its own registry. A [module](#9-mount-a-domain-as-a-module) can bind handlers and remove them on unmount. More in [Actions](actions.md#bind-a-handler-later).
400
435
 
401
436
  ## 9. Mount a domain as a module
402
437
 
@@ -513,336 +548,825 @@ More in [Errors](errors.md).
513
548
  | Which import | [Package entries](package.md) |
514
549
 
515
550
  ---
516
- Source: docs/starter.md
517
-
518
- # Start a project from a template
551
+ Source: docs/recipes.md
519
552
 
520
- Use this page when you want a running project in one command instead of wiring Vite and the bus by hand. It makes a small app with a search box that waits for you to stop typing (`debounce`) and aborts the older request when a newer one starts (`latest`).
553
+ # Common tasks
521
554
 
522
- You need Node 18 or newer.
555
+ > **Levels A to E.** Read first: [README](../README.md).
523
556
 
524
- ## Create the project
557
+ Each task below starts with the problem, then a short solution. Where the page has more to say, the last line links to it. The tasks go from the simplest to the most involved.
525
558
 
526
- Run the command for your package manager. Replace `my-app` with the name of the new directory.
559
+ ## On this page
527
560
 
528
- ```bash
529
- npm create nanoeffects@latest my-app
530
- ```
561
+ - [Events and handlers](#events-and-handlers)
562
+ - [A handler needs an API client, a store or a logger](#a-handler-needs-an-api-client-a-store-or-a-logger)
563
+ - [One handler fails, the others must still run](#one-handler-fails-the-others-must-still-run)
564
+ - [A typo in an event name](#a-typo-in-an-event-name)
565
+ - [Races and repeated actions in the UI](#races-and-repeated-actions-in-the-ui)
566
+ - [An old response overwrites a new one](#an-old-response-overwrites-a-new-one)
567
+ - [A search box sends a request on every key](#a-search-box-sends-a-request-on-every-key)
568
+ - [A second click sends a second request](#a-second-click-sends-a-second-request)
569
+ - [Messages must go out in order](#messages-must-go-out-in-order)
570
+ - [A "Delete" button in a list blocks the other rows](#a-delete-button-in-a-list-blocks-the-other-rows)
571
+ - [An unreliable network and long flows](#an-unreliable-network-and-long-flows)
572
+ - [A request fails sometimes: retry and backoff](#a-request-fails-sometimes-retry-and-backoff)
573
+ - [A request hangs: timeout](#a-request-hangs-timeout)
574
+ - [Refresh data every N seconds: poll](#refresh-data-every-n-seconds-poll)
575
+ - [Wait for another event inside a handler: take](#wait-for-another-event-inside-a-handler-take)
576
+ - [Load several things at once: all](#load-several-things-at-once-all)
577
+ - [Take the fastest answer: race](#take-the-fastest-answer-race)
578
+ - [Features and domain logic](#features-and-domain-logic)
579
+ - [Get the result of emit](#get-the-result-of-emit)
580
+ - [Group a feature with its handlers: modules](#group-a-feature-with-its-handlers-modules)
581
+ - [Do not leave subscriptions after a widget closes](#do-not-leave-subscriptions-after-a-widget-closes)
582
+ - [Stop work on a server](#stop-work-on-a-server)
583
+ - [Debugging: what ran and why](#debugging-what-ran-and-why)
584
+ - [Other libraries in the project](#other-libraries-in-the-project)
585
+ - [Classes and TypeScript](#classes-and-typescript)
586
+ - [Use the bus in a class](#use-the-bus-in-a-class)
587
+ - [Keep the types of the bus in many files](#keep-the-types-of-the-bus-in-many-files)
588
+ - [Examples of whole apps](#examples-of-whole-apps)
589
+
590
+ ## Events and handlers
591
+
592
+ ### A handler needs an API client, a store or a logger
593
+
594
+ Many handlers use the same `api` client or logger. Importing it in every handler file ties the handlers to one client and makes tests harder. Put it on the bus once, and every task gets it on `ctx`.
531
595
 
532
- ```bash
533
- pnpm create nanoeffects my-app
534
- ```
596
+ ```js
597
+ import { createBus } from "nanoeffects";
535
598
 
536
- ```bash
537
- yarn create nanoeffects my-app
538
- ```
599
+ // createBus({ context }) — every task gets these fields on ctx
600
+ const bus = createBus({ context: { api, logger } });
539
601
 
540
- ```bash
541
- bun create nanoeffects my-app
602
+ bus.on("save", async (draft, { api, logger }) => {
603
+ await api.save(draft);
604
+ logger.info("saved");
605
+ });
542
606
  ```
543
607
 
544
- The command asks for the template when you do not give one. The directory must be new or empty: the command stops instead of overwriting files.
608
+ More: [Context fields](context.md).
545
609
 
546
- Then follow the steps it prints:
610
+ ### One handler fails, the others must still run
547
611
 
548
- ```bash
549
- cd my-app
550
- npm install
551
- npm run dev
552
- ```
612
+ Two handlers listen to `order/placed`: one sends analytics, the other saves the order. By default the first error stops the loop, so a broken analytics call can keep the order from being saved. With `onError` every handler runs and the errors go to one place.
553
613
 
554
- Open the address that Vite prints and type in the box. The fake server answers after 800 ms, so you can see the older request being aborted.
614
+ ```js
615
+ import { createBus } from "nanoeffects";
555
616
 
556
- ## Choose a template
617
+ const bus = createBus({
618
+ // onError — isolates the handlers: a thrown error goes here and the next handler still runs
619
+ onError: (error, { type }) => report(error, type),
620
+ });
557
621
 
558
- Pass `--template` to skip the question. With npm, put `--` before the option.
622
+ const sendAnalytics = (order) => {
623
+ throw new Error(`analytics is down for ${order.id}`);
624
+ };
625
+ const saveOrder = (order) => console.log("saved", order.id);
559
626
 
560
- | Template | What it shows | Use it when |
561
- | -------- | ------------- | ----------- |
562
- | `vanilla` | A bus, one event and a `debounce` plus `latest` handler, with plain DOM code | You use no framework, or want to see the bus alone |
563
- | `react` | The same handler with `useEmitter` and `useEventState`, and `usePolicy` for a debounced save in a component | You build a React app |
564
- | `vanilla-ts` | The `vanilla` app in TypeScript | You want the compiler to check events and handlers |
565
- | `react-ts` | The `react` app in TypeScript | You build a React app in TypeScript |
566
- | `domains-ts` | Actions with handlers, a job and modules, with the logic split into domain folders and React as a thin layer | You build an app with more than one feature and want the logic off the UI |
567
- | `vue-ts` | The `react-ts` app in Vue 3, with `watchEmitter` and `useEventRef` from `nanoeffects/vue` | You build a Vue 3 app |
568
- | `svelte-ts` | The `react-ts` app in Svelte 5, with `eventStore` from `nanoeffects/svelte` | You build a Svelte app |
627
+ bus.on("order/placed", sendAnalytics); // throws
628
+ bus.on("order/placed", saveOrder); // still runs
569
629
 
570
- ```bash
571
- npm create nanoeffects@latest my-app -- --template react
630
+ bus.emit("order/placed", { id: 1 }); // saved 1
572
631
  ```
573
632
 
574
- In the `react` templates, `useEventState` keeps the found items in the component. It is not a global store: another component gets its own copy. To share a value between components, use a state manager and see [Stores](stores.md).
633
+ More: [Errors and unheard events](errors.md#isolate-failing-handlers).
575
634
 
576
- The TypeScript templates are the same apps with the same files, written in `.ts` and `.tsx`. Events are made with `createAction<Payload>`, so handlers and `useEventState` get their types from the events, with no annotation in the component. `npm run build` type-checks the project (`tsc --noEmit`) before bundling it.
635
+ ### A typo in an event name
577
636
 
578
- ```bash
579
- npm create nanoeffects@latest my-app -- --template react-ts
580
- ```
637
+ `emit("serch")` does not fail: no one listens to that name, so nothing happens, and you look for the bug in the handler. `onUnhandled` warns about an event that nobody heard. Turn it on in development.
581
638
 
582
- ### Start with Vue
639
+ ```js
640
+ import { createBus } from "nanoeffects";
583
641
 
584
- `vue-ts` is the search app of `react-ts` in Vue 3 with `<script setup lang="ts">`. Its bus uses `vuePlugin` from `nanoeffects/vue`: `watchEmitter` sends the query to the bus when it changes, and `useEventRef` keeps the result of the search in a `ref`. Like `useEventState` in React, `useEventRef` is [not a store](vue.md#it-is-not-a-store): a value that several components share belongs in a store such as Pinia.
642
+ const bus = createBus({
643
+ // onUnhandled — called when an event has no listeners
644
+ onUnhandled: ({ type }) => console.warn(`Nobody listens to "${type}"`),
645
+ });
585
646
 
586
- ```bash
587
- npm create nanoeffects@latest my-app -- --template vue-ts
647
+ bus.on("search", (query) => console.log(query));
648
+
649
+ bus.emit("serch", "nano"); // warning: Nobody listens to "serch"
588
650
  ```
589
651
 
590
- `npm run build` type-checks the `.vue` files with `vue-tsc`, which needs TypeScript 5: the template depends on it.
652
+ More: [Warn when nobody listens](errors.md#warn-when-nobody-listens).
591
653
 
592
- ### Start with Svelte
654
+ ## Races and repeated actions in the UI
593
655
 
594
- `svelte-ts` is the search app of `react-ts` in Svelte 5. Its bus uses `sveltePlugin` from `nanoeffects/svelte`: `eventStore` makes a Svelte store from an event. In a component, `$found` reads it, and Svelte subscribes and unsubscribes for you. The query goes to the bus from the `oninput` handler, so nothing is emitted when the component mounts. The store listens only while something reads it, and is [not a store of the app](svelte.md#it-is-not-a-store-of-the-app): a value that several components share belongs in a store of your own.
656
+ ### An old response overwrites a new one
595
657
 
596
- ```bash
597
- npm create nanoeffects@latest my-app -- --template svelte-ts
598
- ```
658
+ A user types `re`, then `react`. Two requests are in flight. The server answers `react` fast and `re` slowly, so the answer for `re` arrives last and replaces the right list with the wrong one. This is what a plain React effect does:
599
659
 
600
- `npm run build` type-checks the `.svelte` files with `svelte-check`, which needs TypeScript 5: the template depends on it.
660
+ ```jsx
661
+ import { useEffect, useState } from "react";
601
662
 
602
- ### Start with domains
663
+ export function Search({ query }) {
664
+ const [items, setItems] = useState([]);
603
665
 
604
- `domains-ts` is not the search box of the other templates grown in TypeScript: it shows where logic goes when an app has several features. Each domain is a folder under `src/domains/`. It has the events of the domain, the work that handles them, and a module that mounts the work. The React components only emit events and read values.
666
+ useEffect(() => {
667
+ // two requests are in flight, and the one that finishes last wins, even the older one
668
+ api.search(query).then(setItems);
669
+ }, [query]);
605
670
 
606
- ```bash
607
- npm create nanoeffects@latest my-app -- --template domains-ts
671
+ return (
672
+ <ul>
673
+ {items.map((item) => (
674
+ <li key={item.id}>{item.title}</li>
675
+ ))}
676
+ </ul>
677
+ );
678
+ }
608
679
  ```
609
680
 
610
- | Path | Job |
611
- | ---- | --- |
612
- | `src/bus.ts` | Creates the bus. It imports no events and no domain. |
613
- | `src/app.ts` | Mounts the domains with `modulePlugin`. |
614
- | `src/domains/catalog/` | Searching products: `catalog.ts` (events and an action with a handler), `catalog-handlers.ts` (`debounce` and `latest`), `catalog-module.ts`. |
615
- | `src/domains/checkout/` | Placing an order: `checkout.ts` (a job), `checkout-handlers.ts` (`leading` and `retry`), `checkout-store.ts` (state written from the job's outcomes), `checkout-module.ts`. |
616
- | `src/ui/` | The components. None of them calls the fake server in `src/api.ts`. |
617
-
618
- For a bigger example, a chat in which parts react to each other, see [Build a chat with entities, features, modules and a small server](chat-example.md). It has more layers than `domains-ts`: when domains must react to each other, it keeps them from importing each other by splitting them into entities and features.
681
+ Move the request into a handler with `latest`: a new `search` aborts the previous request, and the aborted one delivers no result.
619
682
 
620
- The actions, jobs and modules themselves are explained in [Actions, jobs and modules](actions.md). The template keeps found products in a Context provider with one `useEventState`, and the order state in a store of its own: `useEventState` is [not a store](react.md#show-the-last-value-of-an-event-as-state).
683
+ ```js
684
+ import { createBus } from "nanoeffects";
685
+ import { latest } from "nanoeffects/policies";
621
686
 
622
- ## Read the files
687
+ const bus = createBus();
623
688
 
624
- Every template except `domains-ts` splits the code the way [the guide](guide.md) does: one job per file. In the TypeScript templates the files end in `.ts` (and `.tsx` for React components, `.vue` for Vue components, `.svelte` for Svelte components).
689
+ // latest — a new "search" aborts the previous one and drops its result
690
+ bus.on(
691
+ "search",
692
+ latest(async (query, { signal }) => {
693
+ // signal — lets the browser cancel the request itself
694
+ const items = await api.search(query, { signal });
695
+ bus.emit("results", items);
696
+ }),
697
+ );
625
698
 
626
- | File | Job |
627
- | ---- | --- |
628
- | `src/bus.js` | Creates the bus. It imports no events. |
629
- | `src/search.js` | Names the events with `createAction`. It imports no bus. |
630
- | `src/search-handlers.js` | Handles the events with `debounce` and `latest`. It knows the bus and the events. |
631
- | `src/api.js` | A fake server. Replace it with your own requests. |
699
+ bus.on("results", (items) => render(items));
700
+ ```
632
701
 
633
- Every call of nanoeffects in these files has a comment that says what it does. To make it your own, replace `src/api.js`, then rename the events and the handler.
702
+ More: [Cancel stale requests](cancel-stale-requests.md), [Policies](policies.md#latest-abort-the-previous-run), and the component side in [Build a sign-up form in React](react.md).
634
703
 
635
- ## Next steps
704
+ ### A search box sends a request on every key
636
705
 
637
- - [Policies](policies.md): `leading`, `throttle` and `queue` next to `latest`.
638
- - [Build a sign-up form in React](react.md): the same ideas in a bigger React example.
639
- - [Package entries](package.md): which import holds what.
706
+ Typing `react` sends five requests, and the list flickers. `debounce` waits for a pause in typing; `latest` aborts the request that is still in flight.
640
707
 
641
- ---
642
- Source: docs/motivation.md
708
+ ```js
709
+ import { createBus } from "nanoeffects";
710
+ import { debounce, latest } from "nanoeffects/policies";
643
711
 
644
- # Why nanoeffects exists
712
+ const bus = createBus();
645
713
 
646
- Read this page to see why the library exists and when to pick it. The main reason is to take business logic and side effects out of React, Vue or Svelte components and write them as plain `async` functions, with cancellation, retries and a context for dependencies. The second part of the page is for SDKs, core libraries and micro-frontend shells, where a shared dependency is a shared risk. It does not teach the API: start with the [Guide](guide.md).
714
+ // latest — a new search aborts the request in flight
715
+ const searchLatest = latest(async (query, { signal }) => {
716
+ const items = await api.search(query, { signal });
717
+ bus.emit("results", items);
718
+ });
647
719
 
648
- ## The main reason: logic outside the UI
720
+ // debounce — start after 300 ms without a new "typed"
721
+ bus.on("typed", debounce(300, searchLatest));
722
+ ```
649
723
 
650
- Components that hold requests, timers and races are hard to test and easy to break. The usual cure is services and pure functions outside the UI, and then every service needs the same things: to cancel work that is no longer wanted, to retry, to share dependencies, and to show what ran and why.
724
+ More: [Pause between requests](debounce-and-abort.md).
651
725
 
652
- The common tools ask a lot: redux-saga writes this logic as generators, and RxJS as streams and operators. nanoeffects keeps ordinary `async` functions and adds one idea, the `AbortSignal`. The event bus is its heart but not its value: any emitter can send events (`nanoevents` is about 100 bytes, and there are [dozens more](https://github.com/gajus/sister#similar-libraries)). What nanoeffects adds is cancellable tasks, policies and effects, a context that works as a small container for dependencies, actions and modules to group a feature, and [tracing](tracing.md) with an adapter for Redux DevTools. It also fits what a project already has: recipes and small adapters for [stores](stores.md) (Redux Toolkit, Zustand, MobX, Pinia), an [RxJS](migrating-from-rxjs.md) bridge, and helpers for [React](react.md), [Vue](vue.md) and [Svelte](svelte.md), so nothing has to be rewritten to start.
726
+ ### A second click sends a second request
653
727
 
654
- ## When the constraint is a shared library
728
+ A user double-clicks "Sign up" on a slow network, and two accounts are created. `leading` drops every call that comes while the first one still runs.
655
729
 
656
- 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.
730
+ ```js
731
+ import { createBus } from "nanoeffects";
732
+ import { leading } from "nanoeffects/policies";
657
733
 
658
- 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.
734
+ const bus = createBus();
659
735
 
660
- Real cases from the libraries nanoeffects was compared with:
736
+ // leading — a call that comes while the previous one runs is dropped
737
+ bus.on(
738
+ "form/submitted",
739
+ leading(async (form, { signal }) => {
740
+ await api.signUp(form, { signal });
741
+ }),
742
+ );
743
+ ```
661
744
 
662
- | Library | What goes wrong | Source |
663
- | ------- | --------------- | ------ |
664
- | 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) |
665
- | 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) |
666
- | 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) |
667
- | 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) |
745
+ More: [Prevent a double submit](prevent-double-submit.md).
668
746
 
669
- 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.
747
+ ### Messages must go out in order
670
748
 
671
- ## Why not the alternatives
749
+ In a chat, a user sends `Hello` and then `How are you?` within a second. Two parallel requests can reach the server in the wrong order. `queue` sends the next message only after the previous one is delivered.
672
750
 
673
- | Option | Why it did not fit |
674
- | ------ | ------------------ |
675
- | 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. |
676
- | Effector | A graph with global identity. A version or scope mismatch between the SDK and the app reaches the whole infrastructure. |
677
- | 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). |
678
- | 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. |
751
+ ```js
752
+ import { createBus } from "nanoeffects";
753
+ import { queue } from "nanoeffects/policies";
679
754
 
680
- ## What the library does instead
755
+ const bus = createBus();
681
756
 
682
- - **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.
683
- - **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 actions are separate entries you import only when you need them. See [Package entries](package.md).
684
- - **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.
685
- - **`emit` can return an answer.** An [action with a handler](actions.md) 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.
686
- - **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.
757
+ // queue — the next message waits until the previous one is delivered
758
+ bus.on(
759
+ "message/sent",
760
+ queue(async (message, { signal }) => {
761
+ await api.sendMessage(message, { signal });
762
+ }),
763
+ );
764
+ ```
687
765
 
688
- ## When this is not the right tool
766
+ More: [Policies, `queue`](policies.md#run-calls-one-after-another-with-queue).
689
767
 
690
- 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).
768
+ ### A "Delete" button in a list blocks the other rows
691
769
 
692
- ---
693
- Source: docs/package.md
770
+ You protect "Delete" with `leading`. While one row is deleted, clicks on the other rows are dropped too, because the whole handler has one state. `key` gives every row its own state.
694
771
 
695
- # Choose a package entry
772
+ ```js
773
+ import { createBus } from "nanoeffects";
774
+ import { leading } from "nanoeffects/policies";
696
775
 
697
- Imports are split so you only load what you use. `createBus` stays small; the rest is another entry.
776
+ const bus = createBus();
698
777
 
699
- | Import | What is in it |
700
- | ------ | ------------- |
701
- | `nanoeffects` | `createBus`, the types |
702
- | `nanoeffects/policies` | `latest`, `leading`, `debounce`, `throttle`, `queue` |
703
- | `nanoeffects/effects` | `retry`, `timeout`, `poll`, `delay`, `take`, `all`, `allSettled`, `race`, `effectsPlugin` |
704
- | `nanoeffects/actions` | `createAction`, `createJob`, `createModule`, `modulePlugin`, `actionsPlugin` |
705
- | `nanoeffects/match` | `matchPlugin`: `on`, `once` and `off` that take a function which picks the events |
706
- | `nanoeffects/context` | `contextPlugin`: your own fields (a store, a client) on `ctx` of every task, typed from the value |
707
- | `nanoeffects/signal` | `signalPlugin`: `emit(call, { signal })` ties the tasks of an emit to an outside `AbortSignal` |
708
- | `nanoeffects/oop` | `Emitter`, `extendBus`, `WithBus`, `On`, `Once`, `disposeBus`: the methods of a bus on a class, with the subscriptions of each instance ended by `dispose()` |
709
- | `nanoeffects/scope` | `scopePlugin`, `effect`: fields for one action, job, module or `emit` (the types `Scope`, `Effect`) |
710
- | `nanoeffects/redux` | `createEffectsMiddleware` |
711
- | `nanoeffects/observable` | `createObserve`, `Subject` |
712
- | `nanoeffects/react` | `reactPlugin`: adds the `useEmitter` hook to a bus (needs React 18 or newer) |
713
- | `nanoeffects/svelte` | `sveltePlugin`: adds `eventStore` to a bus (needs Svelte 5 or newer) |
714
- | `nanoeffects/vue` | `vuePlugin`: adds `watchEmitter` and `useEventRef` to a bus (needs Vue 3.3 or newer) |
715
- | `nanoeffects/dev` | `devPlugin` (no-op in production) |
716
- | `nanoeffects/trace` | `createTracer`, the loggers and `tracePlugin` (names for the trace) |
778
+ // leading with key — every row id has its own state, so other rows are not blocked
779
+ bus.on(
780
+ "row/deleted",
781
+ leading(
782
+ async (row, { signal }) => {
783
+ await api.deleteRow(row.id, { signal });
784
+ },
785
+ { key: (row) => row.id },
786
+ ),
787
+ );
788
+ ```
717
789
 
718
- The `...Plugin` names above are used with `bus.use`. [Plugins](plugins.md) lists all of them, what each returns, and the order to use them in.
790
+ More: [Policies, `key`](policies.md#key-isolate-state-by-key).
719
791
 
720
- ## Give the docs to an AI assistant
792
+ ## An unreliable network and long flows
721
793
 
722
- The package ships plain-text files for assistants that write code with it:
794
+ ### A request fails sometimes: retry and backoff
723
795
 
724
- - `llms.txt`: a short index. It states the rules that generated code often gets wrong and links to every page of these docs.
725
- - `llms-full.txt`: the same summary followed by the text of every linked page, in one file.
726
- - `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/`.
796
+ A request fails once in a while because of the network. `retry` repeats it. If the server is overloaded, repeating at once makes it worse, so use `backoff` to make the pause longer with each attempt.
727
797
 
728
- 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.
798
+ ```js
799
+ import { createBus } from "nanoeffects";
800
+ import { backoff, retry } from "nanoeffects/effects";
729
801
 
730
- ---
731
- Source: docs/stability.md
802
+ const bus = createBus();
732
803
 
733
- # Know what is stable
804
+ // retry — repeat the request after an error, up to 4 attempts
805
+ const loadWithRetry = retry((id, { signal }) => api.getUser(id, { signal }), {
806
+ times: 4,
807
+ // backoff — the pause grows (200, 400, 800 ms) with a random part, so clients do not retry at the same moment
808
+ delay: backoff({
809
+ base: 200,
810
+ max: 5000,
811
+ jitter: true,
812
+ }),
813
+ });
734
814
 
735
- Use this page to decide what you can rely on. It says which imports are stable, what counts as a breaking change, which versions are supported, and which call forms stay.
815
+ bus.on("user/opened", loadWithRetry);
816
+ ```
736
817
 
737
- ## See which entries are stable
818
+ More: [`backoff`](effects.md#backoff).
738
819
 
739
- A stable entry follows the rules below. An unstable entry can change in any minor release; every change is written in the [CHANGELOG](../CHANGELOG.md), and nothing promises a deprecation period first.
820
+ ### A request hangs: timeout
740
821
 
741
- | Import | Status | Note |
742
- | ------ | ------ | ---- |
743
- | `nanoeffects` | Stable | `createBus` and the types of a bus |
744
- | `nanoeffects/effects` | Stable | `retry`, `timeout`, `poll`, `take`, `delay`, `all`, `allSettled`, `race`, `backoff`, `cancellable`, `effectsPlugin` |
745
- | `nanoeffects/policies` | Stable | `latest`, `leading`, `debounce`, `throttle`, `queue`, `withPolicy` |
746
- | `nanoeffects/actions` | Stable | `createAction`, `createJob`, `createModule` and their plugins |
747
- | `nanoeffects/trace` | Stable: the contract | `Tracer`, `Trace`, `TraceEvent`, `TraceLogger`, `Phase`, `EffectName` and `tracePlugin` |
748
- | `nanoeffects/trace` | Unstable: the tools | `createTracer`, `consoleLogger`, `performanceLogger`, `createTraceRecorder`, `reduxDevtools` and their option types |
749
- | `nanoeffects/scope` | Unstable | `scopePlugin`, `effect` |
750
- | `nanoeffects/match` | Unstable | `matchPlugin` |
751
- | `nanoeffects/signal` | Unstable | `signalPlugin` |
752
- | `nanoeffects/context` | Unstable | `contextPlugin` |
753
- | `nanoeffects/oop` | Unstable | `Emitter`, `extendBus`, `On`, `Once`, `WithBus`, `disposeBus` |
754
- | `nanoeffects/observable` | Unstable | `createObserve`, `Subject` |
755
- | `nanoeffects/redux` | Unstable | `createEffectsMiddleware` |
756
- | `nanoeffects/react` | Unstable | `reactPlugin`, `usePolicy` |
757
- | `nanoeffects/svelte` | Unstable | `sveltePlugin` |
758
- | `nanoeffects/vue` | Unstable | `vuePlugin` |
759
- | `nanoeffects/dev` | Unstable | `devPlugin` |
822
+ A request does not fail and does not answer, and the spinner spins forever. `timeout` rejects the call after the time you set and aborts the request.
760
823
 
761
- The adapters (`react`, `vue`, `svelte`, `redux`) are unstable because they follow the APIs of other libraries and have had little use. They can become stable after the library has been in use for a while.
824
+ ```js
825
+ import { createBus } from "nanoeffects";
826
+ import { timeout } from "nanoeffects/effects";
762
827
 
763
- ## Know what is a breaking change
828
+ const bus = createBus();
764
829
 
765
- For a stable entry, these changes are breaking:
830
+ // timeout — reject with TimeoutError and abort the request after 5 s
831
+ const loadOnce = timeout(5000, (id, { signal }) => api.getUser(id, { signal }));
766
832
 
767
- - A change in what a call does at run time.
768
- - A change in a type that stops code that compiled before from compiling.
769
- - A removed or renamed export.
833
+ bus.on("user/opened", loadOnce);
834
+ ```
770
835
 
771
- A stricter type that only finds mistakes in code that was already wrong is not breaking. It ships in a minor release with a note in the CHANGELOG.
836
+ More: [`timeout`](effects.md#timeout).
772
837
 
773
- These are not covered by the promise:
838
+ ### Refresh data every N seconds: poll
774
839
 
775
- - Any unstable entry.
776
- - The text of the messages of `nanoeffects/dev`.
777
- - The payload details of trace events. The shape of an event (`id`, `parentId`, `rootId`, `type`, `effect`, `phase`) is covered; the contents of `payload` are not.
840
+ A dashboard must refresh its numbers every ten seconds while it is open. `poll` is an effect: it calls your function on a timer and ends when the task is aborted, so a closed dashboard does not keep asking.
778
841
 
779
- Before version 1.0.0, a breaking change ships in a minor release under `### Breaking` in the CHANGELOG. From 1.0.0 it ships only in a major release.
842
+ ```js
843
+ import { createBus } from "nanoeffects";
844
+ import { poll } from "nanoeffects/effects";
780
845
 
781
- A deprecated export keeps working and is removed only in a major release. `RaceStarter` is deprecated: use `StarterOf` instead.
846
+ const bus = createBus();
782
847
 
783
- ## Check the supported versions
848
+ bus.on("dashboard/opened", async (_, ctx) => {
849
+ // stop the polling when the user leaves
850
+ ctx.on("dashboard/closed", () => ctx.abort());
784
851
 
785
- | What | Supported |
786
- | ---- | --------- |
787
- | Node.js, the library | 18, 20, 22 and 24 (a smoke test runs the built package on each) |
788
- | Node.js, the dev tools and the trace in a browser | 22 and newer |
789
- | TypeScript | 5.9 and 7. The built declaration files are type-checked with TypeScript 5.9 in CI, and the source with TypeScript 7. Older versions are not tested. |
790
- | React | 18 or newer (`nanoeffects/react`) |
791
- | Vue | 3.3 or newer (`nanoeffects/vue`) |
792
- | Svelte | 5 or newer (`nanoeffects/svelte`) |
852
+ // poll — call refresh every 10 s until the task is aborted
853
+ await poll(ctx, () => refresh(), 10_000);
854
+ });
855
+ ```
793
856
 
794
- The library has no dependencies. React, Vue and Svelte are optional peer dependencies of their own entries only.
857
+ More: [`poll`](effects.md#poll).
795
858
 
796
- ## Rely on these call forms
859
+ ### Wait for another event inside a handler: take
797
860
 
798
- `retry`, `timeout` and `poll` have three call forms, and all three stay:
861
+ After a login the handler must wait for the user to log out before it cleans up. Instead of a flag and a second handler, `take` pauses the handler until the next event with that name.
799
862
 
800
863
  ```js
801
864
  import { createBus } from "nanoeffects";
802
- import { race, retry } from "nanoeffects/effects";
865
+ import { take } from "nanoeffects/effects";
803
866
 
804
867
  const bus = createBus();
805
868
 
806
- bus.on("open", async (id, ctx) => {
807
- // with ctx: you pass the context, the function gets the signal
808
- const user = await retry(ctx, ({ signal }) => fetchUser(id, { signal }), { times: 3 });
809
-
810
- // as a starter: a group (race, all, allSettled, timeout) calls it with its child
811
- const result = await race(ctx, {
812
- user: retry((_, { signal }) => fetchUser(id, { signal }), { times: 3 }),
813
- });
869
+ bus.on("login", async (user, ctx) => {
870
+ // take — pause this handler until the next "logout"
871
+ const reason = await take(ctx, "logout");
872
+ cleanup(user, reason);
814
873
  });
815
-
816
- // as a Task: a function of (payload, ctx), which bus.on and ctx.run call
817
- bus.on("reload", retry((id, { signal }) => fetchUser(id, { signal }), { times: 3 }));
818
874
  ```
819
875
 
820
- The forms are told apart by the arguments: a context first is the form with `ctx`; a function called with a payload and a context is the Task; the same function called by a group with one argument, its child, is the starter. The rule is part of the contract and does not depend on how many parameters your function declares.
876
+ More: [`take`](effects.md#take).
821
877
 
822
- `all` and `allSettled` take options only in the form with `ctx`, `all(ctx, effects, { concurrency })`. The form without `ctx` has one argument, so a second one would look like the `ctx` form. `race` takes no `concurrency`: it ends at the first result, so a starter that waited for a free slot would never run. See [Effects](effects.md#run-several-starters-together).
823
-
824
- ---
825
- Source: docs/plugins.md
878
+ ### Load several things at once: all
826
879
 
827
- # Choose the plugins of a bus
880
+ A profile page needs the user and the posts. Loading them one after another is slow. `all` runs both requests together and aborts the second one if the first fails.
828
881
 
829
- Optional. A plugin adds one thing to a bus: fields on `ctx`, a method, a registry of handlers, diagnostics. Read this page when you want to know which plugins exist, what each one gives you, and in what order to use them. Skip it for a small bus: `createBus()` alone is enough.
882
+ ```js
883
+ import { createBus } from "nanoeffects";
884
+ import { all } from "nanoeffects/effects";
830
885
 
831
- ## Use a plugin
886
+ const bus = createBus();
832
887
 
833
- A plugin is a function of the bus. `bus.use(plugin)` calls it with the bus and returns whatever the plugin returns. Most plugins return the same bus, so you can chain them: `createBus().use(a).use(b)`. A few return something of their own (a registry, an app, a `disconnect` function): keep it.
888
+ bus.on("profile/opened", async (id, ctx) => {
889
+ // all — run both requests together; if one fails, the other is aborted. child carries the signal of its own request
890
+ const { user, posts } = await all(ctx, {
891
+ user: (child) => fetchUser(id, child),
892
+ posts: (child) => fetchPosts(id, child),
893
+ });
834
894
 
835
- In TypeScript keep the result of `contextPlugin` and `effectsPlugin`: the type of the bus with its new fields is the type they return (`const bus = createBus().use(contextPlugin({ store }))`).
895
+ ctx.emit("profile/loaded", { user, posts });
896
+ });
897
+ ```
836
898
 
837
- ## Main plugins
899
+ ### Take the fastest answer: race
838
900
 
839
- These four cover what most apps need: shared dependencies, effects, handlers and modules. Start here.
901
+ The same data lives on two servers, and you want the answer of the faster one. `race` returns the first answer and aborts the other request.
902
+
903
+ ```js
904
+ import { createBus } from "nanoeffects";
905
+ import { race } from "nanoeffects/effects";
906
+
907
+ const bus = createBus();
908
+
909
+ bus.on("user/opened", async (id, ctx) => {
910
+ // race — the first answer wins, the slower request is aborted
911
+ const result = await race(ctx, {
912
+ primary: (child) => api.getUser(id, child),
913
+ backup: (child) => api.getUserFromBackup(id, child),
914
+ });
915
+
916
+ const user = "primary" in result ? result.primary : result.backup;
917
+ ctx.emit("user/loaded", user);
918
+ });
919
+ ```
920
+
921
+ More: [`all`](effects.md#all), [`race`](effects.md#race).
922
+
923
+ ## Features and domain logic
924
+
925
+ ### Get the result of emit
926
+
927
+ `emit` of a plain event returns nothing, so to get an answer you need a second event. An action with a handler returns the result of its handler right from `emit`.
928
+
929
+ ```js
930
+ import { createBus } from "nanoeffects";
931
+ import { createAction } from "nanoeffects/actions";
932
+
933
+ const bus = createBus();
934
+
935
+ // createAction with a handler — emit runs it and returns what it returns
936
+ const double = createAction("math/double", (value) => value * 2);
937
+
938
+ const result = bus.emit(double(21)); // 42
939
+ ```
940
+
941
+ More: [Actions, jobs and modules](actions.md).
942
+
943
+ ### Group a feature with its handlers: modules
944
+
945
+ A feature has several subscriptions, and you must remember to remove each one. A module registers them together and removes them all with one `unmount`.
946
+
947
+ ```js
948
+ import { createBus } from "nanoeffects";
949
+ import { createModule, modulePlugin } from "nanoeffects/actions";
950
+
951
+ // createModule — one feature; unmount cancels everything it registered
952
+ const greeting = createModule("greeting", (m) => {
953
+ m.on("greet", (name) => console.log(`hello ${name}`));
954
+ });
955
+
956
+ const bus = createBus();
957
+ const app = bus.use(modulePlugin([greeting]));
958
+
959
+ app.unmount(greeting); // the subscription is gone
960
+ ```
961
+
962
+ More: [Modules](actions.md#modules) and [the guide](guide.md#9-mount-a-domain-as-a-module).
963
+
964
+ ### Do not leave subscriptions after a widget closes
965
+
966
+ A chat widget subscribes to the bus when it opens. If it closes without unsubscribing, the handlers stay: memory grows with every open, and they still update a widget that is gone. A module that you mount on open and unmount on close leaves nothing behind.
967
+
968
+ ```js
969
+ import { createBus } from "nanoeffects";
970
+ import { createModule, modulePlugin } from "nanoeffects/actions";
971
+
972
+ // the module owns the listeners of the widget
973
+ const chatModule = createModule("chat", (m) => {
974
+ m.on("message/received", (message) => render(message));
975
+ });
976
+
977
+ const bus = createBus();
978
+ const app = bus.use(modulePlugin());
979
+
980
+ const stop = app.mount(chatModule); // the widget is opened: the listeners are on the bus
981
+
982
+ stop(); // the widget is closed: no listeners are left
983
+ console.log(bus.get("message/received").length); // 0
984
+ ```
985
+
986
+ More: [Build a chat widget](chat-widget.md).
987
+
988
+ ## Stop work on a server
989
+
990
+ A client closes the tab, but the server goes on asking other services for it. Create an `AbortController` for the request, abort it when the connection closes, and pass its signal to `emit`: `timeout`, `retry` and the request itself stop.
991
+
992
+ ```js
993
+ import { createBus } from "nanoeffects";
994
+ import { createAction } from "nanoeffects/actions";
995
+ import { signalPlugin } from "nanoeffects/signal";
996
+
997
+ // signalPlugin — emit accepts { signal }
998
+ const bus = createBus().use(signalPlugin());
999
+
1000
+ const getUser = createAction("user/get", (id, { signal }) => api.getUser(id, { signal }));
1001
+
1002
+ export async function handleRequest(request, reply) {
1003
+ const controller = new AbortController();
1004
+
1005
+ // close — the client left: abort everything started for this request
1006
+ reply.raw.on("close", () => controller.abort());
1007
+
1008
+ // the task, and the requests it made, stop with the controller
1009
+ return bus.emit(getUser(request.params.id), { signal: controller.signal });
1010
+ }
1011
+ ```
1012
+
1013
+ More: [Stop work on a server](server.md), including WebSocket and queue workers.
1014
+
1015
+ ## Debugging: what ran and why
1016
+
1017
+ An effect fired and you cannot tell which event caused it. The tracer logs every task with its parent, so you see the chain of events.
1018
+
1019
+ ```js
1020
+ import { createBus } from "nanoeffects";
1021
+ import { consoleLogger, createTracer, tracePlugin } from "nanoeffects/trace";
1022
+
1023
+ // createTracer — send every task to the console
1024
+ const trace = createTracer(consoleLogger());
1025
+
1026
+ // tracePlugin — also report every emit
1027
+ const bus = createBus({ trace }).use(tracePlugin());
1028
+
1029
+ bus.on("search", (query, ctx) => ctx.emit("results", [query]));
1030
+ bus.emit("search", "nano"); // the log shows: search · emit, then results · emit with search as the cause
1031
+ ```
1032
+
1033
+ More: [Trace tasks](tracing.md). To reload modules without a page refresh, see [Hot reload](hmr.md).
1034
+
1035
+ ## Other libraries in the project
1036
+
1037
+ - **Redux Toolkit, Zustand, MobX or Pinia.** [Connect a store](stores.md).
1038
+ - **React Query, SWR or RTK Query.** [Query libraries](query-libraries.md).
1039
+ - **RxJS.** [Use nanoeffects next to RxJS](migrating-from-rxjs.md) and [Observables](observable.md).
1040
+ - **Moving from redux-saga.** [Coming from redux-saga](migrating-from-saga.md).
1041
+ - **Moving from Effector.** [Coming from Effector](migrating-from-effector.md).
1042
+ - **Moving from mitt or nanoevents.** [Coming from mitt](migrating-from-mitt.md).
1043
+
1044
+ ## Classes and TypeScript
1045
+
1046
+ ### Use the bus in a class
1047
+
1048
+ Your logic lives in classes, and each instance must remove its subscriptions when it is no longer needed. `Emitter` gives every instance its own bus and a `dispose` that removes all of its subscriptions.
1049
+
1050
+ ```js
1051
+ import { Emitter } from "nanoeffects/oop";
1052
+
1053
+ // Emitter — every instance gets its own bus with on, once, off, emit and dispose
1054
+ class VideoService extends Emitter {
1055
+ constructor() {
1056
+ super();
1057
+ this.on("played", () => console.log("playing"));
1058
+ }
1059
+ }
1060
+
1061
+ const video = new VideoService();
1062
+ video.emit("played");
1063
+
1064
+ video.dispose(); // removes every subscription of this instance
1065
+ ```
1066
+
1067
+ More: [Use the bus from a class](oop.md).
1068
+
1069
+ ### Keep the types of the bus in many files
1070
+
1071
+ Handlers and modules are in different files, and TypeScript must know the payloads and the fields of `ctx`. See [TypeScript in many files](typescript.md).
1072
+
1073
+ ## Examples of whole apps
1074
+
1075
+ - **A form with a username check and a submit, in React.** [Build a sign-up form in React](react.md).
1076
+ - **A whole app in layers: entities, features, modules, a server.** [Build a chat](chat-example.md).
1077
+ - **A widget that listens only while it is open.** [Build a chat widget](chat-widget.md).
1078
+ - **Vue.** [Use nanoeffects in a Vue app](vue.md).
1079
+ - **Svelte.** [Use nanoeffects in a Svelte app](svelte.md).
1080
+ - **A ready project.** [Start a project from a template](starter.md).
1081
+
1082
+ ---
1083
+ Source: docs/starter.md
1084
+
1085
+ # Start a project from a template
1086
+
1087
+ > **Level A.** Read first: nothing: this page makes a project for you.
1088
+
1089
+ Use this page when you want a running project in one command instead of wiring Vite and the bus by hand. It makes a small app with a search box that waits for you to stop typing (`debounce`) and aborts the older request when a newer one starts (`latest`).
1090
+
1091
+ You need Node 18 or newer.
1092
+
1093
+ ## Create the project
1094
+
1095
+ Run the command for your package manager. Replace `my-app` with the name of the new directory.
1096
+
1097
+ ```bash
1098
+ npm create nanoeffects@latest my-app
1099
+ ```
1100
+
1101
+ ```bash
1102
+ pnpm create nanoeffects my-app
1103
+ ```
1104
+
1105
+ ```bash
1106
+ yarn create nanoeffects my-app
1107
+ ```
1108
+
1109
+ ```bash
1110
+ bun create nanoeffects my-app
1111
+ ```
1112
+
1113
+ The command asks for the template when you do not give one. The directory must be new or empty: the command stops instead of overwriting files.
1114
+
1115
+ Then follow the steps it prints:
1116
+
1117
+ ```bash
1118
+ cd my-app
1119
+ npm install
1120
+ npm run dev
1121
+ ```
1122
+
1123
+ Open the address that Vite prints and type in the box. The fake server answers after 800 ms, so you can see the older request being aborted.
1124
+
1125
+ ## Choose a template
1126
+
1127
+ Pass `--template` to skip the question. With npm, put `--` before the option.
1128
+
1129
+ | Template | What it shows | Use it when |
1130
+ | -------- | ------------- | ----------- |
1131
+ | `vanilla` | A bus, one event and a `debounce` plus `latest` handler, with plain DOM code | You use no framework, or want to see the bus alone |
1132
+ | `react` | The same handler with `useEmitter` and `useEventState`, and `usePolicy` for a debounced save in a component | You build a React app |
1133
+ | `vanilla-ts` | The `vanilla` app in TypeScript | You want the compiler to check events and handlers |
1134
+ | `react-ts` | The `react` app in TypeScript | You build a React app in TypeScript |
1135
+ | `domains-ts` | Actions with handlers, a job and modules, with the logic split into domain folders and React as a thin layer | You build an app with more than one feature and want the logic off the UI |
1136
+ | `vue-ts` | The `react-ts` app in Vue 3, with `watchEmitter` and `useEventRef` from `nanoeffects/vue` | You build a Vue 3 app |
1137
+ | `svelte-ts` | The `react-ts` app in Svelte 5, with `eventStore` from `nanoeffects/svelte` | You build a Svelte app |
1138
+
1139
+ ```bash
1140
+ npm create nanoeffects@latest my-app -- --template react
1141
+ ```
1142
+
1143
+ In the `react` templates, `useEventState` keeps the found items in the component. It is not a global store: another component gets its own copy. To share a value between components, use a state manager and see [Stores](stores.md).
1144
+
1145
+ The TypeScript templates are the same apps with the same files, written in `.ts` and `.tsx`. Events are made with `createAction<Payload>`, so handlers and `useEventState` get their types from the events, with no annotation in the component. `npm run build` type-checks the project (`tsc --noEmit`) before bundling it.
1146
+
1147
+ ```bash
1148
+ npm create nanoeffects@latest my-app -- --template react-ts
1149
+ ```
1150
+
1151
+ ### Start with Vue
1152
+
1153
+ `vue-ts` is the search app of `react-ts` in Vue 3 with `<script setup lang="ts">`. Its bus uses `vuePlugin` from `nanoeffects/vue`: `watchEmitter` sends the query to the bus when it changes, and `useEventRef` keeps the result of the search in a `ref`. Like `useEventState` in React, `useEventRef` is [not a store](vue.md#it-is-not-a-store): a value that several components share belongs in a store such as Pinia.
1154
+
1155
+ ```bash
1156
+ npm create nanoeffects@latest my-app -- --template vue-ts
1157
+ ```
1158
+
1159
+ `npm run build` type-checks the `.vue` files with `vue-tsc`, which needs TypeScript 5: the template depends on it.
1160
+
1161
+ ### Start with Svelte
1162
+
1163
+ `svelte-ts` is the search app of `react-ts` in Svelte 5. Its bus uses `sveltePlugin` from `nanoeffects/svelte`: `eventStore` makes a Svelte store from an event. In a component, `$found` reads it, and Svelte subscribes and unsubscribes for you. The query goes to the bus from the `oninput` handler, so nothing is emitted when the component mounts. The store listens only while something reads it, and is [not a store of the app](svelte.md#it-is-not-a-store-of-the-app): a value that several components share belongs in a store of your own.
1164
+
1165
+ ```bash
1166
+ npm create nanoeffects@latest my-app -- --template svelte-ts
1167
+ ```
1168
+
1169
+ `npm run build` type-checks the `.svelte` files with `svelte-check`, which needs TypeScript 5: the template depends on it.
1170
+
1171
+ ### Start with domains
1172
+
1173
+ `domains-ts` is not the search box of the other templates grown in TypeScript: it shows where logic goes when an app has several features. Each domain is a folder under `src/domains/`. It has the events of the domain, the work that handles them, and a module that mounts the work. The React components only emit events and read values.
1174
+
1175
+ ```bash
1176
+ npm create nanoeffects@latest my-app -- --template domains-ts
1177
+ ```
1178
+
1179
+ | Path | Job |
1180
+ | ---- | --- |
1181
+ | `src/bus.ts` | Creates the bus. It imports no events and no domain. |
1182
+ | `src/app.ts` | Mounts the domains with `modulePlugin`. |
1183
+ | `src/domains/catalog/` | Searching products: `catalog.ts` (events and an action with a handler), `catalog-handlers.ts` (`debounce` and `latest`), `catalog-module.ts`. |
1184
+ | `src/domains/checkout/` | Placing an order: `checkout.ts` (a job), `checkout-handlers.ts` (`leading` and `retry`), `checkout-store.ts` (state written from the job's outcomes), `checkout-module.ts`. |
1185
+ | `src/ui/` | The components. None of them calls the fake server in `src/api.ts`. |
1186
+
1187
+ For a bigger example, a chat in which parts react to each other, see [Build a chat with entities, features, modules and a small server](chat-example.md). It has more layers than `domains-ts`: when domains must react to each other, it keeps them from importing each other by splitting them into entities and features.
1188
+
1189
+ The actions, jobs and modules themselves are explained in [Actions, jobs and modules](actions.md). The template keeps found products in a Context provider with one `useEventState`, and the order state in a store of its own: `useEventState` is [not a store](react.md#show-the-last-value-of-an-event-as-state).
1190
+
1191
+ ## Read the files
1192
+
1193
+ Every template except `domains-ts` splits the code the way [the guide](guide.md) does: one job per file. In the TypeScript templates the files end in `.ts` (and `.tsx` for React components, `.vue` for Vue components, `.svelte` for Svelte components).
1194
+
1195
+ | File | Job |
1196
+ | ---- | --- |
1197
+ | `src/bus.js` | Creates the bus. It imports no events. |
1198
+ | `src/search.js` | Names the events with `createAction`. It imports no bus. |
1199
+ | `src/search-handlers.js` | Handles the events with `debounce` and `latest`. It knows the bus and the events. |
1200
+ | `src/api.js` | A fake server. Replace it with your own requests. |
1201
+
1202
+ Every call of nanoeffects in these files has a comment that says what it does. To make it your own, replace `src/api.js`, then rename the events and the handler.
1203
+
1204
+ ## Next steps
1205
+
1206
+ - [Policies](policies.md): `leading`, `throttle` and `queue` next to `latest`.
1207
+ - [Build a sign-up form in React](react.md): the same ideas in a bigger React example.
1208
+ - [Package entries](package.md): which import holds what.
1209
+
1210
+ ---
1211
+ Source: docs/motivation.md
1212
+
1213
+ # Why nanoeffects exists
1214
+
1215
+ > **Level A.** Read first: [README](../README.md).
1216
+
1217
+ Read this page to see why the library exists. It moves business logic and side effects out of React, Vue or Svelte components into plain `async` functions, with cancellation, retries and a context for dependencies. It does not teach the API: start with the [Guide](guide.md).
1218
+
1219
+ ## Moving logic out of the UI
1220
+
1221
+ Components that hold requests, timers and races are hard to test and easy to break. The usual cure is services and pure functions outside the UI, and then every service needs the same things: to cancel work that is no longer wanted, to retry, to share dependencies, and to show what ran and why.
1222
+
1223
+ The common tools ask a lot: redux-saga writes this logic as generators, and RxJS as streams and operators. nanoeffects keeps ordinary `async` functions and adds one idea, the `AbortSignal`.
1224
+
1225
+ Another way to take logic out of the UI is a dependency injection (DI) container, such as the one in Angular or in any DI library. nanoeffects has no container, but it can do dependency injection at the level of the contract: a handler takes what it needs from `ctx`, and the bus decides what to give. See [Context fields](context.md).
1226
+
1227
+ The event bus is the heart of the library but not its value: any emitter can send events (`nanoevents` is about 100 bytes, and there are [dozens more](https://github.com/gajus/sister#similar-libraries)). What nanoeffects adds is cancellable tasks, policies and effects, a context that works as a small container for dependencies, actions and modules to group a feature, and [tracing](tracing.md) with an adapter for Redux DevTools. It also fits what a project already has: recipes and small adapters for [stores](stores.md) (Redux Toolkit, Zustand, MobX, Pinia), an [RxJS](migrating-from-rxjs.md) bridge, and helpers for [React](react.md), [Vue](vue.md) and [Svelte](svelte.md), so nothing has to be rewritten to start.
1228
+
1229
+ ## Micro-frontends
1230
+
1231
+ In micro-frontends every app can bring its own versions of the same libraries. 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. Different versions and copies also do more than work out of step: they load more code and use more resources than one agreed copy would. So the goal is a project that is as simple as possible, with as few shared dependencies that hold state as possible.
1232
+
1233
+ 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.
1234
+
1235
+ Real cases from the libraries nanoeffects was compared with:
1236
+
1237
+ | Library | What goes wrong | Source |
1238
+ | ------- | --------------- | ------ |
1239
+ | 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) |
1240
+ | 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) |
1241
+ | 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) |
1242
+ | 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) |
1243
+
1244
+ 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.
1245
+
1246
+ ## What the library does
1247
+
1248
+ - **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.
1249
+ - **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 actions are separate entries you import only when you need them. See [Package entries](package.md).
1250
+ - **Easy to start.** `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.
1251
+ - **`emit` can return an answer.** An [action with a handler](actions.md) 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.
1252
+ - **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.
1253
+
1254
+ ---
1255
+ Source: docs/package.md
1256
+
1257
+ # Choose a package entry
1258
+
1259
+ > **Levels A to F.** Read first: [README](../README.md).
1260
+
1261
+ Imports are split so you only load what you use. `createBus` stays small; the rest is another entry.
1262
+
1263
+ ## Start with these three
1264
+
1265
+ | Import | What is in it |
1266
+ | ------ | ------------- |
1267
+ | `nanoeffects` | `createBus`, the types |
1268
+ | `nanoeffects/policies` | `latest`, `leading`, `debounce`, `throttle`, `queue` |
1269
+ | `nanoeffects/effects` | `retry`, `timeout`, `poll`, `delay`, `take`, `all`, `allSettled`, `race`, `effectsPlugin` |
1270
+
1271
+ ## Add the rest when you need it
1272
+
1273
+ Each row is optional. The "when" column says what need brings you to it.
1274
+
1275
+ | Import | What is in it | When |
1276
+ | ------ | ------------- | ---- |
1277
+ | `nanoeffects/actions` | `createAction`, `createJob`, `createModule`, `modulePlugin`, `actionsPlugin` | `emit` must return a result, or domain logic leaves the UI |
1278
+ | `nanoeffects/match` | `matchPlugin`: `on`, `once` and `off` that take a function which picks the events | `on` must take a function that picks events |
1279
+ | `nanoeffects/context` | `contextPlugin`: your own fields (a store, a client) on `ctx` of every task, typed from the value | a store or client must be on every task |
1280
+ | `nanoeffects/signal` | `signalPlugin`: `emit(call, { signal })` ties the tasks of an emit to an outside `AbortSignal` | an outside `AbortSignal` (request, React Query) must stop tasks |
1281
+ | `nanoeffects/oop` | `Emitter`, `extendBus`, `WithBus`, `On`, `Once`, `disposeBus`: the methods of a bus on a class, with the subscriptions of each instance ended by `dispose()` | you write classes, not functions |
1282
+ | `nanoeffects/scope` | `scopePlugin`, `effect`: fields for one action, job, module or `emit` (the types `Scope`, `Effect`) | one action or module needs its own fields |
1283
+ | `nanoeffects/redux` | `createEffectsMiddleware` | you already use Redux |
1284
+ | `nanoeffects/observable` | `createObserve`, `Subject` | you need an Observable (RxJS) |
1285
+ | `nanoeffects/react` | `reactPlugin`: adds the `useEmitter` hook to a bus (needs React 18 or newer) | React components |
1286
+ | `nanoeffects/svelte` | `sveltePlugin`: adds `eventStore` to a bus (needs Svelte 5 or newer) | Svelte components |
1287
+ | `nanoeffects/vue` | `vuePlugin`: adds `watchEmitter` and `useEventRef` to a bus (needs Vue 3.3 or newer) | Vue components |
1288
+ | `nanoeffects/dev` | `devPlugin` (no-op in production) | you look for leaks in development |
1289
+ | `nanoeffects/trace` | `createTracer`, the loggers (`graphLogger` finds event loops) and `tracePlugin` (names for the trace) | you want to see what ran and why |
1290
+ | `nanoeffects/guard` | `loopGuardPlugin`: refuses the emit that would pass a limit of a chain | a loop must not take the app down |
1291
+
1292
+ The `...Plugin` names above are used with `bus.use`. [Plugins](plugins.md) lists all of them, what each returns, and the order to use them in.
1293
+
1294
+ ## Give the docs to an AI assistant
1295
+
1296
+ The package ships plain-text files for assistants that write code with it:
1297
+
1298
+ - `llms.txt`: a short index. It states the rules that generated code often gets wrong and links to every page of these docs.
1299
+ - `llms-full.txt`: the same summary followed by the text of every linked page, in one file.
1300
+ - `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/`.
1301
+
1302
+ 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.
1303
+
1304
+ ---
1305
+ Source: docs/plugins.md
1306
+
1307
+ # Choose the plugins of a bus
1308
+
1309
+ > **Level F.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 and 2.
1310
+
1311
+ Optional. A plugin adds one thing to a bus: fields on `ctx`, a method, a registry of handlers, diagnostics. Read this page when you want to know which plugins exist, what each one gives you, and in what order to use them. Skip it for a small bus: `createBus()` alone is enough.
1312
+
1313
+ ## On this page
1314
+
1315
+ - [Use a plugin](#use-a-plugin)
1316
+ - [Pick a plugin by task](#pick-a-plugin-by-task)
1317
+ - [Main plugins](#main-plugins)
1318
+ - [Put a store or a client on every handler: `contextPlugin`](#put-a-store-or-a-client-on-every-handler-contextplugin)
1319
+ - [Use `race` and `delay` without `ctx`: `effectsPlugin`](#use-race-and-delay-without-ctx-effectsplugin)
1320
+ - [Bind or replace a handler: `actionsPlugin`](#bind-or-replace-a-handler-actionsplugin)
1321
+ - [Mount a domain as one unit: `modulePlugin`](#mount-a-domain-as-one-unit-moduleplugin)
1322
+ - [Extra plugins](#extra-plugins)
1323
+ - [Give one action its own fields: `scopePlugin`](#give-one-action-its-own-fields-scopeplugin)
1324
+ - [Tie an emit or a listener to an outside signal: `signalPlugin`](#tie-an-emit-or-a-listener-to-an-outside-signal-signalplugin)
1325
+ - [Name the origin and the flow of a trace: `tracePlugin`](#name-the-origin-and-the-flow-of-a-trace-traceplugin)
1326
+ - [Listen to a group of events: `matchPlugin`](#listen-to-a-group-of-events-matchplugin)
1327
+ - [Hand the bus to a state manager: `emitterPlugin`](#hand-the-bus-to-a-state-manager-emitterplugin)
1328
+ - [Tell the bus when a React value changes: `reactPlugin`](#tell-the-bus-when-a-react-value-changes-reactplugin)
1329
+ - [Find leaks while you develop: `devPlugin`](#find-leaks-while-you-develop-devplugin)
1330
+ - [Cut a loop that runs away: `loopGuardPlugin`](#cut-a-loop-that-runs-away-loopguardplugin)
1331
+ - [Put them together](#put-them-together)
1332
+ - [Use them in any order](#use-them-in-any-order)
1333
+ - [See what is not a plugin](#see-what-is-not-a-plugin)
1334
+ - [Write your own plugin](#write-your-own-plugin)
1335
+
1336
+ ## Use a plugin
1337
+
1338
+ A plugin is a function of the bus. `bus.use(plugin)` calls it with the bus and returns whatever the plugin returns. Most plugins return the same bus, so you can chain them: `createBus().use(a).use(b)`. A few return something of their own (a registry, an app, a `disconnect` function): keep it.
1339
+
1340
+ In TypeScript keep the result of `contextPlugin` and `effectsPlugin`: the type of the bus with its new fields is the type they return (`const bus = createBus().use(contextPlugin({ store }))`).
1341
+
1342
+ ## Pick a plugin by task
1343
+
1344
+ Find your need in the left column. No row fits: you need no plugin.
1345
+
1346
+ | I need | Use |
1347
+ | ------ | --- |
1348
+ | A store, client or logger on every handler | [`contextPlugin`](#put-a-store-or-a-client-on-every-handler-contextplugin) |
1349
+ | `race`, `all` or `delay` without passing `ctx` | [`effectsPlugin`](#use-race-and-delay-without-ctx-effectsplugin) |
1350
+ | A test that replaces a handler | [`actionsPlugin`](#bind-or-replace-a-handler-actionsplugin) |
1351
+ | One domain mounted and unmounted as a unit | [`modulePlugin`](#mount-a-domain-as-one-unit-moduleplugin) |
1352
+ | Fields for one action or module only | [`scopePlugin`](#give-one-action-its-own-fields-scopeplugin) |
1353
+ | Tasks that stop when a request, socket or query is cancelled | [`signalPlugin`](#tie-an-emit-or-a-listener-to-an-outside-signal-signalplugin) |
1354
+ | One listener for a group of events | [`matchPlugin`](#listen-to-a-group-of-events-matchplugin) |
1355
+ | A callback for a state manager that emits | [`emitterPlugin`](#hand-the-bus-to-a-state-manager-emitterplugin) |
1356
+ | A React component that sends its value to the bus | [`reactPlugin`](#tell-the-bus-when-a-react-value-changes-reactplugin) |
1357
+ | Names in the trace | [`tracePlugin`](#name-the-origin-and-the-flow-of-a-trace-traceplugin) |
1358
+ | Warnings about leaks while you develop | [`devPlugin`](#find-leaks-while-you-develop-devplugin) |
1359
+ | A loop that must not take the app down | [`loopGuardPlugin`](#cut-a-loop-that-runs-away-loopguardplugin) |
1360
+
1361
+ ## Main plugins
1362
+
1363
+ These four cover what most apps need: shared dependencies, effects, handlers and modules. Start here.
840
1364
 
841
1365
  | Plugin | What it gives you | Import | `bus.use` returns |
842
1366
  | ------ | ----------------- | ------ | ----------------- |
843
1367
  | [`contextPlugin`](#put-a-store-or-a-client-on-every-handler-contextplugin) | Your own fields (a store, a client, a logger) on `ctx` of every handler. | `nanoeffects` | The same bus, typed with the fields |
844
1368
  | [`effectsPlugin`](#use-race-and-delay-without-ctx-effectsplugin) | `race`, `all`, `delay` and your own effects on `ctx`, called without passing `ctx`. | `nanoeffects/effects` | The same bus, typed with the effects |
845
- | [`actionsPlugin`](#let-a-test-replace-a-handler-commandsplugin) | The handlers of actions in a registry, so a test can bind or replace one. | `nanoeffects/actions` | The registry `{ run, handle }` |
1369
+ | [`actionsPlugin`](#bind-or-replace-a-handler-actionsplugin) | The handlers of actions in a registry, so a test can bind or replace one. | `nanoeffects/actions` | The registry `{ run, handle }` |
846
1370
  | [`modulePlugin`](#mount-a-domain-as-one-unit-moduleplugin) | A module: the listeners, handlers and tasks of one domain, mounted and unmounted with one call. | `nanoeffects/actions` | The app `{ mount, unmount, isMounted, dispose, actions, createModule }` |
847
1371
 
848
1372
  ### Put a store or a client on every handler: `contextPlugin`
@@ -859,9 +1383,11 @@ const bus = createBus().use(contextPlugin({ store }));
859
1383
  bus.on("save", (draft, { store }) => store.put(draft));
860
1384
  ```
861
1385
 
1386
+ A client that must stop with the task, such as an `axios` instance, goes in as `fromSignal((signal) => axios.create({ signal }))` from `nanoeffects/signal`: it is built from the signal of each task, when the task reads it. More in [Build a field from the signal of each task](context.md#build-a-field-from-the-signal-of-each-task).
1387
+
862
1388
  ### Use `race` and `delay` without `ctx`: `effectsPlugin`
863
1389
 
864
- Effects such as `race`, `all`, `take` and `delay` are bound to the task, so a handler calls `race({ … })` and not `race(ctx, { … })`. Your own functions that take `{ signal }` first work too. More in [Effects](effects.md#put-selected-effects-on-ctx).
1390
+ Effects such as `race`, `all`, `take` and `delay` are bound to the task, so a handler calls `race({ … })` and not `race(ctx, { … })`. Your own functions that take `{ signal }` first work too. More in [Effects](effects.md#effectsplugin).
865
1391
 
866
1392
  ```js
867
1393
  import { createBus } from "nanoeffects";
@@ -874,7 +1400,7 @@ const bus = createBus().use(effects);
874
1400
 
875
1401
  bus.on("load", async (id, { race, delay }) => {
876
1402
  const result = await race({
877
- data: ({ signal }) => api.get(id, { signal }),
1403
+ data: (child) => api.get(id, child),
878
1404
  timeout: () => delay(5000),
879
1405
  });
880
1406
  });
@@ -900,7 +1426,7 @@ const user = await bus.emit(getUser("1")); // { id: "1" }
900
1426
 
901
1427
  ### Mount a domain as one unit: `modulePlugin`
902
1428
 
903
- A module groups the listeners, the bound handlers and the background tasks of one domain, and ends all of them when it is unmounted. It also makes [hot reload](hmr.md) work. More in [Modules](actions.md#mount-domains-with-modules).
1429
+ A module groups the listeners, the bound handlers and the background tasks of one domain, and ends all of them when it is unmounted. It also makes [hot reload](hmr.md) work. More in [Modules](actions.md#modules).
904
1430
 
905
1431
  ```js
906
1432
  import { createBus } from "nanoeffects";
@@ -911,7 +1437,7 @@ const bus = createBus();
911
1437
  // modulePlugin — mounts userModule at the end of this block, or at the first use if that is earlier, and returns the app
912
1438
  const app = bus.use(modulePlugin([userModule]));
913
1439
 
914
- app.unmount(userModule); // its listeners, bound commands and tasks are gone
1440
+ app.unmount(userModule); // its listeners, bound actions and tasks are gone
915
1441
  ```
916
1442
 
917
1443
  ## Extra plugins
@@ -921,26 +1447,27 @@ Add one of these when you meet its need. None of them is required.
921
1447
  | Plugin | What it gives you | Import | `bus.use` returns |
922
1448
  | ------ | ----------------- | ------ | ----------------- |
923
1449
  | [`scopePlugin`](#give-one-action-its-own-fields-scopeplugin) | Its own fields for one action, job or module, and a way to replace them in one `emit`. | `nanoeffects/scope` | The same bus |
924
- | [`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` |
1450
+ | [`signalPlugin`](#tie-an-emit-or-a-listener-to-an-outside-signal-signalplugin) | `emit(call, { signal })` that aborts its tasks, and `on(event, handler, { signal })` that removes the listener, when an outside signal aborts. | `nanoeffects/signal` | The same bus, typed with the new `emit` |
925
1451
  | [`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 |
926
1452
  | [`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` |
927
1453
  | [`tracePlugin`](#name-the-origin-and-the-flow-of-a-trace-traceplugin) | `traceId` and `traceGroupId` for a trace: the root of an emitter, a flow, a named listener. | `nanoeffects/trace` | The same bus |
928
1454
  | [`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` |
929
1455
  | [`devPlugin`](#find-leaks-while-you-develop-devplugin) | Warnings about listeners that pile up, and messages about modules, while you develop. | `nanoeffects/dev` | A function that disconnects it |
1456
+ | [`loopGuardPlugin`](#cut-a-loop-that-runs-away-loopguardplugin) | Refuses the emit that would pass a limit of a chain, so a loop ends. | `nanoeffects/guard` | The same bus |
930
1457
 
931
1458
  ### Give one action its own fields: `scopePlugin`
932
1459
 
933
- An action with a handler, an action, a job or a module can carry its own fields and effects, and one `emit` can replace them. A test gives a fake to one call. More in [Scopes](scope.md).
1460
+ An action with a handler, an action, a job or a module can carry its own fields and effects, and one `emit` can replace them. A test gives a fake to one call. More in [Scopes](context.md#scope-fields-for-an-action-a-module-and-a-call).
934
1461
 
935
1462
  ```js
936
1463
  import { createBus } from "nanoeffects";
937
1464
  import { createAction } from "nanoeffects/actions";
938
1465
  import { scopePlugin } from "nanoeffects/scope";
939
1466
 
940
- // scopePlugin — makes the scope of a command work on this bus
1467
+ // scopePlugin — makes the scope of an action work on this bus
941
1468
  const bus = createBus().use(scopePlugin());
942
1469
 
943
- // the last argument: fields of this command only
1470
+ // the last argument: fields of this action only
944
1471
  const getUser = createAction("user/get", (id, { api }) => api.getUser(id), {
945
1472
  api,
946
1473
  });
@@ -949,25 +1476,41 @@ const fakeApi = { getUser: async (id) => ({ id, name: "test" }) };
949
1476
  bus.emit(getUser("1"), { api: fakeApi }); // a fake api for this call only
950
1477
  ```
951
1478
 
952
- ### Tie an emit to an outside signal: `signalPlugin`
1479
+ ### Tie an emit or a listener to an outside signal: `signalPlugin`
953
1480
 
954
- 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).
1481
+ 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, SWR and RTK Query](query-libraries.md).
955
1482
 
956
1483
  ```js
957
1484
  import { createBus } from "nanoeffects";
958
1485
  import { createAction } from "nanoeffects/actions";
959
1486
  import { signalPlugin } from "nanoeffects/signal";
960
1487
 
961
- // signalPlugin — emit takes { signal } for a command, a job or an action
1488
+ // signalPlugin — emit takes { signal } for an action or a job
962
1489
  const bus = createBus().use(signalPlugin());
963
1490
 
964
1491
  const getUser = createAction("user/get", (id, { signal }) => api.getUser(id, { signal }));
965
1492
 
966
1493
  const controller = new AbortController();
967
- bus.emit(getUser("1"), { signal: controller.signal }); // runs the command
1494
+ bus.emit(getUser("1"), { signal: controller.signal }); // runs the action
968
1495
  controller.abort(); // ctx.signal of the handler aborts with it
969
1496
  ```
970
1497
 
1498
+ The same signal can end a listener. `on` and `once` take `{ signal }` as their options, as `addEventListener` does, so code that has a signal keeps no `unbind` function. The listener is removed when the signal aborts; a handler that already runs is not aborted. A signal that is aborted already adds no listener.
1499
+
1500
+ ```js
1501
+ import { createBus } from "nanoeffects";
1502
+ import { signalPlugin } from "nanoeffects/signal";
1503
+
1504
+ // signalPlugin — on and once take { signal } in their options
1505
+ const bus = createBus().use(signalPlugin());
1506
+
1507
+ const controller = new AbortController();
1508
+ bus.on("saved", (draft) => console.log(draft), { signal: controller.signal }); // listens until the abort
1509
+ controller.abort(); // the listener is gone: bus.get("saved") is empty
1510
+ ```
1511
+
1512
+ Without the plugin the `signal` option is ignored and the listener stays. `off(event, handler)` removes a listener but does not end its link to the signal; call the function that `on` returned for that.
1513
+
971
1514
  ### Name the origin and the flow of a trace: `tracePlugin`
972
1515
 
973
1516
  With a tracer on the bus, the plugin lets a trace say which subscription began a chain (`traceId` of `useEmitter` and `bus.emitter`), follow one flow by a `traceGroupId`, and show which listener ran (`bus.on(event, handler, { traceId })`). Without the plugin the options are ignored. More in [Tracing](tracing.md#show-which-subscription-began-a-chain).
@@ -1059,6 +1602,18 @@ const disconnect = bus.use(devPlugin());
1059
1602
  disconnect(); // stops the diagnostics
1060
1603
  ```
1061
1604
 
1605
+ ### Cut a loop that runs away: `loopGuardPlugin`
1606
+
1607
+ Two handlers that emit each other's events never stop, and a loop of microtasks freezes the page. The plugin follows the chain of emits and refuses the emit that would repeat one event too often or make the chain too long. It needs no tracer, so it can stay on in production. More in [Cut a loop](loop-guard.md).
1608
+
1609
+ ```js
1610
+ import { createBus } from "nanoeffects";
1611
+ import { loopGuardPlugin } from "nanoeffects/guard";
1612
+
1613
+ // loopGuardPlugin — refuses the emit that would put one event in a chain 50 times
1614
+ const bus = createBus().use(loopGuardPlugin({ maxRepeats: 50 }));
1615
+ ```
1616
+
1062
1617
  ## Put them together
1063
1618
 
1064
1619
  One bus with every plugin that does not need React, in a file of its own. The events of the app stay in other files. The plugins work in any order; the next section says what that means for modules.
@@ -1080,7 +1635,7 @@ import { scopePlugin } from "nanoeffects/scope";
1080
1635
  export const bus = createBus()
1081
1636
  .use(contextPlugin({ store })) // ctx.store on every task
1082
1637
  .use(effectsPlugin({ race, all })) // ctx.race and ctx.all, bound to the task
1083
- .use(scopePlugin()) // scopes for commands, actions, jobs, modules and emit
1638
+ .use(scopePlugin()) // scopes for actions, jobs, modules and emit
1084
1639
  .use(matchPlugin()) // bus.on takes a function that picks the events
1085
1640
  .use(emitterPlugin()); // bus.emitter(creator)
1086
1641
 
@@ -1121,7 +1676,7 @@ These connect the bus to something, but are not used with `bus.use`:
1121
1676
  - `createEffectsMiddleware(bus, { filter })` from `nanoeffects/redux` is a Redux middleware for a bus that exists. See [Stores](stores.md#wire-redux-toolkit-to-a-bus).
1122
1677
  - `createBus({ trace })` takes a tracer from `nanoeffects/trace`. See [Tracing](tracing.md).
1123
1678
  - `extendBus(bus, Base)` from `nanoeffects/oop` gives a class the methods of a bus, with the subscriptions of each instance ended by `dispose()`. It reads the bus as it is, so use the plugins first. See [Use the bus from a class](oop.md).
1124
- - `createObserve(bus)` from `nanoeffects/observable` turns an event into an Observable. See [Observable](observable.md).
1679
+ - `createObserve(bus)` from `nanoeffects/observable` turns an event into an Observable, and `events(bus, event)` into a `for await` loop. See [Observable](observable.md).
1125
1680
 
1126
1681
  ## Write your own plugin
1127
1682
 
@@ -1157,11 +1712,13 @@ Source: docs/policies.md
1157
1712
 
1158
1713
  # Policies
1159
1714
 
1715
+ > **Level B.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 to 3.
1716
+
1160
1717
  Use this page when handlers overlap in a SPA (search-as-you-type, double submit, leave page). A **policy** wraps a handler and decides what happens then.
1161
1718
 
1162
1719
  Start with `latest`. The table and sections below are the rest of the reference.
1163
1720
 
1164
- ## Abort the previous run
1721
+ ## `latest`: abort the previous run
1165
1722
 
1166
1723
  ```js
1167
1724
  import { createBus } from "nanoeffects";
@@ -1183,7 +1740,7 @@ Pass `signal` to `fetch` (and the like) so the request cancels.
1183
1740
 
1184
1741
  ## Choose a policy
1185
1742
 
1186
- Every policy accepts `key` in its options (see [Isolate state by key](#isolate-state-by-key)).
1743
+ Every policy accepts `key` in its options (see [`key`: isolate state by key](#key-isolate-state-by-key)).
1187
1744
 
1188
1745
  | Policy | Overlapping calls |
1189
1746
  | ------ | ----------------- |
@@ -1193,54 +1750,79 @@ Every policy accepts `key` in its options (see [Isolate state by key](#isolate-s
1193
1750
  | `throttle(ms, fn, options?)` | at most one run per `ms` |
1194
1751
  | `queue(fn, options?)` | one after another, in order |
1195
1752
 
1196
- ## Call a policy without its function
1753
+ Each policy has a short entry in [Look up a policy](#look-up-a-policy).
1197
1754
 
1198
- **Problem.** Some code builds the function for you and only asks which policy to use, as [`usePolicy`](react.md#keep-a-debounced-handler-between-renders) does. If you must pass your own function there, it is easy to pass the wrong one.
1755
+ ## Look up a policy
1199
1756
 
1200
- **When.** You pass a policy to `usePolicy`. With a bus, `withPolicy` or your own code, call the policy with its function as above.
1757
+ One entry per policy: what it does, and a link to the full recipe.
1201
1758
 
1202
- Leave the function out and the policy returns a wrapper. The wrapper takes a function and gives back the same policy as the direct call:
1759
+ ### `latest`
1203
1760
 
1204
- ```js
1205
- import { debounce } from "nanoeffects/policies";
1761
+ A newer call aborts the older one and drops its result.
1206
1762
 
1207
- const runSearch = async (query) => {
1208
- await api.search(query);
1209
- };
1763
+ Signature: `latest(fn, options?)`.
1210
1764
 
1211
- // debounce(300) — no function yet: returns a wrapper
1212
- const wrap = debounce(300);
1765
+ More: [`latest`: abort the previous run](#latest-abort-the-previous-run).
1213
1766
 
1214
- // the wrapper takes the function; same as debounce(300, runSearch)
1215
- const search = wrap(runSearch);
1216
- ```
1767
+ ### `leading`
1217
1768
 
1218
- - It works for `latest()`, `leading()`, `debounce(ms)`, `throttle(ms)` and `queue()`, with the options as the last argument: `queue({ concurrency: 2 })`, `debounce(300, { leading: true })`.
1219
- - Each time you apply a wrapper to a function, you get a new policy with its own state.
1220
- - To combine policies, nest them as before: `throttle(100, latest(fn))`.
1221
- - With `key`, annotate the payload of the key function in TypeScript: `latest({ key: (user: User) => user.id })`. Without the annotation the wrapper cannot know the payload type.
1769
+ New calls are dropped while a run is going.
1770
+
1771
+ Signature: `leading(fn, options?)`.
1772
+
1773
+ More: [Prevent a double submit](prevent-double-submit.md).
1774
+
1775
+ ### `debounce`
1776
+
1777
+ Runs after a period of quiet.
1778
+
1779
+ Signature: `debounce(ms, fn, options?)`.
1780
+
1781
+ More: [Configure debounce and throttle](#configure-debounce-and-throttle).
1782
+
1783
+ ### `throttle`
1784
+
1785
+ At most one run within `ms`.
1786
+
1787
+ Signature: `throttle(ms, fn, options?)`.
1788
+
1789
+ More: [Configure debounce and throttle](#configure-debounce-and-throttle).
1790
+
1791
+ ### `queue`
1792
+
1793
+ Builds a queue of calls: they run one after another, in order.
1794
+
1795
+ Signature: `queue(fn, options?)`.
1796
+
1797
+ More: [Run calls one after another with `queue`](#run-calls-one-after-another-with-queue).
1798
+
1799
+ ### `key`
1800
+
1801
+ An option of every policy. It splits the state of the policy by a property, for example by id: calls with different keys do not affect each other.
1802
+
1803
+ Signature: `latest(fn, { key: (payload) => payload.id })`.
1804
+
1805
+ More: [`key`: isolate state by key](#key-isolate-state-by-key).
1806
+
1807
+ To use a policy for your own function with no bus, see [`withPolicy`: call a policy outside a bus](#withpolicy-call-a-policy-outside-a-bus).
1222
1808
 
1223
1809
  ## Configure debounce and throttle
1224
1810
 
1225
- `debounce` and `throttle` take `leading` and `trailing`. Behavior matches lodash (and patronum); tests compare random call sequences to lodash itself.
1811
+ `debounce` and `throttle` take two options, `leading` and `trailing`. They decide which call of a series runs. `leading` runs the first call of the series at once. `trailing` runs the last call at the end of the series. Both functions are built on the same algorithm as in lodash (and patronum) and behave exactly like them.
1226
1812
 
1227
- **Throttle** (`throttle(ms, fn)`):
1813
+ **Throttle** (`throttle(ms, fn)`) runs a call at once if it has been idle for at least `ms`, holds the calls that come within `ms`, and runs the last of them when the window closes. Under a steady stream it runs every `ms`.
1228
1814
 
1229
- - Default `leading: true`, `trailing: true`.
1230
- - A call when the throttle is idle (at least `ms` after the previous call and run) runs at once.
1231
- - Calls made while it waits are held; the latest runs when the window closes.
1232
- - Under a steady stream it runs every `ms`.
1233
- - `leading: false` — even the first call waits for the end of its window.
1234
- - `trailing: false` — drop calls made during a window.
1815
+ - `leading` — run the first call of a series at once. It is `true` by default. With `false`, even the first call waits for the end of its window.
1816
+ - `trailing` — run the last held call when the window closes. It is `true` by default. With `false`, calls made during a window are dropped.
1235
1817
 
1236
- **Debounce** (`debounce(ms, fn)`):
1818
+ **Debounce** (`debounce(ms, fn)`) runs the last call of a series after `ms` of quiet.
1237
1819
 
1238
- - Default `trailing: true` — run the last call after `ms` of quiet.
1239
- - `leading: true` — also run the first call of a burst at once; the trailing run happens only if more calls follow.
1240
- - `leading: true, trailing: false` — only the first call of a burst.
1820
+ - `trailing` — run the last call after `ms` of quiet. It is `true` by default.
1821
+ - `leading` — also run the first call of a series at once. It is `false` by default. The closing run then happens only if more calls follow.
1822
+ - `leading: true, trailing: false` — only the first call of a series.
1241
1823
  - Both `false` — nothing runs.
1242
1824
 
1243
- ## Isolate state by key
1825
+ ## `key`: isolate state by key
1244
1826
 
1245
1827
  `key` gives each key its own state. Calls with different keys do not affect each other. Idle keys are dropped, so keys can be unbounded (ids, urls).
1246
1828
 
@@ -1299,44 +1881,46 @@ Every call returns a promise with the result of its own run. An error rejects on
1299
1881
  - When a running call aborts, its slot is freed at once and its result is dropped.
1300
1882
  - Pass `signal` into what `fn` does. A `fn` that ignores it can overlap with the next run.
1301
1883
 
1302
- ## Use a policy without a bus
1884
+ ## `withPolicy`: call a policy outside a bus
1303
1885
 
1304
- **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.
1886
+ **Problem.** You want a debounced input handler or a "latest request wins" fetch for your own function, but the code has no bus: a DOM handler, a click callback, a function in a service. A policy needs a context on every call, so `debounce(300, search)("re")` does not work on its own.
1305
1887
 
1306
- **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.
1888
+ **When.** One input, button or function needs a policy and nothing else from the library. If you already have a bus, `bus.on(event, policy)` is simpler. In a React component use [`usePolicy`](react.md#keep-a-debounced-handler-between-renders), which also keeps the function between renders.
1307
1889
 
1308
- `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.
1890
+ `withPolicy` takes a policy and returns a plain function. The function needs no bus and no context argument, and it keeps the state of the policy between calls.
1309
1891
 
1310
1892
  ```js
1311
1893
  import { debounce, withPolicy } from "nanoeffects/policies";
1312
1894
 
1895
+ const input = document.querySelector("#search");
1896
+
1313
1897
  // debounce — waits for 300 ms of quiet, then runs the callback
1314
1898
  const policy = debounce(300, async (query, { signal }) => {
1315
- await api.search(query, { signal });
1899
+ const items = await api.search(query, { signal });
1900
+ render(items);
1316
1901
  });
1317
1902
 
1318
1903
  // withPolicy — a plain function: no bus, no context argument
1319
- const onChange = withPolicy(policy);
1904
+ const onInput = withPolicy(policy);
1320
1905
 
1321
- onChange("re");
1322
- onChange("redux"); // only this call runs, 300 ms after it
1906
+ input.addEventListener("input", (event) => onInput(event.target.value));
1323
1907
 
1324
- // cancel — drops a pending call and aborts a running one
1325
- onChange.cancel();
1908
+ // cancel — drops a pending call and aborts a running request, for example when the page closes
1909
+ window.addEventListener("pagehide", () => onInput.cancel());
1326
1910
  ```
1327
1911
 
1328
1912
  - 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.
1329
1913
  - Every call returns a promise with the result. A call the policy drops resolves with `undefined`.
1330
1914
  - `cancel()` aborts every call that has not finished. Calls made after it work again.
1331
- - Where `Symbol.dispose` exists, `using onChange = withPolicy(policy)` calls `cancel()` at the end of the scope.
1332
-
1333
- In a React component, use [`usePolicy`](react.md#keep-a-debounced-handler-between-renders) instead: it keeps the function between renders.
1915
+ - Where `Symbol.dispose` exists, `using onInput = withPolicy(policy)` calls `cancel()` at the end of the scope.
1334
1916
 
1335
1917
  ---
1336
1918
  Source: docs/cancel-stale-requests.md
1337
1919
 
1338
1920
  # Cancel stale requests and fix race conditions
1339
1921
 
1922
+ > **Level B.** Read first: the bus from the [README](../README.md).
1923
+
1340
1924
  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).
1341
1925
 
1342
1926
  ## See the problem
@@ -1454,7 +2038,9 @@ All policies and their options: [Policies](policies.md). The same step by step i
1454
2038
  ---
1455
2039
  Source: docs/debounce-and-abort.md
1456
2040
 
1457
- # Debounce a request and abort the previous one
2041
+ # Pause between requests
2042
+
2043
+ > **Level B.** Read first: [Policies](policies.md).
1458
2044
 
1459
2045
  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.
1460
2046
 
@@ -1564,6 +2150,8 @@ Source: docs/prevent-double-submit.md
1564
2150
 
1565
2151
  # Prevent a double submit
1566
2152
 
2153
+ > **Level B.** Read first: [Policies](policies.md).
2154
+
1567
2155
  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.
1568
2156
 
1569
2157
  ## See the problem
@@ -1658,7 +2246,7 @@ bus.on(
1658
2246
  );
1659
2247
  ```
1660
2248
 
1661
- Backoff between tries: [Back off between retries](effects.md#back-off-between-retries).
2249
+ Backoff between tries: [`backoff`](effects.md#backoff).
1662
2250
 
1663
2251
  ## Guard a handler when you have no bus
1664
2252
 
@@ -1696,110 +2284,156 @@ Source: docs/effects.md
1696
2284
 
1697
2285
  # Effects and cancellation
1698
2286
 
1699
- Use this page for retry, timeouts, waiting, or parallel work — not as the first page for a small bus. Pick **one** helper for your job; the rest is reference.
2287
+ > **Level C.** Read first: [Policies](policies.md).
2288
+
2289
+ Use this page for retry, timeouts, waiting, or parallel work — not as the first page for a small bus. Pick **one** effect for your job; the rest is reference.
1700
2290
 
1701
2291
  An abort only raises a flag. Your code decides to stop. Start with `retry` / `timeout` on a handler (see [Guide §5](guide.md#5-add-retry-and-timeout)).
1702
2292
 
1703
- ## Wrap a handler with retry and timeout
2293
+ ## Effects
1704
2294
 
1705
- `retry`, `timeout`, and `poll` have two forms:
2295
+ Every effect below has its own section with its forms, options and an example. `ctx` is the context of the task.
1706
2296
 
1707
- - **Task wrapper** — take a function `(payload, ctx) => result` and return one. Pass them into `on` next to policies (no `ctx` in their arguments).
1708
- - **Call inside a body** — take `ctx` first.
2297
+ - [`retry`](#retry) — repeat a call after an error.
2298
+ - [`timeout`](#timeout) — stop a call that takes too long.
2299
+ - [`poll`](#poll) — call a function at an interval.
2300
+ - [`backoff`](#backoff) — make the pause between retries grow.
2301
+ - [`delay`](#delay) — wait for a time; an abort ends the wait.
2302
+ - [`cancellable`](#cancellable) — a Promise tied to the task signal, for callbacks and timers.
2303
+ - [`onAbort`](#onabort) — run code when the task is aborted.
2304
+ - [`ctx.on` and `ctx.off`](#ctxon-and-ctxoff) — subscribe to the bus inside a task.
2305
+ - [`take`](#take) — wait for the next event inside a handler.
2306
+ - [`all`](#all) — run several calls together; one failure cancels the rest.
2307
+ - [`allSettled`](#allsettled) — wait for all of them, whatever the outcome.
2308
+ - [`race`](#race) — take the first result and cancel the rest.
2309
+ - [`effectsPlugin`](#effectsplugin) — short forms of effects: call them without `ctx`.
2310
+ - [Effects outside a task](#effects-outside-a-task) — call effects where there is no handler.
1709
2311
 
1710
- The first argument tells them apart: a function (`retry`), a number (`timeout`), or a number/function (`poll`) is a wrapper; an object is `ctx`.
2312
+ ## `retry`
2313
+
2314
+ Runs a function again after an error. Use it when a call fails now and then, for example because of the network.
2315
+
2316
+ Forms:
2317
+
2318
+ - `retry(fn, options?)` — a wrapper of a handler: takes a function `(payload, ctx) => result` and returns one. Pass it to `on` next to policies.
2319
+ - `retry(ctx, fn, options?)` — a call inside the body of a handler.
2320
+
2321
+ Options:
2322
+
2323
+ - `times` — total attempts. It is `3` by default.
2324
+ - `delay` — a number or a function `(attempt, error) => ms`. For a growing pause see [`backoff`](#backoff).
2325
+ - `shouldRetry(error, attempt)` — return `false` to stop early.
2326
+ - `fn` gets the attempt number, starting at `1`.
2327
+ - On abort, `retry` rejects with the abort reason.
1711
2328
 
1712
2329
  ```js
1713
2330
  import { createBus } from "nanoeffects";
1714
- import { poll, retry, timeout } from "nanoeffects/effects";
2331
+ import { retry } from "nanoeffects/effects";
1715
2332
  import { latest } from "nanoeffects/policies";
1716
2333
 
1717
2334
  const bus = createBus();
1718
2335
  const loadUser = (id, { signal }) => fetchUser(id, { signal });
1719
2336
 
1720
- // timeout — one try must finish within 5 s
1721
- const loadOnce = timeout(5000, loadUser);
1722
- // retry — up to 4 attempts with backoff
1723
- const loadWithRetry = retry(loadOnce, {
2337
+ // retry — a wrapper of the handler: up to 4 attempts, but not after a fatal error
2338
+ const loadWithRetry = retry(loadUser, {
1724
2339
  times: 4,
1725
- delay: (attempt) => 2 ** attempt * 100,
1726
2340
  shouldRetry: (error) => !isFatal(error),
1727
2341
  });
1728
- // latest — a newer load aborts the older retry chain
1729
- bus.on("load", latest(loadWithRetry));
1730
2342
 
1731
- // poll — call refresh every 10 s until the task aborts
1732
- bus.on("watch", poll(10_000, (_, { signal }) => refresh({ signal })));
2343
+ // latest — a newer load aborts the older chain of attempts
2344
+ bus.on("load", latest(loadWithRetry));
1733
2345
 
1734
- // ctx.run — run a wrapped Task as a child (aborted with the handler)
2346
+ // ctx.run — run the wrapped function as a child task (aborted with the handler)
1735
2347
  bus.on("open", async (id, ctx) => {
1736
- const load = retry(timeout(5000, loadUser));
1737
- const user = await ctx.run(load, id);
2348
+ const user = await ctx.run(loadWithRetry, id);
1738
2349
  ctx.emit("opened", user);
1739
2350
  });
2351
+ ```
2352
+
2353
+ Use the wrapper for a whole handler or a whole `ctx.run`. Each attempt gets its own child context: an abort of the caller reaches it, and an abort of one attempt does not reach the caller. For one call in the middle of a body use the form with `ctx`:
2354
+
2355
+ ```js
2356
+ import { createBus } from "nanoeffects";
2357
+ import { retry } from "nanoeffects/effects";
2358
+
2359
+ const bus = createBus();
1740
2360
 
1741
- // ctx-first form — one call inside a body
1742
2361
  bus.on("load", async (id, ctx) => {
2362
+ // retry with ctx — one call inside the body
1743
2363
  const user = await retry(
1744
2364
  ctx,
1745
2365
  () => fetchUser(id, { signal: ctx.signal }),
1746
2366
  { times: 4 },
1747
2367
  );
1748
- const data = await timeout(ctx, 5000, ({ signal }) => {
1749
- return fetchData(id, { signal });
1750
- });
1751
- await poll(ctx, () => refresh(), 10_000);
1752
- ctx.emit("ready", { user, data });
2368
+
2369
+ ctx.emit("loaded", user);
1753
2370
  });
1754
2371
  ```
1755
2372
 
1756
- Use the wrapper for a whole handler or a whole `ctx.run`. Use the `ctx` form for one call in the middle of a body.
2373
+ ## `timeout`
2374
+
2375
+ Rejects when the work takes longer than the time you set, and aborts it. Use it when a request can hang.
2376
+
2377
+ Forms:
2378
+
2379
+ - `timeout(ms, handler)` — a wrapper of a handler.
2380
+ - `timeout(ctx, ms, fn)` — a call inside a body; `fn` gets the context of its own child task.
1757
2381
 
1758
- Each attempt or call of a wrapper gets its own child context. Abort of the caller reaches it. Abort of one attempt (a timeout) does not reach the caller. The attempt number is passed only in the `ctx` form.
2382
+ The error is a `DOMException` named `TimeoutError` (like `AbortSignal.timeout`). `timeout` rejects even if the function ignores its signal.
1759
2383
 
1760
- ### Options for retry, timeout, and poll
2384
+ ```js
2385
+ import { createBus } from "nanoeffects";
2386
+ import { timeout } from "nanoeffects/effects";
1761
2387
 
1762
- **`retry`**
2388
+ const bus = createBus();
2389
+ const loadUser = (id, { signal }) => fetchUser(id, { signal });
1763
2390
 
1764
- - `times` — total attempts (default `3`).
1765
- - `delay` — number or `(attempt, error) => ms`.
1766
- - `shouldRetry(error, attempt)` — stop early.
1767
- - `fn` gets the attempt number starting at `1`.
1768
- - On abort, rejects with the abort reason.
2391
+ // timeout — a wrapper of the handler: it must finish within 5 s
2392
+ bus.on("load", timeout(5000, loadUser));
1769
2393
 
1770
- **`timeout`**
2394
+ bus.on("open", async (id, ctx) => {
2395
+ // timeout with ctx — one call inside the body; child carries the signal of the request
2396
+ const data = await timeout(ctx, 5000, (child) => fetchData(id, child));
2397
+ ctx.emit("opened", data);
2398
+ });
2399
+ ```
1771
2400
 
1772
- - Error is a `DOMException` named `TimeoutError` (like `AbortSignal.timeout`).
1773
- - Rejects even if the starter ignores its signal.
2401
+ ## `poll`
1774
2402
 
1775
- **`poll`** (like patronum `interval`)
2403
+ Calls a function again and again at an interval, until the task is aborted. Use it to refresh data every N seconds. It is like `interval` in patronum.
1776
2404
 
1777
- - First call after `ms`. `leading: true` also calls at once.
1778
- - Next ticks keep a steady rate whatever the duration of `fn`, so a slow `fn` can overlap the next call.
1779
- - `waitForFn: true` starts each pause when the call ends — calls never overlap.
1780
- - `fn` gets the call number from `0`.
1781
- - An error from `fn` stops polling and rejects. An abort ends it normally and clears the timer.
2405
+ Forms:
1782
2406
 
1783
- A pause can be a function wherever a time is given. For retries with a cap and jitter, see [Back off between retries](#back-off-between-retries):
2407
+ - `poll(ms, fn, options?)` — a wrapper of a handler.
2408
+ - `poll(ctx, fn, ms)` — a call inside a body.
2409
+
2410
+ Options:
2411
+
2412
+ - The first call happens after `ms`. `leading: true` also calls at once.
2413
+ - The next ticks keep a steady rate whatever the duration of `fn`, so a slow `fn` can overlap the next call. `waitForFn: true` starts each pause when the call ends, so calls never overlap.
2414
+ - `fn` gets the call number, starting at `0`.
2415
+ - An error from `fn` stops polling and rejects. An abort ends it normally and clears the timer.
1784
2416
 
1785
2417
  ```js
1786
2418
  import { createBus } from "nanoeffects";
1787
- import { delay, poll, retry } from "nanoeffects/effects";
2419
+ import { poll } from "nanoeffects/effects";
1788
2420
 
1789
2421
  const bus = createBus();
1790
2422
 
2423
+ // poll — a wrapper of the handler: call refresh every 10 s until the task aborts
2424
+ bus.on("watch", poll(10_000, (_, { signal }) => refresh({ signal })));
2425
+
1791
2426
  bus.on("sync", async (_, ctx) => {
1792
- retry(ctx, load, { times: 5, delay: (attempt) => 2 ** attempt * 100 }); // 200, 400, 800, …
1793
- poll(ctx, refresh, (call) => Math.min(1000 * 2 ** call, 60_000)); // slows to once a minute
1794
- await delay(ctx, () => 200 + Math.random() * 200); // 200–400 ms
2427
+ // poll with ctx — the same inside a body
2428
+ await poll(ctx, () => refresh(), 10_000);
1795
2429
  });
1796
2430
  ```
1797
2431
 
1798
- All three are traced: the effect is a group (`retry`, `timeout`, `poll`) and each attempt or call is a `run` child.
2432
+ `retry`, `timeout` and `poll` are traced: the effect is a group, and each attempt or call is a `run` child.
1799
2433
 
1800
- ## Back off between retries
2434
+ ## `backoff`
1801
2435
 
1802
- Use this when a server or network fails under load. Retrying at once, or at the same fixed pause, sends every client back at the same moment and can keep the server down. Make the pause grow with each attempt, and add jitter so clients spread out.
2436
+ Makes the pause between retries grow, with a cap and jitter. Use it when a server or network fails under load: retrying at once, or at the same fixed pause, sends every client back at the same moment and can keep the server down. `backoff(options)` returns a function `(attempt) => ms`.
1803
2437
 
1804
2438
  The shortest form is a `delay` function. It needs no import:
1805
2439
 
@@ -1839,48 +2473,41 @@ const loadWithRetry = retry(loadUser, {
1839
2473
  - `retry` counts attempts from 1. `poll` counts calls from 0, so with `poll` the first pause is `base / factor`.
1840
2474
  - The result is a plain function `(attempt) => ms`, so it also fits `delay(ctx, …)`.
1841
2475
 
1842
- ## Put selected effects on `ctx`
1843
-
1844
- If handlers call the same helpers often, put them on the task once. A handler then destructures them like `signal`, with no `ctx` argument. Only the effects you pass are imported, so the rest stay out of the bundle ([package entries](package.md)).
2476
+ A pause can be a function wherever a time is given:
1845
2477
 
1846
2478
  ```js
1847
2479
  import { createBus } from "nanoeffects";
1848
- import { all, delay, effectsPlugin, race } from "nanoeffects/effects";
1849
-
1850
- // effectsPlugin — add race, all and delay to the context of every task
1851
- const effects = effectsPlugin({ race, all, delay });
1852
-
1853
- // use returns the same bus, typed with the new fields
1854
- const bus = createBus().use(effects);
2480
+ import { delay, poll, retry } from "nanoeffects/effects";
1855
2481
 
1856
- bus.on("load", async (id, { race, all, delay }) => {
1857
- // race — same as race(ctx, …)
1858
- const result = await race({
1859
- data: ({ signal }) => fetchData(id, { signal }),
1860
- // a starter gets its own child with the same bound effects, so delay(5000) stops with the race
1861
- timeout: ({ delay }) => delay(5000),
1862
- });
1863
- if ("timeout" in result) throw new Error("timed out");
2482
+ const bus = createBus();
1864
2483
 
1865
- // all — same as all(ctx, …)
1866
- const { user, posts } = await all({
1867
- user: ({ signal }) => fetchUser(id, { signal }),
1868
- posts: ({ signal }) => fetchPosts(id, { signal }),
1869
- });
2484
+ bus.on("sync", async (_, ctx) => {
2485
+ retry(ctx, load, { times: 5, delay: (attempt) => 2 ** attempt * 100 }); // 200, 400, 800, …
2486
+ poll(ctx, refresh, (call) => Math.min(1000 * 2 ** call, 60_000)); // slows to once a minute
2487
+ await delay(ctx, () => 200 + Math.random() * 200); // 200–400 ms
1870
2488
  });
1871
2489
  ```
1872
2490
 
1873
- - The key is the name the effect gets on `ctx`. `race`, `all`, `allSettled`, `take` and `delay` are typed by hand.
1874
- - Your own function works too if its first parameter is `{ signal }` (an *effect*): `effectsPlugin({ fetchJson })` gives `ctx.fetchJson(url)`. TypeScript removes the first parameter from its type; for a generic function see [keep a generic effect generic](scope.md#keep-a-generic-effect-generic).
1875
- - Leave `retry`, `timeout` and `poll` out: bound to the task they would look like their handler wrappers.
1876
- - Fields from `createBus({ context })` stay. TypeScript rejects an effect whose name is already a context field of the bus, including one added by an earlier `effectsPlugin`.
1877
- - Free forms keep working: `race(ctx, …)`.
2491
+ ## `delay`
2492
+
2493
+ Waits for the given time and rejects on abort. A plain `setTimeout` would go on after the task is aborted; `delay` ends the wait at once and clears the timer. `delay(ctx, ms)`, where `ms` can be a function.
2494
+
2495
+ ```js
2496
+ import { createBus } from "nanoeffects";
2497
+ import { delay } from "nanoeffects/effects";
2498
+
2499
+ const bus = createBus();
1878
2500
 
1879
- ## Tie a promise to the task signal
2501
+ bus.on("saved", async (_, ctx) => {
2502
+ // delay — wait 3 s; an abort ends the wait at once and clears the timer
2503
+ await delay(ctx, 3000);
2504
+ ctx.emit("toast/hidden");
2505
+ });
2506
+ ```
1880
2507
 
1881
- `delay(ctx, ms)` waits and rejects on abort (`ms` can be a function).
2508
+ ## `cancellable`
1882
2509
 
1883
- `cancellable(ctx, executor)` is a promise tied to the task signal. It works like `new Promise`, but the executor gets `{ resolve, reject, onCancel }`.
2510
+ A Promise tied to the task signal. Use it for APIs that work with callbacks, timers or listeners: the signal cannot stop them by itself, so `cancellable` rejects the Promise on abort and runs your cleanup. `cancellable(ctx, executor)` works like `new Promise`, but the executor gets `{ resolve, reject, onCancel }`.
1884
2511
 
1885
2512
  ```js
1886
2513
  import { createBus } from "nanoeffects";
@@ -1889,7 +2516,7 @@ import { latest } from "nanoeffects/policies";
1889
2516
 
1890
2517
  const bus = createBus();
1891
2518
 
1892
- // cancellable — promise tied to the task signal; onCancel for cleanups
2519
+ // cancellable — a Promise tied to the task signal; onCancel registers a cleanup
1893
2520
  // latest — a newer wait aborts the older timer
1894
2521
  bus.on(
1895
2522
  "wait",
@@ -1902,13 +2529,13 @@ bus.on(
1902
2529
  );
1903
2530
  ```
1904
2531
 
1905
- - If the task aborts before the promise settles, cleanups run (last registered first) and the promise rejects with the abort reason.
2532
+ - If the task aborts before the Promise settles, cleanups run (last registered first) and the Promise rejects with the abort reason.
1906
2533
  - If the task is already aborted, the executor does not run.
1907
- - After the promise settles, the abort listener is removed and cleanups are dropped.
2534
+ - After the Promise settles, the abort listener is removed and cleanups are dropped.
1908
2535
 
1909
- ## Run cleanup on abort
2536
+ ## `onAbort`
1910
2537
 
1911
- `onAbort(ctx, fn)` runs `fn(reason)` on abort (at once if already aborted) and returns a function that unsubscribes.
2538
+ Runs code when the task is aborted: a hook of the life of the task. A task may be aborted because the user left or a newer call replaced it, and whatever it opened (a socket, a timer, a listener) must be closed then. `onAbort(ctx, fn)` runs `fn(reason)` on abort (at once if the task is already aborted) and returns a function that unsubscribes.
1912
2539
 
1913
2540
  ```js
1914
2541
  import { createBus } from "nanoeffects";
@@ -1918,67 +2545,124 @@ const bus = createBus();
1918
2545
 
1919
2546
  bus.on("connect", (url, ctx) => {
1920
2547
  const socket = new WebSocket(url);
1921
- // onAbort — run cleanup when this task aborts
2548
+ // onAbort — close the socket when this task aborts
1922
2549
  onAbort(ctx, () => socket.close());
1923
2550
  });
1924
2551
  ```
1925
2552
 
1926
- ## Wait for the next event with `take`
2553
+ ## `ctx.on` and `ctx.off`
2554
+
2555
+ Subscribes to the bus inside a task. A task context has `on` and `off`, like the bus, and a subscription made with `ctx.on` ends when the task aborts.
2556
+
2557
+ ```js
2558
+ import { createBus } from "nanoeffects";
2559
+
2560
+ const bus = createBus();
2561
+
2562
+ bus.on("watch", (_, ctx) => {
2563
+ // gone when this task aborts
2564
+ const stop = ctx.on("tick", (n) => console.log(n));
2565
+
2566
+ // or end it sooner yourself: stop() unsubscribes
2567
+ ctx.on("watch/closed", stop);
2568
+ });
2569
+ ```
2570
+
2571
+ Be careful with it:
2572
+
2573
+ - **A task that finishes by itself is not aborted**, so its `ctx.on` subscription stays. When the work is done, unsubscribe with the function that `ctx.on` returns, or call `ctx.abort()`.
2574
+ - To wait for **one** event, use [`take`](#take): it unsubscribes by itself.
2575
+ - For listeners that live as long as a feature, use a [module](actions.md#modules): `unmount` removes all of them.
2576
+
2577
+ Also:
2578
+
2579
+ - After an abort, `ctx.on` does nothing.
2580
+ - Under `latest`, the subscriptions of a superseded run end with that run.
2581
+ - `ctx.off(event)` clears every handler of the event on the bus, not only the ones of this task.
2582
+
2583
+ ## `take`
2584
+
2585
+ Waits for the next event of the bus inside a handler and resolves with its payload. `take(ctx, event, options?)`. The `event` is the name of an event or an action creator, as for `bus.on`.
2586
+
2587
+ ```js
2588
+ import { createBus } from "nanoeffects";
2589
+ import { createAction } from "nanoeffects/actions";
2590
+ import { take, timeout } from "nanoeffects/effects";
2591
+
2592
+ const bus = createBus();
2593
+ const stopRequested = createAction("stop"); // an event with a name; calling it builds the action
2594
+
2595
+ bus.on("login", async (user, ctx) => {
2596
+ const reason = await take(ctx, "logout"); // resolves on the next logout
2597
+ cleanup(user, reason);
2598
+
2599
+ // an action creator works the same, and its payload type is the type of the result
2600
+ const stopped = await take(ctx, stopRequested);
2601
+ console.log(stopped);
2602
+
2603
+ // wait for a matching event, but not longer than 5 s
2604
+ const id = await timeout(
2605
+ ctx,
2606
+ 5000,
2607
+ take("saved", { predicate: (savedId) => savedId === draft.id }), // take without ctx — a ready-made function for the group
2608
+ );
2609
+ });
2610
+ ```
2611
+
2612
+ - It sees only events emitted after the call, takes one, and unsubscribes.
2613
+ - With an action creator the result has the payload type of the creator. A creator made without a payload type (`createAction("saved")`) takes it from the event map of the bus.
2614
+ - `options.predicate` skips payloads it rejects. In TypeScript, a type guard (`(payload): payload is Admin`) narrows the type of the result.
2615
+ - `options.signal` stops the wait. Inside `race`, `all` or `timeout` you do not need it: the group already stops the wait, so write `take("saved")` without options.
2616
+ - On abort of the task or of `options.signal`, it unsubscribes and rejects with the abort reason.
2617
+
2618
+ ## `all`
1927
2619
 
1928
- A task context has `on` and `off`, like the bus. A subscription made with `ctx.on` ends when the task aborts:
2620
+ Runs several calls together; one failure cancels the rest. Use it to load several things at once. `all(ctx, effects)` takes an object or an array of functions, and the result keeps that shape.
1929
2621
 
1930
2622
  ```js
1931
2623
  import { createBus } from "nanoeffects";
2624
+ import { all } from "nanoeffects/effects";
1932
2625
 
1933
2626
  const bus = createBus();
1934
2627
 
1935
- bus.on("watch", (_, ctx) => {
1936
- ctx.on("tick", (n) => console.log(n)); // gone when this task aborts
2628
+ bus.on("open", async (id, ctx) => {
2629
+ // all — run both requests together; one failure cancels the other. child is the context of its own request
2630
+ const { user, posts } = await all(ctx, {
2631
+ user: (child) => fetchUser(id, child),
2632
+ posts: (child) => fetchPosts(id, child),
2633
+ });
2634
+
2635
+ ctx.emit("opened", { user, posts });
1937
2636
  });
1938
2637
  ```
1939
2638
 
1940
- - The returned function unsubscribes sooner. After abort, `ctx.on` does nothing.
1941
- - Under `latest`, subscriptions of a superseded run end with that run.
1942
- - `ctx.off(event)` clears every handler of the event on the bus, not only the task’s.
2639
+ Every function of the group gets `child`, the context of its own child task. Pass `child` as the first argument to any effect, as a handler passes `ctx`: when the group ends or fails, `child` is aborted and stops exactly this effect. If your API takes the signal in its options, `child` has a `signal`, so pass it as the options: `(child) => fetchUser(id, child)` (`fetch` reads only `signal` from it). The same holds for `allSettled`, `race` and `timeout`.
1943
2640
 
1944
- `take(ctx, event, options?)` waits for the next `event` and resolves with its payload. `event` is the name of an event or an action creator, as for `bus.on`:
2641
+ **Limit how many run at once.** `all` and `allSettled` start everything together. Over a long list that is a burst of requests. Pass `{ concurrency }` as the third argument and at most that many run at a time; the next one starts when a running one settles:
1945
2642
 
1946
2643
  ```js
1947
2644
  import { createBus } from "nanoeffects";
1948
- import { createAction } from "nanoeffects/actions";
1949
- import { take, timeout } from "nanoeffects/effects";
2645
+ import { all, retry } from "nanoeffects/effects";
1950
2646
 
1951
2647
  const bus = createBus();
1952
- const stopRequested = createAction("stop"); // an event with a name; calling it builds the action
1953
-
1954
- bus.on("login", async (user, ctx) => {
1955
- const reason = await take(ctx, "logout"); // resolves on the next logout
1956
- cleanup(user, reason);
1957
-
1958
- // an action creator works the same, and its payload type is the type of the result
1959
- const stopped = await take(ctx, stopRequested);
1960
- console.log(stopped);
1961
2648
 
1962
- // wait for a matching event, but not longer than 5 s
1963
- const id = await timeout(
2649
+ bus.on("sync", async (ids, ctx) => {
2650
+ // all with concurrency — 5 loads at a time, results in the order of ids
2651
+ const users = await all(
1964
2652
  ctx,
1965
- 5000,
1966
- take("saved", { predicate: (savedId) => savedId === draft.id }), // take without ctx: a starter
2653
+ ids.map((id) => retry(() => loadUser(id), { times: 2 })),
2654
+ { concurrency: 5 },
1967
2655
  );
2656
+
2657
+ render(users);
1968
2658
  });
1969
2659
  ```
1970
2660
 
1971
- - Sees only events emitted after the call, takes one, and unsubscribes.
1972
- - With an action creator the result has the payload type of the creator. A creator made without a payload type (`createAction("saved")`) takes it from the event map of the bus.
1973
- - `options.predicate` skips payloads it rejects. In TypeScript, a type guard (`(payload): payload is Admin`) narrows the type of the result.
1974
- - `options.signal` stops the wait. Inside `race`, `all` or `timeout` you do not need it: the group already stops the starter, so write `take("saved")` without options.
1975
- - On abort of the task or of `options.signal`, unsubscribes and rejects with the abort reason.
1976
-
1977
- ## Run several starters together
2661
+ The result has the same order and shape as without the option. If one call of `all` fails, the running ones are cancelled and the waiting ones are never started. `allSettled` goes on after a failure. When `ctx` aborts, both stop and start nothing more. A value that is not a positive integer (`0`, `-1`, `NaN`, `1.5`) throws a `RangeError` before anything starts; `Infinity` means no limit. `race` has no `concurrency`: it ends at the first result, so a call that waited for a free slot would never run.
1978
2662
 
1979
- `all(ctx, effects)` runs starters together. One failure cancels the rest. Shape of the input (object or array) is preserved in the result.
2663
+ ## `allSettled`
1980
2664
 
1981
- `allSettled(ctx, effects)` waits for every starter and never fails because of one. Each result is `{ status: "fulfilled", value }` or `{ status: "rejected", reason }`. Nothing is cancelled when a starter fails. Only an abort of `ctx` rejects it.
2665
+ Waits for every call and never fails because of one. Use it when a partial answer is better than none. `allSettled(ctx, effects)`. Each result is `{ status: "fulfilled", value }` or `{ status: "rejected", reason }`. Nothing is cancelled when a call fails; only an abort of `ctx` rejects it.
1982
2666
 
1983
2667
  ```js
1984
2668
  import { createBus } from "nanoeffects";
@@ -1987,9 +2671,10 @@ import { allSettled } from "nanoeffects/effects";
1987
2671
  const bus = createBus();
1988
2672
 
1989
2673
  bus.on("open", async (id, ctx) => {
2674
+ // allSettled — wait for both, whatever the outcome
1990
2675
  const { user, posts } = await allSettled(ctx, {
1991
- user: ({ signal }) => fetchUser(id, { signal }),
1992
- posts: ({ signal }) => fetchPosts(id, { signal }),
2676
+ user: (child) => fetchUser(id, child),
2677
+ posts: (child) => fetchPosts(id, child),
1993
2678
  });
1994
2679
 
1995
2680
  if (user.status === "fulfilled") {
@@ -1998,7 +2683,9 @@ bus.on("open", async (id, ctx) => {
1998
2683
  });
1999
2684
  ```
2000
2685
 
2001
- `race(ctx, effects)` settles with the first starter that finishes and cancels the others.
2686
+ ## `race`
2687
+
2688
+ Settles with the first call that finishes and cancels the others. Use it to take the fastest answer or to cut a wait short. `race(ctx, effects)`.
2002
2689
 
2003
2690
  ```js
2004
2691
  import { createBus } from "nanoeffects";
@@ -2007,9 +2694,9 @@ import { delay, race, take } from "nanoeffects/effects";
2007
2694
  const bus = createBus();
2008
2695
 
2009
2696
  bus.on("save", async (draft, ctx) => {
2010
- // race — the first starter to finish wins, the other is cancelled
2697
+ // race — the first to finish wins, the other is cancelled
2011
2698
  const result = await race(ctx, {
2012
- waited: delay(1000), // delay without ctx — a starter: race gives it its own child
2699
+ waited: delay(1000), // delay without ctx — a ready-made function for the group: race gives it its own child
2013
2700
  cancelled: take("cancel"), // take without ctx — the same, waits for the next "cancel"
2014
2701
  });
2015
2702
 
@@ -2019,9 +2706,9 @@ bus.on("save", async (draft, ctx) => {
2019
2706
  });
2020
2707
  ```
2021
2708
 
2022
- **`delay` and `take` without `ctx` are ready-made starters.** In a group, `delay(1000)` and `take("cancel")` do what `(child) => delay(child, 1000)` and `(child) => take(child, "cancel")` do. The group calls them with the child, so the wait stops with the group. Use this form inside `race`, `all`, `allSettled` and `timeout`; use `delay(ctx, 1000)` in plain handler code. A starter waits only when a group calls it: do not `await delay(1000)` on its own, it is a function, not a promise. Write `take("cancel")` in the group, as above; a starter stored in a variable outside it does not know the events of your bus, and you name them yourself in TypeScript (`const cancelled: StarterOf<Events> = take("cancel")`; `StarterOf` is exported by `nanoeffects/effects`).
2709
+ **`delay` and `take` without `ctx` are ready-made functions for a group.** In a group, `delay(1000)` and `take("cancel")` do what `(child) => delay(child, 1000)` and `(child) => take(child, "cancel")` do: the group calls them with the child, so the wait stops with the group. Use this form inside `race`, `all`, `allSettled` and `timeout`; use `delay(ctx, 1000)` in plain handler code. They wait only when a group calls them: `await delay(1000)` on its own does not wait, because it is a function, not a Promise. In TypeScript, one kept in a variable outside a group does not know the events of your bus; name them yourself: `const cancelled: StarterOf<Events> = take("cancel")` (`StarterOf` is exported by `nanoeffects/effects`).
2023
2710
 
2024
- The wrapper form of `retry`, `timeout` and `poll` is a starter too: a group calls it with the child, and the function you wrap gets `undefined` as its payload and the child as its context. Write the data it needs into a closure:
2711
+ The wrapper form of `retry`, `timeout` and `poll` works in a group too: the group calls it with the child, and the function you wrap gets `undefined` as its payload and the child as its context. Write the data it needs into a closure:
2025
2712
 
2026
2713
  ```js
2027
2714
  import { createBus } from "nanoeffects";
@@ -2031,7 +2718,7 @@ const bus = createBus();
2031
2718
 
2032
2719
  bus.on("open", async (id, ctx) => {
2033
2720
  const result = await race(ctx, {
2034
- // retry without ctx — a starter: up to 3 attempts, each with the signal of the child
2721
+ // retry without ctx — up to 3 attempts, each with the signal of the child
2035
2722
  user: retry((_, { signal }) => fetchUser(id, { signal }), { times: 3 }),
2036
2723
  // delay without ctx — the loser is cancelled when the other one finishes
2037
2724
  waited: delay(5000),
@@ -2043,9 +2730,9 @@ bus.on("open", async (id, ctx) => {
2043
2730
  });
2044
2731
  ```
2045
2732
 
2046
- The same call with a payload and a context, `(payload, ctx)`, is the Task that `bus.on` and `ctx.run` use, and it does not change. `retry`, `timeout` and `poll` therefore have three call forms: with `ctx`, as a Task, and as a starter. All three stay; [Stability](stability.md#rely-on-these-call-forms) says how they are told apart.
2733
+ So `retry`, `timeout` and `poll` have three call forms: with `ctx`, as a Task for `bus.on` and `ctx.run`, and inside a group. The arguments tell them apart: a context first is the `ctx` form; a function called with a payload and a context is a Task; the same function called by a group with one argument, its child, is the group form. This does not depend on how many parameters your function declares.
2047
2734
 
2048
- **`all`, `allSettled` and `race` without `ctx` are starters too**, so a group nests in another group with no wrapper. Load two things, but give up after five seconds:
2735
+ **`all`, `allSettled` and `race` without `ctx` also work in a group**, so a group nests in another group with no wrapper. Load two things, but give up after five seconds:
2049
2736
 
2050
2737
  ```js
2051
2738
  import { createBus } from "nanoeffects";
@@ -2055,7 +2742,7 @@ const bus = createBus();
2055
2742
 
2056
2743
  bus.on("open", async (id, ctx) => {
2057
2744
  const result = await race(ctx, {
2058
- // all without ctx — a starter: loads both, each with up to 3 attempts
2745
+ // all without ctx — loads both, each with up to 3 attempts
2059
2746
  loaded: all({
2060
2747
  user: retry((_, { signal }) => fetchUser(id, { signal }), { times: 3 }),
2061
2748
  posts: retry((_, { signal }) => fetchPosts(id, { signal }), { times: 3 }),
@@ -2070,46 +2757,77 @@ bus.on("open", async (id, ctx) => {
2070
2757
  });
2071
2758
  ```
2072
2759
 
2073
- One argument is the short form and two arguments are the form with `ctx`: `all(effects)` returns a starter, `all(ctx, effects)` runs the group. The inner group stops with the outer one and cancels its own starters. Passing only a context, `all(ctx)`, is a mistake and throws a `TypeError` at once. A group starter written in a group needs no type. One kept in a variable outside a group does not know its bus: in TypeScript, annotate it with `StarterOf<Events>`, or `StarterOf<Events, Result>` when you need its value.
2760
+ One argument is the short form and two arguments are the form with `ctx`: `all(effects)` returns a function for a group, `all(ctx, effects)` runs the group. The inner group stops with the outer one and cancels its own calls. Passing only a context, `all(ctx)`, is a mistake and throws a `TypeError` at once. The form without `ctx` takes no options, because a second argument would look like the form with `ctx`; write `(child) => all(child, effects, { concurrency: 5 })` there. In TypeScript, a group kept in a variable outside a group needs a type: `StarterOf<Events>`, or `StarterOf<Events, Result>` when you need its value.
2761
+
2762
+ ## `effectsPlugin`
2074
2763
 
2075
- **Limit how many run at once.** `all` and `allSettled` start every starter together. Over a long list that is a burst of requests. Pass `{ concurrency }` as the third argument and at most that many run at a time; the next one starts when a running one settles:
2764
+ Short forms of effects: `effectsPlugin(effects)` puts chosen effects on `ctx`, so a handler calls them without passing `ctx` around. Use it when many handlers call the same helpers, to cut the boilerplate. A handler destructures them like `signal`. Only the effects you pass are imported, so the rest stay out of the bundle ([package entries](package.md)).
2076
2765
 
2077
2766
  ```js
2078
2767
  import { createBus } from "nanoeffects";
2079
- import { all, retry } from "nanoeffects/effects";
2768
+ import { all, delay, effectsPlugin, race } from "nanoeffects/effects";
2080
2769
 
2081
- const bus = createBus();
2770
+ // effectsPlugin — add race, all and delay to the context of every task
2771
+ const effects = effectsPlugin({ race, all, delay });
2082
2772
 
2083
- bus.on("sync", async (ids, ctx) => {
2084
- // all with concurrency — 5 loads at a time, results in the order of ids
2085
- const users = await all(
2086
- ctx,
2087
- ids.map((id) => retry(() => loadUser(id), { times: 2 })),
2088
- { concurrency: 5 },
2089
- );
2773
+ // use returns the same bus, typed with the new fields
2774
+ const bus = createBus().use(effects);
2090
2775
 
2091
- render(users);
2776
+ bus.on("load", async (id, { race, all, delay }) => {
2777
+ // race — same as race(ctx, …)
2778
+ const result = await race({
2779
+ data: (child) => fetchData(id, child),
2780
+ // a group function gets its own child with the same bound effects, so delay(5000) stops with the race
2781
+ timeout: ({ delay }) => delay(5000),
2782
+ });
2783
+ if ("timeout" in result) throw new Error("timed out");
2784
+
2785
+ // all — same as all(ctx, …)
2786
+ const { user, posts } = await all({
2787
+ user: (child) => fetchUser(id, child),
2788
+ posts: (child) => fetchPosts(id, child),
2789
+ });
2092
2790
  });
2093
2791
  ```
2094
2792
 
2095
- The result has the same order and shape as without the option. If one starter of `all` fails, the running ones are cancelled and the waiting ones are never called. `allSettled` goes on after a failure. When `ctx` aborts, both stop and start nothing more. A value that is not a positive integer (`0`, `-1`, `NaN`, `1.5`) throws a `RangeError` before anything starts; `Infinity` means no limit. `race` has no `concurrency`: it ends at the first result, so a starter that waited for a free slot would never run. The form without `ctx` takes no options, because a second argument would look like the form with `ctx`, so a group starter inside another group takes none; write `(child) => all(child, effects, { concurrency: 5 })` there.
2793
+ - The key is the name the effect gets on `ctx`. `race`, `all`, `allSettled`, `take` and `delay` are typed by hand.
2794
+ - Your own function works too if its first parameter is `{ signal }` (an *effect*): `effectsPlugin({ fetchJson })` gives `ctx.fetchJson(url)`. TypeScript removes the first parameter from its type; for a generic function see [keep a generic effect generic](context.md#keep-a-generic-effect-generic).
2795
+ - Leave `retry`, `timeout` and `poll` out: bound to the task they would look like their handler wrappers.
2796
+ - Fields from `createBus({ context })` stay. TypeScript rejects an effect whose name is already a context field of the bus, including one added by an earlier `effectsPlugin`.
2797
+ - The free forms keep working: `race(ctx, …)`.
2798
+
2799
+ ## Effects outside a task
2800
+
2801
+ Effects take `ctx` first. Inside a handler it is the `ctx` you already have. Outside a task (in a plain function, a script or a test) there is no `ctx`: pass an object with a `signal`, or `{}` if you never abort.
2096
2802
 
2803
+ ```js
2804
+ import { delay, retry } from "nanoeffects/effects";
2097
2805
 
2098
- **A starter gets the context of its own child task.** Pass `child` as the first argument to any effect, as a handler passes `ctx`: when the race ends, `child` is aborted and stops exactly this effect. The outer `ctx` is only for `race` itself. Your own API takes the signal in its options: `({ signal }) => fetchUser(id, { signal })`. The same holds for `all`, `allSettled` and `timeout`.
2806
+ const controller = new AbortController();
2099
2807
 
2100
- If `ctx` is `{}` or a bare `{ signal }` (outside a task), `child` has only `signal` and `abort`, so it fits `delay` and `poll` but not `take`, which needs a bus.
2808
+ // outside a task there is no ctx: an object with a signal is enough
2809
+ const context = { signal: controller.signal };
2101
2810
 
2102
- ## Pass `ctx` outside a task
2811
+ // the effects take it as ctx; fetchUser gets the same signal, so the request itself stops too
2812
+ const user = await retry(
2813
+ context,
2814
+ () => fetchUser("1", { signal: controller.signal }),
2815
+ { times: 3 },
2816
+ );
2103
2817
 
2104
- Effects that wait inside a body take `ctx` first: `delay`, `all`, `allSettled`, `race`, `cancellable`, `onAbort`, `take`, and the `ctx` forms of `retry` / `timeout` / `poll`. Outside a task, pass `{}` or any object with a `signal`. The one exception is `delay(ms)` and `take(event)` as starters of a group, which take no `ctx` (see above).
2818
+ await delay(context, 500);
2105
2819
 
2106
- For `fetch` and other APIs that take a signal, pass `ctx.signal`. A timeout without `race`: `AbortSignal.any([ctx.signal, AbortSignal.timeout(5000)])`.
2820
+ // stops the pending retries and waits
2821
+ controller.abort();
2822
+ ```
2107
2823
 
2108
2824
  ---
2109
2825
  Source: docs/errors.md
2110
2826
 
2111
2827
  # Handle errors and unheard events
2112
2828
 
2829
+ > **Level B.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 and 2.
2830
+
2113
2831
  Defaults match a small bus. Set these when you want isolation or typo warnings.
2114
2832
 
2115
2833
  ## Isolate failing handlers
@@ -2131,9 +2849,9 @@ const bus = createBus({
2131
2849
 
2132
2850
  ## Warn when nobody listens
2133
2851
 
2134
- A typo in an event name (`emit("serch")`) does nothing and says nothing. `onUnhandled` runs when an event is emitted and nobody listens (a listener of every event, `"*"`, counts). It is not called for actions, which always have a handler to run.
2852
+ A typo in an event name (`emit("serch")`) does nothing and says nothing. `onUnhandled` runs when an event is emitted and nobody listens (a listener of every event, `"*"`, counts). It is not called for an action that has a handler to run. An action without a handler is only an event, so it is called for it.
2135
2853
 
2136
- Use it as a warning in development:
2854
+ Use it as a warning in development (`import.meta.env.DEV` is the Vite flag; use your own check in another bundler):
2137
2855
 
2138
2856
  ```js
2139
2857
  import { createBus } from "nanoeffects";
@@ -2150,7 +2868,24 @@ Source: docs/actions.md
2150
2868
 
2151
2869
  # Actions, jobs and modules
2152
2870
 
2153
- Use this page when **domain logic lives on the bus**, off the UI: the UI emits and listens; handlers and modules own the work.
2871
+ > **Level D.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 to 6.
2872
+
2873
+ When a project has a lot of business logic, you need to split it. One way is to split it by domains, or entities. In a shop, the domains could be products and product categories. Then the code that belongs to the actions of one domain goes into one file (`product.js`), its handlers into another, and the module that mounts them into a third. A domain can then be mounted, replaced or unmounted without touching the others. For the shop the files could look like this:
2874
+
2875
+ ```text
2876
+ src/
2877
+ bus.js
2878
+ product/
2879
+ product.js
2880
+ product-handlers.js
2881
+ product-module.js
2882
+ category/
2883
+ category.js
2884
+ category-handlers.js
2885
+ category-module.js
2886
+ ```
2887
+
2888
+ The examples below show the content of such files.
2154
2889
 
2155
2890
  If you only need `on` / `emit` or policies, stay in the [Guide](guide.md) until you need a result from `emit` or a mountable domain.
2156
2891
 
@@ -2160,11 +2895,37 @@ A chat with two entities and three features, jobs under policies and effects, an
2160
2895
 
2161
2896
  A whole project with two domains, a job and React as a thin layer: `npm create nanoeffects@latest my-app -- --template domains-ts` (see [Start a project from a template](starter.md#start-with-domains)).
2162
2897
 
2163
- ## Return a value from `emit` with a handler
2898
+ ## Action, job and module
2899
+
2900
+ Four forms, one idea each. Take the first row that fits.
2901
+
2902
+ | Form | What it is | Use it when |
2903
+ | ---- | ---------- | ----------- |
2904
+ | `createAction(type)` | An action with no handler: a name and a payload. | The UI or a handler tells that something happened. |
2905
+ | `createAction(type, handler)` | An action with a handler: `emit` runs the handler and returns its result. | You want the result of the handler from `emit`. |
2906
+ | `createJob(type, handler)` | An action with a handler that returns its result as the actions `success` and `fail`, and also sends `pending` and `cancel`. | You want the result of the work of a handler and, if you need it, to subscribe to the state of that work. |
2907
+ | `createModule(name, setup)` | A group of actions and jobs with their handlers, mounted and unmounted with one call. | One domain must start and stop as a unit. |
2908
+
2909
+ Start with actions. Add a handler when you need a result, a job when you need a status, a module when you need a unit.
2164
2910
 
2165
- Give `createAction` a handler, and `emit` runs it and returns its result. There is one creator for an event and for an action with work behind it; the handler is the only difference.
2911
+ ## On this page
2912
+
2913
+ - [Action, job and module](#action-job-and-module)
2914
+ - [Get the result of `emit`](#get-the-result-of-emit)
2915
+ - [Actions, handlers and jobs](#actions-handlers-and-jobs)
2916
+ - [Actions](#actions)
2917
+ - [Bind a handler later](#bind-a-handler-later)
2918
+ - [The result type of a bound handler](#the-result-type-of-a-bound-handler)
2919
+ - [Jobs](#jobs)
2920
+ - [The state of a job](#the-state-of-a-job)
2921
+ - [Modules](#modules)
2922
+ - [Extend the context of actions and modules](#extend-the-context-of-actions-and-modules)
2923
+
2924
+ ## Get the result of `emit`
2925
+
2926
+ When you want an answer from a handler at the `emit` call, give `createAction` the handler. A handler is a function `(payload, ctx) => result` that runs when the action is emitted. `emit` runs it and returns its result. There is one creator for an action with and without a handler; the handler is the only difference.
2166
2927
 
2167
- The bus lives in one file and its events in another, so any module can import both without a cycle.
2928
+ The bus lives in one file and its actions in another, so any module can import both without a cycle.
2168
2929
 
2169
2930
  ```js
2170
2931
  // bus.js
@@ -2189,7 +2950,7 @@ import { double } from "./math";
2189
2950
  bus.emit(double(21)); // 42
2190
2951
  ```
2191
2952
 
2192
- ## Describe events, handlers and jobs
2953
+ ## Actions, handlers and jobs
2193
2954
 
2194
2955
  `nanoeffects/actions` has the pieces to name what a bus does, apart from the code that does it.
2195
2956
 
@@ -2224,7 +2985,7 @@ These are the events of the user domain, in a file of their own (`user.js`). The
2224
2985
  - `userLoaded.match(action)` — narrows an action
2225
2986
  - `createAction(type, handler)` — a function as the second argument is the handler; `bus.emit(action(payload))` tells the listeners, runs the handler as a task and returns its result
2226
2987
  - `createAction(type, { prepare })` — build the payload from several arguments
2227
- - `createAction(type, { handle, prepare, ...scope })` — everything in one object: `handle` and `prepare` are taken out, and every other field is the [scope](scope.md)
2988
+ - `createAction(type, { handle, prepare, ...scope })` — everything in one object: `handle` and `prepare` are taken out, and every other field is the [scope](context.md#scope-fields-for-an-action-a-module-and-a-call)
2228
2989
  - Works as the event of its type: `bus.emit(userLoaded(user))`, `bus.on(userLoaded, fn)`
2229
2990
 
2230
2991
  A scope goes after a handler as the last argument, `createAction(type, handler, scope)`; `prepare` with a handler goes in that last object: `createAction(type, handler, { prepare, ...scope })`.
@@ -2274,7 +3035,7 @@ A handler bound in the registry makes `bus.emit(sendMail(mail))` run it and retu
2274
3035
 
2275
3036
  The registry lives in `nanoeffects/actions`, so a bus without one carries none of that code. Without a registry, `emit` runs the handler that the action carries. `bus.use(actionsPlugin())` sets `bus.actions`. A second `actionsPlugin` binds its list in that registry and returns it. There is no `actions` option in `createBus`; assign a custom runner with `bus.actions = { run }`, and add `bound(type)` to it to answer for actions that have no handler of their own.
2276
3037
 
2277
- ### Say what a bound handler returns
3038
+ ### The result type of a bound handler
2278
3039
 
2279
3040
  TypeScript cannot see which handler the registry has, so the type of `emit` for an action without a handler is `void`. Tell it what the bound handler returns with `Call<Payload, Result>`. This is a promise of yours: nothing checks it against the handler.
2280
3041
 
@@ -2314,11 +3075,11 @@ On a bus without an event map you can say it where you call: `const id = bus.emi
2314
3075
  - `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`.
2315
3076
  - `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, give `createAction` the handler.
2316
3077
  - Outcome actions are emitted as the task that ran the job (also for a handler bound with `actions.handle`). `success` and `fail` are not sent after abort; `cancel` is, at the moment of the abort.
2317
- - A job without a handler is not an outcome: `emit` throws `No handler for command "..."` and nothing is sent. An action without a handler is an event and does not throw.
3078
+ - A job without a handler is not an outcome: `emit` throws `No handler for action "..."` and nothing is sent. An action without a handler is an event and does not throw.
2318
3079
  - Calling the job only builds `{ type, payload }`; the handler runs on `emit`.
2319
3080
  - 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.
2320
3081
 
2321
- ## Show a loading state with a job
3082
+ ## The state of a job
2322
3083
 
2323
3084
  **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`.
2324
3085
 
@@ -2391,7 +3152,7 @@ bus.emit(loadUser("1"));
2391
3152
 
2392
3153
  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.
2393
3154
 
2394
- ## Mount domains with modules
3155
+ ## Modules
2395
3156
 
2396
3157
  **Problem.** A domain needs several subscriptions and handler bindings. With plain `bus.on` / `actions.handle` you must remember every unsubscribe.
2397
3158
 
@@ -2484,9 +3245,9 @@ If the `setup` of one module throws, the modules of that call that were already
2484
3245
  - `spawn` — background task, aborted on unmount.
2485
3246
  - An action that carries a handler keeps answering after unmount (`use` only tracks registration). Use `handle` for a handler that must live only as long as the module.
2486
3247
 
2487
- ## Give an action or a module its own fields
3248
+ ## Extend the context of actions and modules
2488
3249
 
2489
- An action with a handler can need its own client, a module its own logger. Pass a scope, a plain object whose keys go on `ctx` (`$effects` binds functions to the task), as the last argument of `createAction`, `createAction`, `createModule` and `createJob`, and turn it on with `scopePlugin`:
3250
+ An action with a handler can need its own client, a module its own logger. Pass a scope, a plain object whose keys go on `ctx` (`$effects` binds functions to the task), as the last argument of `createAction`, `createModule` and `createJob`, and turn it on with `scopePlugin`:
2490
3251
 
2491
3252
  ```js
2492
3253
  // bus.js
@@ -2518,13 +3279,15 @@ const fakeApi = { getUser: async (id) => ({ id, name: "test" }) };
2518
3279
  bus.emit(getUser("1"), { api: fakeApi });
2519
3280
  ```
2520
3281
 
2521
- The handler gets the fields typed. Without `scopePlugin` the scope is ignored. The full picture, with jobs, actions, modules and which value wins, is in [scopes](scope.md).
3282
+ The handler gets the fields typed. Without `scopePlugin` the scope is ignored. The full picture, with jobs, actions, modules and which value wins, is in [scopes](context.md#scope-fields-for-an-action-a-module-and-a-call).
2522
3283
 
2523
3284
  ---
2524
3285
  Source: docs/stores.md
2525
3286
 
2526
3287
  # Connect Redux, Zustand, MobX, or Pinia
2527
3288
 
3289
+ > **Level E.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 to 3.
3290
+
2528
3291
  Use this page when you **already have a store** and want async work with cancellation and policies next to it. It is not a reason to pick Redux (or any store).
2529
3292
 
2530
3293
  If you do not have a store yet, use `createBus` from the [Guide](guide.md).
@@ -3198,11 +3961,26 @@ A handler gets the store from `ctx.store` and calls a mutation, whatever it hand
3198
3961
  ---
3199
3962
  Source: docs/query-libraries.md
3200
3963
 
3201
- # Use nanoeffects with React Query and SWR
3964
+ # Use nanoeffects with React Query, SWR and RTK Query
3965
+
3966
+ > **Level E.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 to 3.
3967
+
3968
+ Read this page if you fetch data with [React Query](https://tanstack.com/query) (TanStack Query), [SWR](https://swr.vercel.app) or [RTK Query](https://redux-toolkit.js.org/rtk-query/overview) and want `retry`, `timeout` and the abort of nanoeffects inside the request. It shows one action that the libraries call, then three recipes for the parts around it: a client that stops with the request, a live stream into the cache, and a refetch on a bus event.
3969
+
3970
+ You need a React project, `nanoeffects`, and one of the libraries. `api` below is your own client: replace it with your requests.
3202
3971
 
3203
- 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 action that both libraries call.
3972
+ ## On this page
3204
3973
 
3205
- You need a React project, `nanoeffects`, and one of the two libraries. `api` below is your own client: replace it with your requests.
3974
+ - [Decide who owns what](#decide-who-owns-what)
3975
+ - [Make the action](#make-the-action)
3976
+ - [Call it from React Query](#call-it-from-react-query)
3977
+ - [Call it from SWR](#call-it-from-swr)
3978
+ - [Let the bus know the result](#let-the-bus-know-the-result)
3979
+ - [Call it from RTK Query](#call-it-from-rtk-query)
3980
+ - [Give the client its signal once](#give-the-client-its-signal-once)
3981
+ - [Push a live stream into the cache](#push-a-live-stream-into-the-cache)
3982
+ - [Refetch queries when the bus says so](#refetch-queries-when-the-bus-says-so)
3983
+ - [Know the limits](#know-the-limits)
3206
3984
 
3207
3985
  ## Decide who owns what
3208
3986
 
@@ -3263,7 +4041,7 @@ import { getUser } from "./user";
3263
4041
  export function Profile({ id }) {
3264
4042
  const { isLoading, data, error } = useQuery({
3265
4043
  queryKey: ["user", id],
3266
- // emit — runs the command; { signal } ties its task to the signal of React Query
4044
+ // emit — runs the action; { signal } ties its task to the signal of React Query
3267
4045
  queryFn: ({ signal }) => bus.emit(getUser(id), { signal }),
3268
4046
  });
3269
4047
 
@@ -3313,11 +4091,266 @@ import { createAction } from "nanoeffects/actions";
3313
4091
  export const userLoaded = createAction("user/loaded");
3314
4092
  ```
3315
4093
 
3316
- Send it from the handler with `ctx.emit(userLoaded(user))`. Do not replace the action 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).
4094
+ Send it from the handler with `ctx.emit(userLoaded(user))`. Do not replace the action 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#the-state-of-a-job).
4095
+
4096
+ ## Call it from RTK Query
4097
+
4098
+ <!-- docs-check: new -->
4099
+
4100
+ RTK Query gives the `queryFn` of an endpoint a `signal` in its second argument, so the call is the same as with React Query: pass it to `emit`. This page does not set up the store; see [Stores](stores.md) for that.
4101
+
4102
+ ```js
4103
+ // bus.js
4104
+ import { createBus } from "nanoeffects";
4105
+ import { signalPlugin } from "nanoeffects/signal";
4106
+
4107
+ // signalPlugin — lets emit take { signal }
4108
+ export const bus = createBus().use(signalPlugin());
4109
+ ```
4110
+
4111
+ ```js
4112
+ // user.js
4113
+ import { createAction } from "nanoeffects/actions";
4114
+
4115
+ // createAction with a handler — emit returns what the handler returns
4116
+ export const getUser = createAction("user/get", (id, { signal }) =>
4117
+ api.getUser(id, { signal }),
4118
+ );
4119
+ ```
4120
+
4121
+ ```js
4122
+ // api-slice.js
4123
+ import { createApi, fakeBaseQuery } from "@reduxjs/toolkit/query/react";
4124
+ import { bus } from "./bus";
4125
+ import { getUser } from "./user";
4126
+
4127
+ export const userApi = createApi({
4128
+ reducerPath: "userApi",
4129
+ baseQuery: fakeBaseQuery(), // RTK Query — every endpoint brings its own queryFn
4130
+ endpoints: (build) => ({
4131
+ getUser: build.query({
4132
+ // emit — runs the action; { signal } ties its task to the request of RTK Query
4133
+ queryFn: async (id, { signal }) => {
4134
+ const user = await bus.emit(getUser(id), { signal });
4135
+ return { data: user };
4136
+ },
4137
+ }),
4138
+ }),
4139
+ });
4140
+ ```
4141
+
4142
+ ## Give the client its signal once
4143
+
4144
+ <!-- docs-check: new -->
4145
+
4146
+ Problem: the action passes `signal` to every call of `api`, and one forgotten `{ signal }` leaves a request running after the query is gone. `fromSignal` builds the client from the signal of each task, so the handler cannot forget it. Use it when many actions share one client. A single action is simpler as it is in [Make the action](#make-the-action).
4147
+
4148
+ `fromSignal` marks a field of `contextPlugin`. The client is made when a handler first reads `ctx.http`, from the `ctx.signal` of that task. More in [Context fields](context.md#build-a-field-from-the-signal-of-each-task).
4149
+
4150
+ ```js
4151
+ // bus.js
4152
+ import axios from "axios";
4153
+ import { createBus } from "nanoeffects";
4154
+ import { contextPlugin } from "nanoeffects/context";
4155
+ import { fromSignal, signalPlugin } from "nanoeffects/signal";
4156
+
4157
+ // fromSignal — http is a new axios client for each task, bound to the signal of that task
4158
+ const http = fromSignal((signal) => axios.create({ baseURL: "/api", signal }));
4159
+
4160
+ // contextPlugin — every handler gets ctx.http
4161
+ const context = contextPlugin({ http });
4162
+
4163
+ // signalPlugin — emit takes { signal }, so the signal of the query reaches the task
4164
+ export const bus = createBus().use(signalPlugin()).use(context);
4165
+ ```
4166
+
4167
+ ```js
4168
+ // user.js
4169
+ import { createAction } from "nanoeffects/actions";
4170
+
4171
+ // the handler never sees a signal: ctx.http stops when the task aborts
4172
+ export const getUser = createAction("user/get", async (id, { http }) => {
4173
+ const response = await http.get(`/users/${id}`);
4174
+ return response.data;
4175
+ });
4176
+ ```
4177
+
4178
+ ```jsx
4179
+ // Profile.jsx
4180
+ import { useQuery } from "@tanstack/react-query";
4181
+ import { bus } from "./bus";
4182
+ import { getUser } from "./user";
4183
+
4184
+ export function Profile({ id }) {
4185
+ const { data } = useQuery({
4186
+ queryKey: ["user", id],
4187
+ // emit — the signal of React Query aborts the task, and with it the request of ctx.http
4188
+ queryFn: ({ signal }) => bus.emit(getUser(id), { signal }),
4189
+ });
4190
+
4191
+ return <p>{data?.name}</p>;
4192
+ }
4193
+ ```
4194
+
4195
+ ## Push a live stream into the cache
4196
+
4197
+ <!-- docs-check: new -->
4198
+
4199
+ Problem: prices arrive from a socket, and the component should show the newest one without a refetch. The socket code emits an event on the bus; the component reads those events with `events` and writes each into the cache. Both libraries have an entry point for pushed data: `setQueryData` in React Query and `useSWRSubscription` in SWR.
4200
+
4201
+ `events` gives the payloads to a `for await` loop. `buffer: 1` keeps only the newest price while a write is running. The abort in the cleanup ends the loop and removes the listener. More in [Read an event with a `for await` loop](observable.md#read-an-event-with-a-for-await-loop).
4202
+
4203
+ ```js
4204
+ // bus.js
4205
+ import { createBus } from "nanoeffects";
4206
+
4207
+ // createBus — the bus the socket code and the components share
4208
+ export const bus = createBus();
4209
+ ```
4210
+
4211
+ ```js
4212
+ // prices.js
4213
+ import { createAction } from "nanoeffects/actions";
4214
+
4215
+ // createAction — the event the socket code emits for every new price
4216
+ export const priceChanged = createAction("price/changed");
4217
+ ```
4218
+
4219
+ ```jsx
4220
+ // Price.jsx
4221
+ import { useEffect } from "react";
4222
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
4223
+ import { events } from "nanoeffects/observable";
4224
+ import { bus } from "./bus";
4225
+ import { priceChanged } from "./prices";
4226
+
4227
+ export function Price({ symbol }) {
4228
+ const queryClient = useQueryClient();
4229
+ const { data } = useQuery({
4230
+ queryKey: ["price", symbol],
4231
+ queryFn: () => api.getPrice(symbol), // the first value; the stream keeps it fresh
4232
+ });
4233
+
4234
+ useEffect(() => {
4235
+ const controller = new AbortController();
4236
+ // events — the newest price event, one at a time
4237
+ const prices = events(bus, priceChanged, {
4238
+ signal: controller.signal,
4239
+ buffer: 1,
4240
+ });
4241
+
4242
+ async function follow() {
4243
+ for await (const price of prices) {
4244
+ if (price.symbol === symbol) {
4245
+ queryClient.setQueryData(["price", symbol], price.value); // write into the cache
4246
+ }
4247
+ }
4248
+ }
4249
+
4250
+ follow().catch((error) => {
4251
+ if (!controller.signal.aborted) throw error; // the abort ends the loop; anything else is a bug
4252
+ });
4253
+
4254
+ return () => controller.abort(); // the component left: the loop ends, the listener goes
4255
+ }, [queryClient, symbol]);
4256
+
4257
+ return <p>{data}</p>;
4258
+ }
4259
+ ```
4260
+
4261
+ SWR does the same with `useSWRSubscription` from `swr/subscription`. Its subscribe function gets `next` to push a value and returns the cleanup:
4262
+
4263
+ ```jsx
4264
+ // Price.jsx
4265
+ import useSWRSubscription from "swr/subscription";
4266
+ import { events } from "nanoeffects/observable";
4267
+ import { bus } from "./bus";
4268
+ import { priceChanged } from "./prices";
4269
+
4270
+ export function Price({ symbol }) {
4271
+ const { data } = useSWRSubscription(["price", symbol], (_key, { next }) => {
4272
+ const controller = new AbortController();
4273
+ // events — the newest price event, one at a time
4274
+ const prices = events(bus, priceChanged, {
4275
+ signal: controller.signal,
4276
+ buffer: 1,
4277
+ });
4278
+
4279
+ async function follow() {
4280
+ for await (const price of prices) {
4281
+ if (price.symbol === symbol) {
4282
+ next(null, price.value); // SWR — push the value to the hook
4283
+ }
4284
+ }
4285
+ }
4286
+
4287
+ follow().catch((error) => {
4288
+ if (!controller.signal.aborted) next(error);
4289
+ });
4290
+
4291
+ return () => controller.abort(); // SWR calls this when the last component of the key leaves
4292
+ });
4293
+
4294
+ return <p>{data}</p>;
4295
+ }
4296
+ ```
4297
+
4298
+ ## Refetch queries when the bus says so
4299
+
4300
+ <!-- docs-check: new -->
4301
+
4302
+ Problem: a save in one part of the app should refresh the lists in another. The save emits an event, and the component that owns the queries listens for it. One `AbortController` ends every listener of the component, instead of one `unbind` for each. For a single listener, `return bus.on(…)` from the effect is shorter.
4303
+
4304
+ This needs `signalPlugin`, because `{ signal }` in the options of `on` is read by the plugin. Without it the listeners stay after the component leaves.
4305
+
4306
+ ```js
4307
+ // bus.js
4308
+ import { createBus } from "nanoeffects";
4309
+ import { signalPlugin } from "nanoeffects/signal";
4310
+
4311
+ // signalPlugin — on takes { signal } and removes the listener when it aborts
4312
+ export const bus = createBus().use(signalPlugin());
4313
+ ```
4314
+
4315
+ ```js
4316
+ // user-events.js
4317
+ import { createAction } from "nanoeffects/actions";
4318
+
4319
+ // createAction — events the save code emits
4320
+ export const userSaved = createAction("user/saved");
4321
+ export const userDeleted = createAction("user/deleted");
4322
+ ```
4323
+
4324
+ ```jsx
4325
+ // UserList.jsx
4326
+ import { useEffect } from "react";
4327
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
4328
+ import { bus } from "./bus";
4329
+ import { userDeleted, userSaved } from "./user-events";
4330
+
4331
+ export function UserList() {
4332
+ const queryClient = useQueryClient();
4333
+ const { data } = useQuery({ queryKey: ["users"], queryFn: () => api.listUsers() });
4334
+
4335
+ useEffect(() => {
4336
+ const controller = new AbortController();
4337
+ const { signal } = controller;
4338
+ const refresh = () => queryClient.invalidateQueries({ queryKey: ["users"] });
4339
+
4340
+ // on with { signal } — both listeners go when the component leaves
4341
+ bus.on(userSaved, refresh, { signal });
4342
+ bus.on(userDeleted, refresh, { signal });
4343
+
4344
+ return () => controller.abort();
4345
+ }, [queryClient]);
4346
+
4347
+ return <ul>{data?.map((user) => <li key={user.id}>{user.name}</li>)}</ul>;
4348
+ }
4349
+ ```
3317
4350
 
3318
4351
  ## Know the limits
3319
4352
 
3320
- - **Without the plugin, `{ signal }` is ignored.** The action runs, but the abort does not reach it. Call `bus.use(signalPlugin())` once, where the bus is made.
4353
+ - **Without the plugin, `{ signal }` is ignored,** in `emit` and in the options of `on`. The action runs, but the abort does not reach it. Call `bus.use(signalPlugin())` once, where the bus is made.
3321
4354
  - **Only the call forms take it.** `bus.emit(getUser(id), { signal })` works; `bus.emit("user/get", id, { signal })` does not.
3322
4355
  - **A handler that returns without a promise is finished with the call**, so a signal that aborts later does not reach it. Actions that fetch are async, so this only matters for a sync handler that sets up a callback.
3323
4356
  - **Give each request its own signal for an event.** For an action with a handler or a job the link ends when it settles. For `bus.emit(action, { signal })` it stays until the signal aborts.
@@ -3330,6 +4363,8 @@ Source: docs/server.md
3330
4363
 
3331
4364
  # Stop work on a server when the client leaves
3332
4365
 
4366
+ > **Level C.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 to 3.
4367
+
3333
4368
  Read this page if you run a Node server (the examples use [Fastify](https://fastify.dev)) and want the work for a request, a socket or a queued job to stop when nobody needs it. It shows one pattern in three places: a request, a WebSocket and a worker.
3334
4369
 
3335
4370
  You need Node 18 or newer, `fastify` and `nanoeffects`. `api` below is your own client for another service: replace it with your requests.
@@ -3367,7 +4402,7 @@ The action loads a user. It tries the request up to three times, and gives up on
3367
4402
  import { createAction } from "nanoeffects/actions";
3368
4403
  import { retry, timeout } from "nanoeffects/effects";
3369
4404
 
3370
- // createAction — a command with a handler: emit returns what the handler returns
4405
+ // createAction — an action with a handler: emit returns what the handler returns
3371
4406
  export const getUser = createAction("user/get", async (id, ctx) => {
3372
4407
  // one try: the request, with the signal of timeout so it can stop it
3373
4408
  const load = ({ signal }) => api.getUser(id, { signal });
@@ -3408,7 +4443,7 @@ app.get("/users/:id", async (request, reply) => {
3408
4443
  });
3409
4444
 
3410
4445
  try {
3411
- // emit with { signal } — aborting the controller aborts the command: timeout, retry and the request
4446
+ // emit with { signal } — aborting the controller aborts the action: timeout, retry and the request
3412
4447
  const call = getUser(request.params.id);
3413
4448
  return await bus.emit(call, { signal: controller.signal });
3414
4449
  } catch (error) {
@@ -3461,7 +4496,7 @@ import { createAction } from "nanoeffects/actions";
3461
4496
  import { poll } from "nanoeffects/effects";
3462
4497
  import { latest } from "nanoeffects/policies";
3463
4498
 
3464
- // createAction — a command with a handler
4499
+ // createAction — an action with a handler
3465
4500
  // latest — a new subscription stops the old one; key — but only the old one of the same socket
3466
4501
  export const watchPrices = createAction(
3467
4502
  "prices/watch",
@@ -3536,21 +4571,35 @@ Source: docs/react.md
3536
4571
 
3537
4572
  # Build a sign-up form in React
3538
4573
 
3539
- Use this page when a React screen starts requests on user input and you need them not to race: a username check while typing, a submit button, a flaky network. It builds one registration form. The bus lives outside React; the component only subscribes to it.
4574
+ > **Level B.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 to 3.
4575
+
4576
+ The task is a registration form. While the user types a name, the form asks the backend whether it is free, and the "Sign up" button submits the form. That makes the requests race: the answer to an old input can arrive after the answer to a new one and show a wrong result. This page shows how to solve it with nanoeffects. The bus lives outside React; the component only subscribes to it.
4577
+
4578
+ Steps 1 and 2 are required, step 3 is not. The last two sections are extra.
3540
4579
 
3541
4580
  You need a React project and `nanoeffects` installed. The form talks to your own client, `api` below: replace it with your requests.
3542
4581
 
3543
- ## See what goes wrong without a bus
4582
+ ## On this page
4583
+
4584
+ - [Problems of the form without a bus](#problems-of-the-form-without-a-bus)
4585
+ - [1. The bus and the handlers](#1-the-bus-and-the-handlers)
4586
+ - [2. The form component](#2-the-form-component)
4587
+ - [3. Stop requests when the form closes](#3-stop-requests-when-the-form-closes)
4588
+ - [Show the last value of an event as state](#show-the-last-value-of-an-event-as-state)
4589
+ - [Keep a debounced handler between renders](#keep-a-debounced-handler-between-renders)
4590
+ - [Where to go next](#where-to-go-next)
4591
+
4592
+ ## Problems of the form without a bus
3544
4593
 
3545
4594
  A registration form has three problems that `useEffect` and `fetch` leave to you:
3546
4595
 
3547
- - **A stale answer.** The user types `ann`, then `anna`. The answer for `ann` arrives last and says the name is free, although `anna` is taken.
4596
+ - **A race in the name check.** The user types `ann`, then `anna`, and every input sends a "is the name free" request. The answer for `ann` arrives last and says the name is free, although `anna` is taken.
3548
4597
  - **A double submit.** A second click on "Sign up" sends a second request while the first one is running.
3549
4598
  - **A flaky network.** One failed request should be retried, but a "name is taken" answer should not.
3550
4599
 
3551
4600
  Each has a policy or an effect: `latest`, `leading`, `retry`. They live in one place, outside the component.
3552
4601
 
3553
- ## Wire the bus once
4602
+ ## 1. The bus and the handlers
3554
4603
 
3555
4604
  Keep the bus in a file of its own, and the handlers of the form in another. Create the bus once, outside any component: a bus created in the body of a component would be a new bus, without subscribers, on every render.
3556
4605
 
@@ -3607,11 +4656,11 @@ bus.on(
3607
4656
 
3608
4657
  The handlers know nothing about React. A test can emit `username/typed` and read `username/checked` without rendering anything: the same wiring is tested in [`examples/registration-form.ts`](../examples/registration-form.ts) and `src/tests/registration-form.test.ts`.
3609
4658
 
3610
- ## Connect the form
4659
+ ## 2. The form component
3611
4660
 
3612
4661
  <!-- docs-check: continue -->
3613
4662
 
3614
- The component keeps what it shows in `useState`. It subscribes in `useEffect` and emits events from its handlers. `bus.on` returns the function that unsubscribes; returning it from the effect removes the listener on unmount.
4663
+ The component keeps what it shows in `useState`. It subscribes in `useEffect` and emits events from its handlers. `bus.on` returns the function that unsubscribes; returning it from the effect removes the listener on unmount. If the component already holds an `AbortSignal`, `bus.on(event, handler, { signal })` ends the listener with it; see [`signalPlugin`](plugins.md#tie-an-emit-or-a-listener-to-an-outside-signal-signalplugin).
3615
4664
 
3616
4665
  ```jsx
3617
4666
  // SignUpForm.jsx
@@ -3678,11 +4727,11 @@ The `disabled` button is only for the look. The form stays correct without it: `
3678
4727
 
3679
4728
  In development, React StrictMode runs the effect twice (subscribe, unsubscribe, subscribe). That is safe here, because the cleanup removes exactly what the effect added.
3680
4729
 
3681
- ## Stop running work when the form closes
4730
+ ## 3. Stop requests when the form closes
3682
4731
 
3683
4732
  <!-- docs-check: continue -->
3684
4733
 
3685
- Unsubscribing stops the component from hearing results, but a request that has started keeps running. To cancel it, the component emits `form/closed` when it unmounts, and each handler that should stop listens for it with `ctx.on`:
4734
+ This step is optional. Unsubscribing stops the component from hearing results, but a request that has started keeps running. To cancel it, the component emits `form/closed` when it unmounts, and each handler that should stop listens for it with `ctx.on`:
3686
4735
 
3687
4736
  ```js
3688
4737
  import { latest } from "nanoeffects/policies";
@@ -3799,26 +4848,53 @@ export const Search = ({ wait }) => {
3799
4848
  };
3800
4849
  ```
3801
4850
 
3802
- - `onChange` keeps its identity while `wait` stays the same, so it is safe in `useEffect` and in props.
3803
- - A pending call is cancelled on unmount and when `deps` change.
3804
- - Write the policy without its callback: `debounce(300)`, not `debounce(300, callback)`. `usePolicy` puts the callback in itself, always the one of the latest render.
3805
- - To combine policies, write a function of the task: `(task) => throttle(100, latest(task))`. Here `task` is the callback that `usePolicy` keeps current; pass it, not your own function.
3806
- - `deps` stay your job: `debounce(wait)` is made again on every render, but only the first one (and the one after `deps` change) is used.
3807
- - Without a React component, use [`withPolicy`](policies.md#use-a-policy-without-a-bus).
4851
+ - `onChange` keeps its identity while `wait` stays the same, so it is safe in `useEffect` and in props.
4852
+ - A pending call is cancelled on unmount and when `deps` change.
4853
+ - Write the policy without its callback: `debounce(300)`, not `debounce(300, callback)`. `usePolicy` puts the callback in itself, always the one of the latest render.
4854
+ - To combine policies, write a function of the task: `(task) => throttle(100, latest(task))`. Here `task` is the callback that `usePolicy` keeps current; pass it, not your own function.
4855
+ - `deps` stay your job: `debounce(wait)` is made again on every render, but only the first one (and the one after `deps` change) is used.
4856
+ - Without a React component, use [`withPolicy`](policies.md#withpolicy-call-a-policy-outside-a-bus).
4857
+
4858
+ ### Write a policy without its function
4859
+
4860
+ **Problem.** `usePolicy` puts the callback of the latest render into the policy itself, so it needs a policy that has no function yet. If you passed your own function there, it would be easy to pass the wrong one.
4861
+
4862
+ Leave the function out and the policy returns a wrapper. The wrapper takes a function and gives back the same policy as the direct call:
4863
+
4864
+ ```js
4865
+ import { debounce } from "nanoeffects/policies";
4866
+
4867
+ const runSearch = async (query) => {
4868
+ await api.search(query);
4869
+ };
4870
+
4871
+ // debounce(300) — no function yet: returns a wrapper
4872
+ const wrap = debounce(300);
4873
+
4874
+ // the wrapper takes the function; same as debounce(300, runSearch)
4875
+ const search = wrap(runSearch);
4876
+ ```
4877
+
4878
+ - It works for `latest()`, `leading()`, `debounce(ms)`, `throttle(ms)` and `queue()`, with the options as the last argument: `queue({ concurrency: 2 })`, `debounce(300, { leading: true })`.
4879
+ - Each time you apply a wrapper to a function, you get a new policy with its own state.
4880
+ - To combine policies, nest them: `throttle(100, latest(fn))`.
4881
+ - With `key`, annotate the payload of the key function in TypeScript: `latest({ key: (user: User) => user.id })`. Without the annotation the wrapper cannot know the payload type.
3808
4882
 
3809
4883
  ## Where to go next
3810
4884
 
3811
4885
  - The policies behind this form: [Policies](policies.md).
3812
- - A policy as a plain function without a bus: [Use a policy without a bus](policies.md#use-a-policy-without-a-bus).
4886
+ - A policy as a plain function without a bus: [`withPolicy`: call a policy outside a bus](policies.md#withpolicy-call-a-policy-outside-a-bus).
3813
4887
  - `retry`, `timeout` and the other effects: [Effects](effects.md).
3814
4888
  - A state library instead of `useState`: [Stores](stores.md).
3815
- - React Query or SWR instead of your own state: [Use nanoeffects with React Query and SWR](query-libraries.md).
4889
+ - React Query or SWR instead of your own state: [Use nanoeffects with React Query, SWR and RTK Query](query-libraries.md).
3816
4890
 
3817
4891
  ---
3818
4892
  Source: docs/chat-example.md
3819
4893
 
3820
4894
  # Build a chat with entities, features, modules and a small server
3821
4895
 
4896
+ > **Level D.** Read first: [Actions, jobs and modules](actions.md).
4897
+
3822
4898
  Use this page when your app has several features that react to each other and you want to see where each piece of logic goes. The example is a chat with rooms, a history that loads, messages that can fail to send, a search across all rooms, and new messages that arrive on their own. A small Fastify server in the example answers every request, and it misbehaves on a known schedule, so each problem on this page really happens.
3823
4899
 
3824
4900
  It is a level D example. Start with [Actions, jobs and modules](actions.md) for the API, or with the `domains-ts` template in [Start a project from a template](starter.md#start-with-domains) for a smaller project.
@@ -3833,6 +4909,18 @@ npm run dev
3833
4909
 
3834
4910
  `npm run dev` starts the server and Vite together. Start it with `CHAOS=off npm run dev` to turn the failures off.
3835
4911
 
4912
+ ## On this page
4913
+
4914
+ - [Read how the server fails](#read-how-the-server-fails)
4915
+ - [See how the files are split](#see-how-the-files-are-split)
4916
+ - [Load a history that must not race](#load-a-history-that-must-not-race)
4917
+ - [Send a message that can fail](#send-a-message-that-can-fail)
4918
+ - [Search where one room is slow](#search-where-one-room-is-slow)
4919
+ - [Wait for a room before marking a result](#wait-for-a-room-before-marking-a-result)
4920
+ - [Poll for new messages](#poll-for-new-messages)
4921
+ - [Know where each value lives](#know-where-each-value-lives)
4922
+ - [Where to go next](#where-to-go-next)
4923
+
3836
4924
  ## Read how the server fails
3837
4925
 
3838
4926
  The server keeps its data in memory. These rules are on purpose, and they are the same every run, so you can follow each section below by doing what the table says:
@@ -3895,7 +4983,7 @@ import { latest } from "nanoeffects/policies";
3895
4983
  export const fetchHistory = latest((roomId, { signal }) => api.loadHistory(roomId, { signal }));
3896
4984
  ```
3897
4985
 
3898
- The module gives the job its work and keeps the state from the outcomes, so an aborted load clears the flag too (see [Show a loading state with a job](actions.md#show-a-loading-state-with-a-job)):
4986
+ The module gives the job its work and keeps the state from the outcomes, so an aborted load clears the flag too (see [Show a loading state with a job](actions.md#the-state-of-a-job)):
3899
4987
 
3900
4988
  ```js
3901
4989
  // message-module.js
@@ -4075,6 +5163,8 @@ Source: docs/chat-widget.md
4075
5163
 
4076
5164
  # Build a chat widget that listens only while it is open
4077
5165
 
5166
+ > **Level D.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 to 3.
5167
+
4078
5168
  Use this page when a site has a chat with a bot behind a launcher button, and the chat must cost nothing while it is closed. Closed means no subscriptions on the bus, no polling, no keyboard listener, and no request still running. Opening turns all of it on, and closing turns all of it off in one call.
4079
5169
 
4080
5170
  The code is in [`examples/chat-widget`](../examples/chat-widget): plain DOM and TypeScript, a fake bot in the browser, no server. Run it on [StackBlitz](https://stackblitz.com/github/nanoeffects/nanoeffects/tree/main/examples/chat-widget), or on your machine:
@@ -4105,7 +5195,7 @@ It is a level D example: the logic lives in one module, and the page only mounts
4105
5195
  | `src/chat-handlers.ts` | `askBot` (`timeout` and `retry` inside) and `watchOperator` (`poll`): plain `async` functions that know nothing about the page. |
4106
5196
  | `src/chat-module.ts` | The list of what is alive while the chat is open. |
4107
5197
  | `src/panel.ts` | The view: shows the lines, sends the texts, returns one function that removes it. |
4108
- | `src/main.ts` | The launcher: the only file that knows both the module and the panel. |
5198
+ | `src/main.ts` | The launcher: the only file that knows both the module and the panel; it listens for `closeRequested` only while the chat is open. |
4109
5199
 
4110
5200
  ## Bind everything in one module
4111
5201
 
@@ -4155,7 +5245,7 @@ Three details that make the cleanup complete:
4155
5245
 
4156
5246
  ## Open and close from the launcher
4157
5247
 
4158
- `app.mount` returns the function that unmounts the module. The launcher puts it together with the one that removes the panel.
5248
+ `app.mount` returns the function that unmounts the module. `bus.on` returns the one that unsubscribes. The launcher puts them together with the one that removes the panel into a single `close`.
4159
5249
 
4160
5250
  <!-- docs-check: skip - the other files of the example are in examples/chat-widget -->
4161
5251
  ```js
@@ -4171,7 +5261,10 @@ let close = null; // set while the chat is open
4171
5261
  const open = () => {
4172
5262
  const stopModule = app.mount(chatModule); // subscriptions, polling and Escape start
4173
5263
  const stopPanel = renderPanel(document.body);
5264
+ // the module emits closeRequested on Escape; nothing does while the chat is closed
5265
+ const stopEscape = bus.on(closeRequested, () => close?.());
4174
5266
  close = () => {
5267
+ stopEscape();
4175
5268
  stopPanel();
4176
5269
  stopModule(); // everything the module started ends, a running request included
4177
5270
  close = null;
@@ -4185,18 +5278,15 @@ button.addEventListener("click", () => {
4185
5278
  open();
4186
5279
  }
4187
5280
  });
4188
-
4189
- // the launcher stays subscribed: it must hear Escape to close the chat
4190
- bus.on(closeRequested, () => close?.());
4191
5281
  ```
4192
5282
 
4193
- The launcher's own `bus.on` is the only subscription that lives while the chat is closed, and it does nothing then: `close` is `null`.
5283
+ While the chat is closed the launcher holds no subscription at all: it only has the click listener of the button, which is a DOM listener, not a listener of the bus.
4194
5284
 
4195
5285
  ## Check that closing cleans up
4196
5286
 
4197
5287
  1. Open the chat and send a text. The bot answers after about a second. Every third question fails, and the retry asks again.
4198
5288
  2. Send a text and close the chat before the answer arrives. No line appears, also when you open the chat again.
4199
- 3. Look at the box "Listeners on the bus": it shows `bus.get(event).length` for four events and updates when the chat opens or closes. Closed: only `chat/closeRequested` has a listener (the launcher). Open: `chat/sent`, `chat/messageAdded` and `chat/typing` have one each as well. That is the proof that nothing is left subscribed.
5289
+ 3. Look at the box "Listeners on the bus": it shows `bus.get(event).length` for four events and updates when the chat opens or closes. Closed: every event shows `0`, and so does "all events". Open: each of the four has one listener, and "all events" is `4`. That is the proof that nothing is left subscribed.
4200
5290
  4. Wait 10 s with the chat closed. The operator is not polled: no line from the operator appears.
4201
5291
  5. Press `Escape` with the chat closed. Nothing happens. Open the chat and press it again: it closes.
4202
5292
  6. Open the chat again. The old lines are back, and the polling runs.
@@ -4209,11 +5299,77 @@ The launcher's own `bus.on` is the only subscription that lives while the chat i
4209
5299
  - Replace the polling by a WebSocket in a spawned task: the abort closes it the same way (see [`examples/trades`](../examples/trades) for a socket under `retry` and `latest`).
4210
5300
  - Watch the whole thing in the [trace](tracing.md): the module, the bot task, the retries and the polling are tasks of one tree.
4211
5301
 
5302
+ ---
5303
+ Source: docs/examples.md
5304
+
5305
+ # Examples
5306
+
5307
+ > **Levels A to F.** Read first: [README](README.md#the-ladder-from-a-to-f).
5308
+
5309
+ Examples to run in the browser and to read. Every folder lives in `examples/` of the repository and is type-checked with the project. Two of them are explained step by step: [Build a chat](chat-example.md) and [Build a chat widget](chat-widget.md).
5310
+
5311
+ ## Try it in the browser
5312
+
5313
+ All three are the same search box: type fast and only one request starts (`debounce`); type again while a request runs and the older one is aborted (`latest`). StackBlitz runs them with no install.
5314
+
5315
+ | Folder | What is in it |
5316
+ | ------ | ------------- |
5317
+ | [`playground`](../examples/playground) · [StackBlitz](https://stackblitz.com/github/nanoeffects/nanoeffects/tree/main/examples/playground) | Plain DOM and one file: the smallest setup. The page lists the requests, so the abort is visible. |
5318
+ | [`playground-react`](../examples/playground-react) · [StackBlitz](https://stackblitz.com/github/nanoeffects/nanoeffects/tree/main/examples/playground-react) | The same search in a React component, with the bus outside React. |
5319
+ | [`playground-devtools`](../examples/playground-devtools) · [StackBlitz](https://stackblitz.com/github/nanoeffects/nanoeffects/tree/main/examples/playground-devtools) | The same search with its emits and stopped calls sent to Redux DevTools. |
5320
+
5321
+ ## Bigger projects
5322
+
5323
+ React and TypeScript projects that show where logic goes when an app has several features.
5324
+
5325
+ | Folder | What is in it |
5326
+ | ------ | ------------- |
5327
+ | [`chat`](../examples/chat) · [StackBlitz](https://stackblitz.com/github/nanoeffects/nanoeffects/tree/main/examples/chat) | Rooms, history under `latest`, ordered sends under `queue` and `retry`, search under `allSettled`, polling with `poll`. Explained in [Build a chat](chat-example.md). |
5328
+ | [`pokedex`](../examples/pokedex) · [StackBlitz](https://stackblitz.com/github/nanoeffects/nanoeffects/tree/main/examples/pokedex) | Real requests to PokeAPI: a search with `debounce`, `latest`, `timeout` and `retry`, abilities with `allSettled`. |
5329
+ | [`spells`](../examples/spells) · [StackBlitz](https://stackblitz.com/github/nanoeffects/nanoeffects/tree/main/examples/spells) | The smallest example: a search in one file, one action that carries its handler. |
5330
+ | [`chat-widget`](../examples/chat-widget) · [StackBlitz](https://stackblitz.com/github/nanoeffects/nanoeffects/tree/main/examples/chat-widget) | A chat behind a launcher button: opening mounts one module, closing unmounts it. Explained in [Build a chat widget](chat-widget.md). |
5331
+ | [`counter`](../examples/counter) · [StackBlitz](https://stackblitz.com/github/nanoeffects/nanoeffects/tree/main/examples/counter) | The counter of the redux-saga tutorial, without a store and with Redux Toolkit. |
5332
+ | [`rickandmorty`](../examples/rickandmorty) · [StackBlitz](https://stackblitz.com/github/nanoeffects/nanoeffects/tree/main/examples/rickandmorty) | Characters with React Query: requests are actions that get its abort signal. |
5333
+ | [`trades`](../examples/trades) · [StackBlitz](https://stackblitz.com/github/nanoeffects/nanoeffects/tree/main/examples/trades) | A live stream over a WebSocket: `retry`, `latest` and `throttle`. |
5334
+
5335
+ ## Backend examples
5336
+
5337
+ Small [Fastify](https://fastify.dev) apps with no database. They need Node 18 or newer: run `npm install` and `npm start` in the folder. More in [Stop work on a server](server.md).
5338
+
5339
+ | Folder | What is in it |
5340
+ | ------ | ------------- |
5341
+ | [`api-server`](../examples/api-server) | A server over PokeAPI: `timeout`, `retry`, `allSettled`, and an abort when the client leaves. |
5342
+ | [`job-worker`](../examples/job-worker) | A queue worker: `queue`, `retry`, `timeout`, counts from the outcomes of a job. |
5343
+ | [`ws-server`](../examples/ws-server) | A WebSocket server: one controller per socket, `latest` with a `key`. |
5344
+
5345
+ ## Recipes
5346
+
5347
+ Each file is one job, written as a short program with a comment on every nanoeffects call.
5348
+
5349
+ | File | Job | Docs |
5350
+ | ---- | --- | ---- |
5351
+ | [`search.ts`](../examples/search.ts) | A search box that waits for quiet and aborts the older request. | [Policies](policies.md) |
5352
+ | [`backoff.ts`](../examples/backoff.ts) | Retry a flaky request with a growing pause. | [Effects](effects.md#backoff) |
5353
+ | [`registration-form.ts`](../examples/registration-form.ts) | A sign-up form: a username check that does not race. | [React form](react.md) |
5354
+ | [`scope.ts`](../examples/scope.ts) | An action with its own effects and a test that replaces one. | [Working with context](context.md) |
5355
+ | [`modules.ts`](../examples/modules.ts) | Domain logic with no UI, mounted and unmounted as one unit. | [Actions](actions.md) |
5356
+ | [`redux.ts`](../examples/redux.ts) | Redux Toolkit actions that also reach the bus. | [State managers](stores.md) |
5357
+ | [`guide.ts`](../examples/guide.ts) | The code of every step of the guide. | [Guide](guide.md) |
5358
+
5359
+ ## TypeScript projects
5360
+
5361
+ | Folder | What is in it |
5362
+ | ------ | ------------- |
5363
+ | [`typescript`](../examples/typescript) | Name the event map once and use it in modules and actions. |
5364
+ | [`typescript-context`](../examples/typescript-context) | Give `ctx` the fields of your bus and type them from one place. |
5365
+
4212
5366
  ---
4213
5367
  Source: docs/vue.md
4214
5368
 
4215
5369
  # Use nanoeffects in a Vue app
4216
5370
 
5371
+ > **Level B.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 to 3.
5372
+
4217
5373
  Use this page when your app is in Vue 3 and a component has to talk to the bus: tell it that a value changed, and show the result of an event. `vuePlugin` gives you two functions for that. To see them in a running project, start from the `vue-ts` template in [Start a project from a template](starter.md#start-with-vue).
4218
5374
 
4219
5375
  You need Vue 3.3 or newer and `nanoeffects` installed. If you do not know what the bus is, read the [Guide](guide.md) first.
@@ -4306,7 +5462,7 @@ In TypeScript the type of the `ref` comes from the event: with `createAction<str
4306
5462
  ## Where to go next
4307
5463
 
4308
5464
  - The same two jobs for React: [Build a sign-up form in React](react.md).
4309
- - A policy in a component (a debounced save): in `<script setup>` make it once with [`withPolicy`](policies.md#use-a-policy-without-a-bus); Vue runs the setup once, so it needs no hook.
5465
+ - A policy in a component (a debounced save): in `<script setup>` make it once with [`withPolicy`](policies.md#withpolicy-call-a-policy-outside-a-bus); Vue runs the setup once, so it needs no hook.
4310
5466
  - Keep values in Pinia: [Connect Redux, Zustand, MobX, or Pinia](stores.md).
4311
5467
 
4312
5468
  ---
@@ -4314,6 +5470,8 @@ Source: docs/svelte.md
4314
5470
 
4315
5471
  # Use nanoeffects in a Svelte app
4316
5472
 
5473
+ > **Level B.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 to 3.
5474
+
4317
5475
  Use this page when your app is in Svelte 5 and a component has to show the result of an event. `sveltePlugin` gives you one function for that, `eventStore`. To see it in a running project, start from the `svelte-ts` template in [Start a project from a template](starter.md#start-with-svelte).
4318
5476
 
4319
5477
  You need Svelte 5 or newer and `nanoeffects` installed. If you do not know what the bus is, read the [Guide](guide.md) first.
@@ -4392,16 +5550,39 @@ bus.on(searchLoaded, (titles) => foundTitles.set(titles));
4392
5550
  ## Where to go next
4393
5551
 
4394
5552
  - The same job for React: [Build a sign-up form in React](react.md); for Vue: [Use nanoeffects in a Vue app](vue.md).
4395
- - A policy in a component (a debounced save): in the script of a component make it once with [`withPolicy`](policies.md#use-a-policy-without-a-bus); Svelte runs the script once, so it needs no hook.
5553
+ - A policy in a component (a debounced save): in the script of a component make it once with [`withPolicy`](policies.md#withpolicy-call-a-policy-outside-a-bus); Svelte runs the script once, so it needs no hook.
4396
5554
 
4397
5555
  ---
4398
5556
  Source: docs/context.md
4399
5557
 
4400
- # Put your own fields on every task
5558
+ # Working with context
5559
+
5560
+ > **Level D.** Read first: [Guide](guide.md#1-create-a-bus).
4401
5561
 
4402
- Optional: put a store, client, or logger on every task. Skip until many handlers need the same dependency.
5562
+ Optional: put a store, client or logger on every task, or give fields to one action, module or call. Skip until many handlers need the same dependency.
4403
5563
 
4404
- ## Pass values at runtime
5564
+ ## On this page
5565
+
5566
+ - [Context fields](#context-fields)
5567
+ - [Pass values at runtime](#pass-values-at-runtime)
5568
+ - [Type the fields (optional)](#type-the-fields-optional)
5569
+ - [Build a field from the signal of each task](#build-a-field-from-the-signal-of-each-task)
5570
+ - [Scope: fields for an action, a module and a call](#scope-fields-for-an-action-a-module-and-a-call)
5571
+ - [Turn scopes on](#turn-scopes-on)
5572
+ - [Give an action its own fields](#give-an-action-its-own-fields)
5573
+ - [Bind an effect to the task](#bind-an-effect-to-the-task)
5574
+ - [Replace a field for one call](#replace-a-field-for-one-call)
5575
+ - [Give an action, a job or a module its own fields](#give-an-action-a-job-or-a-module-its-own-fields)
5576
+ - [Which value wins](#which-value-wins)
5577
+ - [Keep a generic effect generic](#keep-a-generic-effect-generic)
5578
+ - [Type the fields of a scope](#type-the-fields-of-a-scope)
5579
+ - [Limits](#limits)
5580
+
5581
+ ## Context fields
5582
+
5583
+ ### Pass values at runtime
5584
+
5585
+ <!-- docs-check: new -->
4405
5586
 
4406
5587
  ```js
4407
5588
  import { createBus } from "nanoeffects";
@@ -4414,15 +5595,17 @@ bus.on("save", (draft, { store }) => {
4414
5595
  });
4415
5596
  ```
4416
5597
 
4417
- That is enough in JavaScript. The fields appear on handlers, `ctx.run`, `ctx.on`, actions, modules, and child tasks of effects and policies.
5598
+ Here `store` is your own object: a store, an API client, a logger, anything. That is enough in JavaScript. The fields appear on handlers, `ctx.run`, `ctx.on`, actions, modules, and child tasks of effects and policies.
4418
5599
 
4419
5600
  Library fields (`signal`, `emit`, `run`, …) win on name clashes. `context` may be a function `(task) => fields` when the value depends on the task.
4420
5601
 
4421
- To get `race`, `all`, `take` and the like on every task without passing `ctx`, see [`effectsPlugin`](effects.md#put-selected-effects-on-ctx).
5602
+ To get `race`, `all`, `take` and the like on every task without passing `ctx`, see [`effectsPlugin`](effects.md#effectsplugin).
4422
5603
 
4423
- To give fields to **one** command, action, job or module, or to replace them for one `emit`, see [scopes](scope.md).
5604
+ To give fields to **one** action, job or module, or to replace them for one `emit`, see [scopes](context.md#scope-fields-for-an-action-a-module-and-a-call).
4424
5605
 
4425
- ## Type the fields (optional)
5606
+ ### Type the fields (optional)
5607
+
5608
+ <!-- docs-check: new -->
4426
5609
 
4427
5610
  In TypeScript, `contextPlugin` (from `nanoeffects/context`) puts the fields on every task of **this** bus and takes their types from the value you pass. The event map holds only events. It does not wire anything across the app — another bus can have different fields or none.
4428
5611
 
@@ -4444,7 +5627,41 @@ bus.on("save", (draft, { store }) => store.put(draft));
4444
5627
  - A name that the bus already has is a type error. The fields of the library (`signal`, `emit`, `run`, …) win at runtime, and a field that the context already has is not replaced.
4445
5628
  - The plugin returns the same bus, so keep the result of the chain: `const bus = createBus().use(contextPlugin({ store }))`.
4446
5629
 
4447
- ### Name the fields in the event map instead
5630
+ ### Build a field from the signal of each task
5631
+
5632
+ <!-- docs-check: new -->
5633
+
5634
+ Problem: an HTTP client or a database handle should stop when the task is aborted. Without help, every handler passes `ctx.signal` to it by hand, and one forgotten call leaves a request running.
5635
+
5636
+ `fromSignal(make)` from `nanoeffects/signal` marks a field of `contextPlugin` as a value that is built from the signal of the task. It does not run `make` for the bus: it runs it for each task that reads the field, once, with the `ctx.signal` of that task.
5637
+
5638
+ ```js
5639
+ import { createBus } from "nanoeffects";
5640
+ import { contextPlugin } from "nanoeffects/context";
5641
+ import { fromSignal } from "nanoeffects/signal";
5642
+ import axios from "axios";
5643
+
5644
+ // contextPlugin — every task gets these fields
5645
+ // fromSignal — http is made from the signal of the task that reads ctx.http
5646
+ const bus = createBus().use(
5647
+ contextPlugin({
5648
+ logger: console, // a plain field: the same object for every task
5649
+ http: fromSignal((signal) => axios.create({ signal })),
5650
+ }),
5651
+ );
5652
+
5653
+ bus.on("user/open", async (id, ctx) => {
5654
+ const response = await ctx.http.get(`/users/${id}`); // aborts with the task
5655
+ ctx.logger.log(response.data);
5656
+ });
5657
+ ```
5658
+
5659
+ - `make` runs at the first read, so a handler that never uses `ctx.http` pays nothing for it.
5660
+ - A task that `ctx.run` starts builds its own value from its own signal, so aborting the child stops only the child's requests.
5661
+ - The field is not copied by `{ ...ctx }` and `Object.keys(ctx)` does not list it. Read `ctx.http` first if you copy a context.
5662
+ - Without `contextPlugin` a `fromSignal` marker is only an object.
5663
+
5664
+ #### Name the fields in the event map instead
4448
5665
 
4449
5666
  `createBus<Events>({ context: { store } })` also types the fields, but through the event map: `WithContext` is checker-only and says which fields the handlers of this bus have. Use it when you cannot call `use`, for example for the bus that `createEffectsMiddleware` makes.
4450
5667
 
@@ -4457,10 +5674,7 @@ type Events = { save: Draft } & WithContext<{ store: Store }>;
4457
5674
  const bus = createBus<Events>({ context: { store } });
4458
5675
  ```
4459
5676
 
4460
- ---
4461
- Source: docs/scope.md
4462
-
4463
- # Pin context fields to an action, a module or a call
5677
+ ## Scope: fields for an action, a module and a call
4464
5678
 
4465
5679
  Optional, for domain logic. Use it when one action needs its own client, one module its own logger, or one test its own fake, and a field on the whole bus ([context fields](context.md)) is too wide.
4466
5680
 
@@ -4468,7 +5682,9 @@ A **scope** is a plain object. Every key goes on `ctx` as it is (`cache`, `api`,
4468
5682
 
4469
5683
  You give a scope to the thing that uses it. A call can replace it. Nothing else changes: handlers still get `(payload, ctx)`.
4470
5684
 
4471
- ## Turn scopes on
5685
+ ### Turn scopes on
5686
+
5687
+ <!-- docs-check: new -->
4472
5688
 
4473
5689
  Scopes are a plugin, so a bus that does not use them pays nothing.
4474
5690
 
@@ -4486,7 +5702,9 @@ The bus is in one file and the actions in files named after their domain, so the
4486
5702
  - Without `scopePlugin` a scope is **ignored**: no error and no warning. If a field is missing from `ctx`, check this first.
4487
5703
  - Use it in any order with the other plugins: a scope wins over `effectsPlugin`, `contextPlugin` and `createBus({ context })`.
4488
5704
 
4489
- ## Give an action its own fields
5705
+ ### Give an action its own fields
5706
+
5707
+ <!-- docs-check: new -->
4490
5708
 
4491
5709
  <!-- docs-check: continue -->
4492
5710
 
@@ -4498,7 +5716,7 @@ import { createAction } from "nanoeffects/actions";
4498
5716
  export const getUser = createAction(
4499
5717
  "user/get",
4500
5718
  (id, { api, cache }) => {
4501
- // api and cache are on ctx for this command only
5719
+ // api and cache are on ctx for this action only
4502
5720
  return cache.get(id) ?? api.getUser(id);
4503
5721
  },
4504
5722
  { api, cache: new Map() },
@@ -4514,7 +5732,9 @@ const user = await bus.emit(getUser("1")); // the handler has api and cache
4514
5732
 
4515
5733
  The listeners of `"user/get"` get the same fields when the action is emitted, because the fields belong to the call.
4516
5734
 
4517
- ## Bind an effect to the task
5735
+ ### Bind an effect to the task
5736
+
5737
+ <!-- docs-check: new -->
4518
5738
 
4519
5739
  An effect is a function with `{ signal }` as its first parameter. In `$effects` it is bound to the task, so it stops with the task.
4520
5740
 
@@ -4559,7 +5779,9 @@ const result = await bus.emit(loadFeed("/feed"));
4559
5779
  - A function under any other key is copied as it is; a function in `$effects` is bound.
4560
5780
  - `$effects` is the one name with a meaning. A field called `effects` is an ordinary field: `{ effects: { retries: 3 } }` puts that object on `ctx`, and nothing in it is bound.
4561
5781
 
4562
- ## Replace a field for one call
5782
+ ### Replace a field for one call
5783
+
5784
+ <!-- docs-check: new -->
4563
5785
 
4564
5786
  The second argument of `emit` is a scope for the tasks of **that** call. It is the usual way to give a test a fake.
4565
5787
 
@@ -4601,7 +5823,9 @@ Rules for the second argument:
4601
5823
  - It can replace `$effects` too: `{ $effects: { delay: instantDelay } }`.
4602
5824
  - Fields of the library (`signal`, `emit`, …) in it are ignored.
4603
5825
 
4604
- ## Give an action, a job or a module its own fields
5826
+ ### Give an action, a job or a module its own fields
5827
+
5828
+ <!-- docs-check: new -->
4605
5829
 
4606
5830
  ```js
4607
5831
  // user.js
@@ -4640,20 +5864,24 @@ If the outcome action (`success` or `fail`) has a scope of its own, its listener
4640
5864
 
4641
5865
  A module scope does not leak: a listener registered outside the module does not see it.
4642
5866
 
4643
- ## Which value wins
5867
+ ### Which value wins
5868
+
5869
+ <!-- docs-check: new -->
4644
5870
 
4645
5871
  When several places set the same field, the strongest one wins:
4646
5872
 
4647
5873
  | Strength | Where it is set |
4648
5874
  | -------- | --------------- |
4649
5875
  | 1 (strongest) | the second argument of `emit` |
4650
- | 2 | the scope of the action, command or job |
5876
+ | 2 | the scope of the action or job |
4651
5877
  | 3 | the scope of the module that owns the handler |
4652
5878
  | 4 (weakest) | the bus: `createBus({ context })`, `contextPlugin`, `effectsPlugin` |
4653
5879
 
4654
5880
  The fields of the library (`signal`, `abort`, `emit`, `on`, `off`, `run`, `type`, `payload`) are never replaced. The name `traceGroupId` is reserved: the tracer reads it, and it is not put on `ctx` ([Follow one flow](tracing.md#follow-one-flow-with-a-tracegroupid)). A child task (`ctx.run`, an attempt of `retry`) has the fields of its parent.
4655
5881
 
4656
- ## Keep a generic effect generic
5882
+ ### Keep a generic effect generic
5883
+
5884
+ <!-- docs-check: new -->
4657
5885
 
4658
5886
  In `$effects` a function loses its first parameter, and TypeScript cannot keep a generic through that step: `<T>(items: T[]) => T` becomes `(items: unknown[]) => unknown`. JavaScript code is not affected. If you want the exact type, use one of two ways.
4659
5887
 
@@ -4695,7 +5923,9 @@ const pick = createAction(
4695
5923
 
4696
5924
  `Effect<F>` and `effect` change what the checker sees. At run time the function is the same.
4697
5925
 
4698
- ## Type the fields (optional)
5926
+ ### Type the fields of a scope
5927
+
5928
+ <!-- docs-check: new -->
4699
5929
 
4700
5930
  A handler of an action, job or module with a scope sees the fields of the scope in `ctx`, typed from the values you gave. You write no `WithContext`.
4701
5931
 
@@ -4719,7 +5949,9 @@ const getUser = createAction(
4719
5949
  - A field that no scope and no bus declares has no type to check against, so `emit(call, { unknown: 1 })` is an error in TypeScript.
4720
5950
  - `bus.on(creator, fn)` for an action with a scope types `ctx` with its fields.
4721
5951
 
4722
- ## Limits
5952
+ ### Limits
5953
+
5954
+ <!-- docs-check: new -->
4723
5955
 
4724
5956
  - No plugin, no scope: see [Turn scopes on](#turn-scopes-on).
4725
5957
  - The fields of the bus are not in the handler of an action made with `createAction(…, scope)` unless you type `ctx` yourself; the fields of the scope are.
@@ -4732,6 +5964,8 @@ Source: docs/oop.md
4732
5964
 
4733
5965
  # Use the bus from a class
4734
5966
 
5967
+ > **Level D.** Read first: [Guide](guide.md#1-create-a-bus) step 1.
5968
+
4735
5969
  Use this page when **your domain logic lives in classes**: a service, a store or a controller that needs the bus inside its methods. There are two ways to give a class the methods of a bus, and both remember what each instance subscribed to, so `dispose()` ends it all:
4736
5970
 
4737
5971
  | You want | Use |
@@ -4739,7 +5973,24 @@ Use this page when **your domain logic lives in classes**: a service, a store or
4739
5973
  | every `new Service()` to be an independent emitter | `extends Emitter` |
4740
5974
  | the instances to share a bus, or to use plugins | `extends extendBus(bus)` |
4741
5975
 
4742
- If your logic is plain functions, stay with [modules](actions.md#mount-domains-with-modules): a module is the same idea without a class.
5976
+ If your logic is plain functions, stay with [modules](actions.md#modules): a module is the same idea without a class.
5977
+
5978
+ ## On this page
5979
+
5980
+ - [The problem without it](#the-problem-without-it)
5981
+ - [Make an independent emitter with `Emitter`](#make-an-independent-emitter-with-emitter)
5982
+ - [Type the events (optional)](#type-the-events-optional)
5983
+ - [Give a class the methods of a shared bus](#give-a-class-the-methods-of-a-shared-bus)
5984
+ - [Extend the result with no class of your own](#extend-the-result-with-no-class-of-your-own)
5985
+ - [Let the class make its bus](#let-the-class-make-its-bus)
5986
+ - [End the subscriptions with `dispose`](#end-the-subscriptions-with-dispose)
5987
+ - [Stream an event with a `Subject`](#stream-an-event-with-a-subject)
5988
+ - [Keep a method of your own](#keep-a-method-of-your-own)
5989
+ - [Subscribe a method with `@On` and `@Once`](#subscribe-a-method-with-on-and-once)
5990
+ - [Decorate the class with `@WithBus`](#decorate-the-class-with-withbus)
5991
+ - [Pass a method by hand](#pass-a-method-by-hand)
5992
+ - [Type the instances (optional)](#type-the-instances-optional)
5993
+ - [What is not done](#what-is-not-done)
4743
5994
 
4744
5995
  ## The problem without it
4745
5996
 
@@ -5148,6 +6399,8 @@ Source: docs/typescript.md
5148
6399
 
5149
6400
  # Use nanoeffects in a TypeScript app with many files
5150
6401
 
6402
+ > **Level D.** Read first: [Guide](guide.md#10-add-types-when-you-want-checking-optional).
6403
+
5151
6404
  Read this page if your app is in TypeScript and the handlers and modules live in different files. It shows how to write an action with a handler so that TypeScript infers its payload and result, and what to add when the handlers need the fields of your bus (`ctx.store`, the effects of `effectsPlugin`). For one file or for JavaScript you do not need it.
5152
6405
 
5153
6406
  ## Write an action with its handler
@@ -5171,7 +6424,7 @@ export const getUser = createAction(
5171
6424
  },
5172
6425
  );
5173
6426
 
5174
- // createModule: m.use binds the handler the command carries, while the module is mounted
6427
+ // createModule: m.use binds the handler the action carries, while the module is mounted
5175
6428
  export const userModule = createModule("user", (m) => {
5176
6429
  m.use(getUser);
5177
6430
  });
@@ -5320,6 +6573,8 @@ Source: docs/migrating-from-saga.md
5320
6573
 
5321
6574
  # Coming from redux-saga
5322
6575
 
6576
+ > **Level E, for readers who know redux-saga.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 to 3.
6577
+
5323
6578
  Read this page if you know redux-saga and want to find the same job in nanoeffects. It maps the names, shows one saga rewritten, and lists what has no equivalent. It does not teach the library: start with the [Guide](guide.md).
5324
6579
 
5325
6580
  ## Find the equivalent
@@ -5419,8 +6674,21 @@ Source: docs/migrating-from-effector.md
5419
6674
 
5420
6675
  # Coming from Effector
5421
6676
 
6677
+ > **Level E, for readers who know Effector.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 to 3.
6678
+
5422
6679
  Read this page if your project uses Effector and you want to know where nanoeffects fits. They overlap in one place: events that start effects. Effector also holds state; nanoeffects does not. It maps the names, shows one effect both ways, and lists what has no equivalent.
5423
6680
 
6681
+ ## On this page
6682
+
6683
+ - [Split the work](#split-the-work)
6684
+ - [Find the equivalent](#find-the-equivalent)
6685
+ - [Rewrite an effect](#rewrite-an-effect)
6686
+ - [Keep the state in Zustand](#keep-the-state-in-zustand)
6687
+ - [Rewrite a form](#rewrite-a-form)
6688
+ - [Replace the log with a request](#replace-the-log-with-a-request)
6689
+ - [Use both in one app](#use-both-in-one-app)
6690
+ - [What is different](#what-is-different)
6691
+
5424
6692
  ## Split the work
5425
6693
 
5426
6694
  | Job | Use |
@@ -5439,7 +6707,7 @@ nanoeffects has no store. `createStore` can be replaced by any store you like, f
5439
6707
  | event call `loaded(user)` | `bus.emit("loaded", user)` |
5440
6708
  | `createEffect(handler)` | a handler: `bus.on(event, handler)` |
5441
6709
  | effect with `done` and `fail` | [`createJob`](actions.md): emits the success and fail actions |
5442
- | `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)) |
6710
+ | `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#the-state-of-a-job)) |
5443
6711
  | `sample({ clock, fn, target })` | `bus.on(clock, handler)` and `ctx.emit(target, …)` |
5444
6712
  | `sample({ source: $store, clock })` | read the store from a [context field](context.md) in the handler |
5445
6713
  | `sample({ filter })` | an `if` in the handler |
@@ -5737,7 +7005,7 @@ bus.on(
5737
7005
  ## What is different
5738
7006
 
5739
7007
  - **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)).
5740
- - **`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).
7008
+ - **`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#the-state-of-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).
5741
7009
  - **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.
5742
7010
  - **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.
5743
7011
  - **No graph.** `sample` and `forward` build a static graph. A bus subscribes handlers at runtime and the call order is the order of `on`.
@@ -5747,9 +7015,11 @@ bus.on(
5747
7015
  ---
5748
7016
  Source: docs/migrating-from-mitt.md
5749
7017
 
5750
- # Coming from mitt, nanoevents or EventEmitter
7018
+ # Coming from mitt and nanoevents
7019
+
7020
+ > **Level A, for readers who know mitt or nanoevents.** Read first: nothing: the bus is the same idea.
5751
7021
 
5752
- 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.
7022
+ Read this page if your app uses a small event emitter (`mitt`, `nanoevents`, `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.
5753
7023
 
5754
7024
  ## When to switch
5755
7025
 
@@ -5764,8 +7034,8 @@ If handlers stay synchronous, keep your emitter. nanoeffects costs about 1.4 kB
5764
7034
 
5765
7035
  ## Find the equivalent
5766
7036
 
5767
- | mitt / EventEmitter | nanoeffects |
5768
- | ------------------- | ----------- |
7037
+ | mitt / nanoevents | nanoeffects |
7038
+ | ----------------- | ----------- |
5769
7039
  | `emitter.on("a", handler)` | `bus.on("a", handler)` |
5770
7040
  | `emitter.off("a", handler)` | call the function that `bus.on` returned, or `bus.off("a")` for all handlers of `a` |
5771
7041
  | `emitter.emit("a", payload)` | `bus.emit("a", payload)` |
@@ -5850,6 +7120,8 @@ Source: docs/migrating-from-rxjs.md
5850
7120
 
5851
7121
  # Use nanoeffects next to RxJS
5852
7122
 
7123
+ > **Level E, for readers who know RxJS.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 to 3.
7124
+
5853
7125
  Read this page if your project uses RxJS and you wonder what nanoeffects takes over. It does not replace RxJS. The two split the work: RxJS shapes streams of values, nanoeffects runs the side effects that events start. It maps the operators, shows a search box and a polling loop both ways, and lists what stays in RxJS.
5854
7126
 
5855
7127
  ## Split the work
@@ -6049,23 +7321,340 @@ Source: docs/semantics.md
6049
7321
 
6050
7322
  # Semantics
6051
7323
 
7324
+ > **Level F.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 to 6.
7325
+
6052
7326
  Guarantees and sharp edges. Read when something surprises you; not required to start.
6053
7327
 
7328
+ ## Five rules to know first
7329
+
7330
+ 1. A handler is `(payload, ctx)`. Use `ctx.signal`, `ctx.emit` and `ctx.on`; the rest of `ctx` is optional.
7331
+ 2. Pass `ctx.signal` to what the handler calls (`fetch`, a client), so an abort stops the work.
7332
+ 3. An aborted task delivers no result and emits nothing.
7333
+ 4. `emit` is synchronous: the first handler error is thrown, unless `onError` is set.
7334
+ 5. Rejections of async handlers are not handled without `onError`.
7335
+
7336
+ Details of each rule and the rest:
7337
+
6054
7338
  - **An aborted task delivers no result.** If the task aborts before a promise settles, the promise rejects with the abort reason, even if the value was just produced in the same tick. `latest`, `debounce`, `all`, `race`, `take`, and the rest follow this rule.
6055
7339
  - **`emit` is a plain, synchronous loop** (see [Errors](errors.md)). A `ctx.emit` inside a handler is the same call: without `onError`, an error of a nested handler is thrown into the handler that emitted; with `onError` it goes there and `ctx.emit` does not throw.
6056
7340
  - **Rejections of async handlers** are not handled for you. Without `onError` they are unhandled rejections, which end a Node process by default. Use `onError` for async handlers.
6057
7341
  - **`leading` releases its lock when the task aborts**, not only when `fn` settles. A `fn` that ignores the abort signal can then overlap with the next run — pass `ctx.signal` to what `fn` does.
6058
7342
  - **Effects start their function synchronously**, so an event emitted right after the call reaches a `take` inside it.
6059
7343
 
7344
+ ---
7345
+ Source: docs/pass-the-signal.md
7346
+
7347
+ # Keep the signal through a handler
7348
+
7349
+ > **Level B.** Read first: [Policies](policies.md#latest).
7350
+
7351
+ A policy such as `latest` cancels an older call by aborting its `ctx.signal`. The handler stops only if every step that waits gets that signal. One request without it keeps running, and its result arrives after the newer one. This page lists the ways the signal gets lost, what catches each one before the code runs (a linter) and while it runs (`devPlugin`), and how to fix it.
7352
+
7353
+ ## On this page
7354
+
7355
+ - [Know what the signal does](#know-what-the-signal-does)
7356
+ - [Fix a handler that loses the signal](#fix-a-handler-that-loses-the-signal)
7357
+ - [Pass the signal when it is hard](#pass-the-signal-when-it-is-hard)
7358
+ - [Turn on the checks](#turn-on-the-checks)
7359
+ - [Choose which calls are checked](#choose-which-calls-are-checked)
7360
+ - [Count lost signals in a test](#count-lost-signals-in-a-test)
7361
+ - [Prove it with a test](#prove-it-with-a-test)
7362
+ - [Know what the checks cannot see](#know-what-the-checks-cannot-see)
7363
+
7364
+ ## Know what the signal does
7365
+
7366
+ `ctx.signal` is an `AbortSignal`. The policy aborts it when the call is cancelled (a newer call of `latest`, a `debounce` that was replaced, an unmounted module). A step stops when you give it the signal: `fetch(url, { signal: ctx.signal })`, `api.search(query, { signal: ctx.signal })`, or the whole `ctx` for an effect, `retry(ctx, ...)`. Everything else keeps running. The bus drops what a cancelled handler emits with `ctx.emit`, so the loss shows up late and quietly.
7367
+
7368
+ ## Fix a handler that loses the signal
7369
+
7370
+ The two shapes you meet first, and the fix:
7371
+
7372
+ ```js
7373
+ import { createBus } from "nanoeffects";
7374
+ import { latest } from "nanoeffects/policies";
7375
+
7376
+ const bus = createBus();
7377
+
7378
+ // 1. ctx is taken and the signal is lost: the request cannot stop
7379
+ bus.on(
7380
+ "search",
7381
+ latest(async (query, ctx) => {
7382
+ const items = await api.search(query);
7383
+ ctx.emit("results", items);
7384
+ }),
7385
+ );
7386
+
7387
+ // 2. one step passes it and another does not: the first one cannot stop
7388
+ bus.on(
7389
+ "feed",
7390
+ latest(async (query, ctx) => {
7391
+ const news = await api.news(query);
7392
+ const posts = await api.posts(query, { signal: ctx.signal });
7393
+ ctx.emit("results", { news, posts });
7394
+ }),
7395
+ );
7396
+
7397
+ // the fix: every step that waits gets the signal
7398
+ bus.on(
7399
+ "feed/fixed",
7400
+ latest(async (query, ctx) => {
7401
+ const news = await api.news(query, { signal: ctx.signal });
7402
+ const posts = await api.posts(query, { signal: ctx.signal });
7403
+ ctx.emit("results", { news, posts });
7404
+ }),
7405
+ );
7406
+ ```
7407
+
7408
+ ## Pass the signal when it is hard
7409
+
7410
+ Each row is a situation where the signal is easy to lose. The right column says how to keep it. "Lint" is the rule from [`examples/lint`](https://github.com/nanoeffects/nanoeffects/tree/main/examples/lint) that flags it, and "devPlugin" is the warning at run time ([details](hmr.md#find-a-handler-that-ignores-the-signal)).
7411
+
7412
+ | Situation | What to do | Lint | devPlugin |
7413
+ | --------- | ---------- | :--: | :-------: |
7414
+ | The handler has no `ctx`: `latest(async (query) => ...)` | Take `(query, ctx)` and pass `ctx.signal` | yes | |
7415
+ | `ctx` is taken and never used for the request | Pass `{ signal: ctx.signal }` to each request | yes | yes |
7416
+ | One step passes the signal, another does not | Pass it to every step | yes | yes |
7417
+ | The signal is in a variable: `const options = { signal: ctx.signal }` | Nothing, a variable that holds it counts | accepted | |
7418
+ | Calls run together: `Promise.all([a(), b()])` | Pass it to each call, not only one | yes | |
7419
+ | `Promise.all(ids.map((id) => load(id)))` | Pass it inside the callback | yes | |
7420
+ | The step is inside an effect: `retry(ctx, () => api.search(q))` | The callback is not given the signal. Pass `{ signal: ctx.signal }` in it, or use the child context it gets | yes | |
7421
+ | `timeout(ctx, 1000, (child) => api.search(q))` | Pass `child`, or `child.signal`, to the request | yes | |
7422
+ | `ctx.run((child) => api.search(q))` | Pass `child` | yes | |
7423
+ | A pause: `await new Promise((r) => setTimeout(r, 1000))` | Use `delay(ctx, 1000)` | yes | |
7424
+ | A stream: `for await (const x of api.stream(q))` | Pass the signal to `api.stream` | yes | |
7425
+ | A chain: `api.news(q).then(...)` | Pass the signal to the first call | yes | |
7426
+ | Another signal: `AbortSignal.timeout(5000)`, or the signal of your own `AbortController` | Join it with the handler's: `AbortSignal.any([ctx.signal, AbortSignal.timeout(5000)])` | yes | |
7427
+ | Reading a response body: `await response.json()` | Nothing, the signal of the request stops it | accepted | |
7428
+ | A library that takes no signal (a cache, a local database) | List it in `ignore`, or check only the calls on your list with `only`. With type information the rule skips a function that takes no signal on its own | `ignore`, `only`, types | |
7429
+ | A function takes the signal and never uses it: `search(query, options) { return fetch(url); }` | Pass the signal on inside it. Only type information finds this in the linter; a test finds it for sure | types | |
7430
+ | The signal is wired by hand: `signal.addEventListener("abort", ...)` | Nothing, the handler is not checked step by step | accepted | |
7431
+ | `bus.emit` in a handler | Use `ctx.emit`: only it is dropped after the handler was cancelled | yes | |
7432
+ | Results emitted after the cancel | The handler ignored the signal. Pass it, or check `ctx.signal.aborted` | | yes |
7433
+ | `ctx.on` after the cancel | The same | | yes |
7434
+
7435
+ The pause and the effect rows in code:
7436
+
7437
+ ```js
7438
+ import { createBus } from "nanoeffects";
7439
+ import { delay, retry, timeout } from "nanoeffects/effects";
7440
+ import { latest } from "nanoeffects/policies";
7441
+
7442
+ const bus = createBus();
7443
+
7444
+ bus.on(
7445
+ "search",
7446
+ latest(async (query, ctx) => {
7447
+ // delay — a pause that stops with ctx, not new Promise(setTimeout)
7448
+ await delay(ctx, 300);
7449
+
7450
+ // retry — the attempts stop with ctx, but the request needs the signal too
7451
+ const items = await retry(ctx, () => api.search(query, { signal: ctx.signal }));
7452
+
7453
+ // timeout — the callback gets a child context: give it to the request
7454
+ const details = await timeout(ctx, 5000, (child) => api.details(query, child));
7455
+
7456
+ ctx.emit("results", { items, details });
7457
+ }),
7458
+ );
7459
+ ```
7460
+
7461
+ Calls that run together, and a signal that joins another:
7462
+
7463
+ ```js
7464
+ import { createBus } from "nanoeffects";
7465
+ import { latest } from "nanoeffects/policies";
7466
+
7467
+ const bus = createBus();
7468
+
7469
+ bus.on(
7470
+ "feed",
7471
+ latest(async (query, ctx) => {
7472
+ // each call of the group gets the signal, not only the first
7473
+ const [news, posts] = await Promise.all([
7474
+ api.news(query, { signal: ctx.signal }),
7475
+ api.posts(query, { signal: ctx.signal }),
7476
+ ]);
7477
+
7478
+ // AbortSignal.any — stops with the handler or after 5 seconds, whichever comes first
7479
+ const signal = AbortSignal.any([ctx.signal, AbortSignal.timeout(5000)]);
7480
+ const details = await api.details(query, { signal });
7481
+
7482
+ ctx.emit("results", { news, posts, details });
7483
+ }),
7484
+ );
7485
+ ```
7486
+
7487
+ ## Turn on the checks
7488
+
7489
+ **While you write.** [`examples/lint`](https://github.com/nanoeffects/nanoeffects/tree/main/examples/lint) has a rule for ESLint and oxlint (one file, no dependency) and two GritQL plugins for Biome. Copy the file, turn the rule on, and give it the names of calls that cannot be cancelled:
7490
+
7491
+ <!-- docs-check: skip - a config file of the reader's project -->
7492
+ ```js
7493
+ // eslint.config.mjs
7494
+ import nanoeffects from "./nanoeffects-lint.mjs";
7495
+
7496
+ export default [
7497
+ {
7498
+ files: ["**/*.{js,mjs,cjs,jsx,ts,tsx}"],
7499
+ plugins: { nanoeffects },
7500
+ rules: {
7501
+ "nanoeffects/policy-handler-uses-signal": [
7502
+ "warn",
7503
+ // calls that take no signal and are fine to wait for
7504
+ { ignore: ["cache.get", "storage.*"] },
7505
+ ],
7506
+ },
7507
+ },
7508
+ ];
7509
+ ```
7510
+
7511
+ For oxlint list the file in `jsPlugins` of `.oxlintrc.json` and use the same rule name. For Biome list the two `.grit` files in `plugins`. The Biome plugins read text, so they flag the direct steps (the first rows above) and miss the deeper ones: the calls of `Promise.all`, the callbacks of effects, `for await`, a pause, and the key of an options object.
7512
+
7513
+ **While you run.** `devPlugin` warns once when a handler emits, or subscribes with `ctx.on`, after its task was cancelled:
7514
+
7515
+ ```js
7516
+ import { createBus } from "nanoeffects";
7517
+ import { devPlugin } from "nanoeffects/dev";
7518
+
7519
+ const bus = createBus();
7520
+
7521
+ // devPlugin — warns about a handler that kept running after it was cancelled; nothing in production
7522
+ bus.use(devPlugin());
7523
+ ```
7524
+
7525
+ ## Choose which calls are checked
7526
+
7527
+ The rule cannot tell, from the call alone, whether the function takes a signal. Three ways to tell it:
7528
+
7529
+ - **`ignore`** lists the calls to skip, by name: `ignore: ["cache.get", "storage.*"]`. Use it when most calls take a signal and a few cannot.
7530
+ - **`only`** lists the calls to check, by name: `only: ["api.*", "fetch"]`. Anything else is left alone, so a call that is not on the list is never reported. Use it when you know which clients take a signal. A call on both lists is skipped, and a pause that nothing can cancel (`await new Promise(...)`) is reported whatever `only` says.
7531
+ - **Type information** needs no list. With `@typescript-eslint/parser` and a `project`, the rule reads the function it calls. A call that does not get the signal is reported only when the function takes one (a parameter typed `AbortSignal`, or an options object with `signal`), so `cache.get(key)` is left alone. A call that passes the signal is reported when the function is in your sources and never uses it: the parameter is unused, or only other options are read from it. A signal forwarded to another function of yours is followed up to three calls deep, and a function from a library is trusted. A callee typed `any` is reported as before.
7532
+
7533
+ <!-- docs-check: skip - a config file of the reader's project -->
7534
+ ```js
7535
+ // eslint.config.mjs
7536
+ import parser from "@typescript-eslint/parser";
7537
+ import nanoeffects from "./nanoeffects-lint.mjs";
7538
+
7539
+ export default [
7540
+ {
7541
+ files: ["**/*.{ts,tsx}"],
7542
+ // the parser gives the rule the type checker of your project
7543
+ languageOptions: { parser, parserOptions: { project: "./tsconfig.json" } },
7544
+ plugins: { nanoeffects },
7545
+ rules: { "nanoeffects/policy-handler-uses-signal": "warn" },
7546
+ },
7547
+ ];
7548
+ ```
7549
+
7550
+ oxlint and Biome do not give a rule type information, so they use the syntax and the lists. The type-aware mode needs the `typescript` package with the compiler API (version 5 today; the native version 7 has none), as `@typescript-eslint/parser` does.
7551
+
7552
+ ## Count lost signals in a test
7553
+
7554
+ `getLostSignalsCount(bus)` from `nanoeffects/dev` returns how many signals the handlers of a bus lost: each emit or `ctx.on` that a handler made after its task was cancelled. The bus needs `devPlugin`. Run the flow the way a user would, cancel it, and check that the count is 0:
7555
+
7556
+ ```js
7557
+ import { createBus } from "nanoeffects";
7558
+ import { devPlugin, getLostSignalsCount, getLostSignals } from "nanoeffects/dev";
7559
+ import { latest } from "nanoeffects/policies";
7560
+ import { expect, test } from "vitest";
7561
+
7562
+ test("a newer search leaves no lost signals", async () => {
7563
+ const bus = createBus();
7564
+ // devPlugin — records every emit that a cancelled handler made; log hides its warnings in the test
7565
+ bus.use(devPlugin({ log: () => {} }));
7566
+ bus.on(
7567
+ "search",
7568
+ latest(async (query, ctx) => {
7569
+ const items = await api.search(query, { signal: ctx.signal });
7570
+ ctx.emit("results", items);
7571
+ }),
7572
+ );
7573
+
7574
+ bus.emit("search", "a");
7575
+ bus.emit("search", "ab"); // cancels the first call
7576
+
7577
+ // wait — how long to let the handlers finish before counting, in ms
7578
+ const count = await getLostSignalsCount(bus, { wait: 50 });
7579
+
7580
+ expect(count).toBe(0);
7581
+ });
7582
+ ```
7583
+
7584
+ `getLostSignals(bus)` gives the list instead of the count: `{ kind: "emit" | "on", handler, event }` for each, so a failing test says which handler and which event. Without `devPlugin` (or with `abandoned: false`) it throws and says so. In a production build both functions are empty: the count is 0.
7585
+
7586
+ It counts what a handler does after the cancel. A request that a handler started and that the cancel did not stop is not counted unless the handler then emits; the next section finds those.
7587
+
7588
+ ## Prove it with a test
7589
+
7590
+ A linter reads code; it cannot know what a function does with the signal. A test can. The idea: replace `fetch` with a server that answers when the test lets it and stops when the request's signal aborts, run the handler, cancel it in the middle of each step, and list the requests that did not stop. No timers are involved, so the result does not depend on how fast the machine is.
7591
+
7592
+ <!-- docs-check: skip - a test file of the reader's project -->
7593
+ ```js
7594
+ // search.cancel.test.js
7595
+ import { createBus } from "nanoeffects";
7596
+ import { latest } from "nanoeffects/policies";
7597
+ import { expect, test } from "vitest";
7598
+ // copy prove-cancel.js from examples/lint; it needs no package
7599
+ import { requestsThatKeptRunning } from "./prove-cancel.js";
7600
+
7601
+ test("a newer search stops the older one at every step", async () => {
7602
+ const bus = createBus();
7603
+ bus.on(
7604
+ "search",
7605
+ latest(async (query, ctx) => {
7606
+ const news = await api.news(query, { signal: ctx.signal });
7607
+ const posts = await api.posts(query, { signal: ctx.signal });
7608
+ ctx.emit("results", { news, posts });
7609
+ }),
7610
+ );
7611
+
7612
+ // for each step: start a call, cancel it with a newer one, and collect the requests that did not stop
7613
+ const kept = await requestsThatKeptRunning((value) => bus.emit("search", value));
7614
+
7615
+ // empty when every request got a signal that aborted with the handler
7616
+ expect(kept).toEqual([]);
7617
+ });
7618
+ ```
7619
+
7620
+ `kept` has the url of each request that went on, the step where the handler was cancelled, and whether the request got a signal at all (`hadSignal: false`) or one that is not the handler's (`hadSignal: true`). This finds what the linter cannot: `api.posts` that takes `{ signal }` and ignores it, an `AbortSignal.timeout` in place of the handler's signal, a request in a branch you did not think of. It checks the boundary, `fetch`; for another client (axios, a database driver) stub that client in the same way. The repository tests the helper on handlers that forward the signal, ignore it, drop it at the third step, and use a signal of their own.
7621
+
7622
+ ## Know what the checks cannot see
7623
+
7624
+ - **A handler that is not written in place.** `latest(searchHandler)` and `latest(withLogging(async (query, ctx) => ...))` are not read by the linter.
7625
+ - **A callee that ignores the signal you pass.** Without type information the rule sees that you passed it, not what the callee does with it. With it, the rule reads callees in your sources; a library is trusted. The test above checks the real requests.
7626
+ - **A handler that never emits.** `devPlugin` sees a lost signal by an emit after the cancel; a handler that only writes elsewhere is seen by the linter alone.
7627
+ - **Shadowed names.** The rule uses no scope analysis, so an inner variable named `signal` can hide a lost signal.
7628
+
6060
7629
  ---
6061
7630
  Source: docs/tracing.md
6062
7631
 
6063
7632
  # Trace tasks
6064
7633
 
7634
+ > **Level F.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 and 2.
7635
+
6065
7636
  Find out what your app did and why: which events were emitted, which calls a policy cancelled or dropped, which request failed. You see it in the browser console, in Redux DevTools, or in the Performance panel.
6066
7637
 
6067
7638
  Tracing is optional. A bus works the same without it, and it costs almost nothing until you turn it on. If you only want to see what happens while you develop, jump to [Show every emit](#show-every-emit) and [Configure Redux DevTools](#configure-redux-devtools); the rest of the page is for apps that need to follow a flow or turn tracing off in a build.
6068
7639
 
7640
+ ## On this page
7641
+
7642
+ - [Attach a tracer](#attach-a-tracer)
7643
+ - [Show every emit](#show-every-emit)
7644
+ - [Show which subscription began a chain](#show-which-subscription-began-a-chain)
7645
+ - [Follow one flow with a traceGroupId](#follow-one-flow-with-a-tracegroupid)
7646
+ - [Name a listener](#name-a-listener)
7647
+ - [Keep the chain: `ctx.emit`, not `bus.emit`](#keep-the-chain-ctxemit-not-busemit)
7648
+ - [Turn the names off](#turn-the-names-off)
7649
+ - [Use built-in loggers](#use-built-in-loggers)
7650
+ - [Cut volume in production](#cut-volume-in-production)
7651
+ - [Keep the last events for an incident](#keep-the-last-events-for-an-incident)
7652
+ - [Configure Redux DevTools](#configure-redux-devtools)
7653
+ - [Find loops with `graphLogger`](#find-loops-with-graphlogger)
7654
+ - [Send safe data to a tool](#send-safe-data-to-a-tool)
7655
+ - [Write your own logger](#write-your-own-logger)
7656
+ - [Drop tracing from a build](#drop-tracing-from-a-build)
7657
+
6069
7658
  ## Attach a tracer
6070
7659
 
6071
7660
  Pass `trace` to `createBus`. Make it with `createTracer` from `nanoeffects/trace`, which takes a logger (a function) or an array of them. The tracing code lives in that entry, so a bus without `trace` does not carry it.
@@ -6101,7 +7690,7 @@ Phases:
6101
7690
  - `cancel` — aborted after it started (superseded by `latest`, parent aborted, lost a `race`).
6102
7691
  - `drop` — never started (`leading` while busy, a `throttle` call replaced by a newer one, a `debounce` call replaced before its delay ended).
6103
7692
 
6104
- `all` and `race` are traced when you pass them the handler `ctx`: the group is one task; every starter is a child `run` task.
7693
+ `all` and `race` are traced when you pass them the handler `ctx`: the group is one task; every function of the group is a child `run` task.
6105
7694
 
6106
7695
  ```js
6107
7696
  import { createBus } from "nanoeffects";
@@ -6148,7 +7737,7 @@ The event changes nothing else: the other tasks keep their `parentId`, `rootId`
6148
7737
 
6149
7738
  ## Show which subscription began a chain
6150
7739
 
6151
- **Problem.** `useEmitter` and `bus.emitter` put an event on the bus from outside any task, such as a render or the callback of a store. In the trace that event has no sign of where it came from. With many such subscriptions it is hard to tell which one started a chain of commands and retries.
7740
+ **Problem.** `useEmitter` and `bus.emitter` put an event on the bus from outside any task, such as a render or the callback of a store. In the trace that event has no sign of where it came from. With many such subscriptions it is hard to tell which one started a chain of actions and retries.
6152
7741
 
6153
7742
  **When.** The bus has a tracer and the project has several `useEmitter` or `bus.emitter` calls. The names come from `tracePlugin`: use it on the bus, or the `traceId` is ignored.
6154
7743
 
@@ -6176,8 +7765,8 @@ In a component, the same option goes in the options of the hook: `useEmitter(nam
6176
7765
 
6177
7766
  ```
6178
7767
  [nanoeffects] Profile · run start
6179
- [nanoeffects] user/save · command start
6180
- [nanoeffects] user/save · command done
7768
+ [nanoeffects] user/save · action start
7769
+ [nanoeffects] user/save · action done
6181
7770
  [nanoeffects] Profile · run done
6182
7771
  ```
6183
7772
 
@@ -6217,7 +7806,7 @@ const pay = createAction("checkout/pay", async () => {}, {
6217
7806
  ```
6218
7807
 
6219
7808
  ```
6220
- [nanoeffects] [checkout] checkout/pay · command start
7809
+ [nanoeffects] [checkout] checkout/pay · action start
6221
7810
  ```
6222
7811
 
6223
7812
  The emitters take it too: `useEmitter(cartChanged, cart, { traceId: "Cart", traceGroupId: "checkout" })`. `traceGroupId` works without `scopePlugin`, and needs `tracePlugin`.
@@ -6247,7 +7836,7 @@ bus.once("name/changed", (name) => console.log(name), { traceId: "auditLog" });
6247
7836
 
6248
7837
  ```
6249
7838
  [nanoeffects] saveNotifier · run start
6250
- [nanoeffects] user/save · command start
7839
+ [nanoeffects] user/save · action start
6251
7840
  [nanoeffects] saveNotifier · run done
6252
7841
  ```
6253
7842
 
@@ -6292,7 +7881,8 @@ const bus = createBus({
6292
7881
  | `reduxDevtools()` | Sends the emits to Redux DevTools, and with `policies` the stages of the handlers as `type/effect/phase`; state is `{ running }` |
6293
7882
  | `consoleLogger()` | Prints one line per phase, indented by task depth |
6294
7883
  | `performanceLogger()` | User Timing measures for the browser Performance panel |
6295
- | `createTraceRecorder()` | Keeps events in `recorder.events`, for tests |
7884
+ | `createTraceRecorder()` | Keeps events in `recorder.events`: all of them for tests, or with `{ capacity }` the last N ([see below](#keep-the-last-events-for-an-incident)) |
7885
+ | `graphLogger()` | Builds the map of which event leads to which, finds loops while the app runs, and writes the map as Mermaid, Graphviz or JSON ([see below](#find-loops-with-graphlogger)) |
6296
7886
 
6297
7887
  ### Cut volume in production
6298
7888
 
@@ -6305,11 +7895,63 @@ createTracer(logger, {
6305
7895
  });
6306
7896
  ```
6307
7897
 
6308
- - `sample` is decided once per root task; every task below it follows, so a trace is never cut in the middle. Unsampled tasks cost almost nothing.
6309
- - `filter` runs on each event before any logger sees it.
7898
+ - `sample` is decided once per root task; every task below it follows, so a trace is never cut in the middle. Unsampled tasks cost almost nothing.
7899
+ - `filter` runs on each event before any logger sees it.
7900
+
7901
+ ### Keep the last events for an incident
7902
+
7903
+ `createTraceRecorder()` with no option keeps every event, so a long session fills memory. That is fine in a test and wrong in a running app. Give it a `capacity` and it keeps only the last N events (a ring buffer): when something goes wrong, you still have what led up to it.
7904
+
7905
+ <!-- docs-check: new -->
7906
+
7907
+ ```js
7908
+ // bus.js
7909
+ import { createBus } from "nanoeffects";
7910
+ import { createTraceRecorder, createTracer } from "nanoeffects/trace";
7911
+
7912
+ // A logger that keeps the last 500 events and drops the older ones.
7913
+ export const recorder = createTraceRecorder({ capacity: 500 });
7914
+
7915
+ // Feed the recorder from the tracer.
7916
+ const trace = createTracer(recorder);
7917
+
7918
+ export const bus = createBus({ trace });
7919
+ ```
7920
+
7921
+ ```js
7922
+ // incident.js
7923
+ import { recorder } from "./bus";
7924
+
7925
+ // Keep what is safe to send. An event also has `payload` and `error`.
7926
+ const withoutPayload = ({ id, parentId, type, effect, phase }) => ({
7927
+ id,
7928
+ parentId,
7929
+ type,
7930
+ effect,
7931
+ phase,
7932
+ });
7933
+
7934
+ window.addEventListener("error", () => {
7935
+ const events = recorder.events; // a snapshot, oldest event first
7936
+ const safe = events.map(withoutPayload);
7937
+ const body = JSON.stringify(safe);
7938
+ navigator.sendBeacon("/api/incident", body);
7939
+ });
7940
+ ```
7941
+
7942
+ What to know before you send the events:
7943
+
7944
+ - **`payload` and `error` can hold private data**: what a user typed, tokens, personal text. The recorder keeps them as they are and does not redact. Choose what to send, as `withoutPayload` does.
7945
+ - **There is no time on an event.** You get the order, not the duration. To add a time, wrap the recorder in a logger that stamps `performance.now()` on a copy of the event.
7946
+ - **An old `parentId` can point at an event that was pushed out.** When you build a tree from the events, treat an unknown `parentId` as a root.
7947
+ - **`recorder.events` is a snapshot with a `capacity`.** Each read builds a new array, so read it once at the moment you need it. Without a `capacity` it is one live array.
7948
+ - **A low `sample` thins the buffer.** `createTracer(recorder, { sample: 0.05 })` records 5% of root tasks, so the buffer may hold little when the incident comes. See [Cut volume in production](#cut-volume-in-production).
7949
+ - **A `capacity` that is not a positive integer** (`0`, a negative number, `NaN`) means no limit.
6310
7950
 
6311
7951
  ### Configure Redux DevTools
6312
7952
 
7953
+ <!-- docs-check: new -->
7954
+
6313
7955
  Read-only (the bus owns no state, so there is no time travel). Does nothing when the extension is not installed. Has its own instance in the DevTools picker, apart from your store.
6314
7956
 
6315
7957
  Quiet by default, so you see what happened before you see how:
@@ -6373,6 +8015,58 @@ reduxDevtools({
6373
8015
 
6374
8016
  With two quick `search` emits and a `latest` handler, the default list is `search`, `search`. With `policies: true` it adds the `latest` stages of each call, for example `search/latest/cancel` for the first one.
6375
8017
 
8018
+ ### Find loops with `graphLogger`
8019
+
8020
+ Two handlers that emit each other's events never stop. `graphLogger` is a logger that finds such a loop while the app runs, writes a report once for it, and keeps the map of the events so you can draw it.
8021
+
8022
+ ```js
8023
+ // bus.js
8024
+ import { createBus } from "nanoeffects";
8025
+ import { createTracer, graphLogger, tracePlugin } from "nanoeffects/trace";
8026
+
8027
+ // graphLogger — the map of events and the loop finder, in one logger
8028
+ export const graph = graphLogger({ name: "app" });
8029
+
8030
+ // createTracer — sends every task of the bus to the logger
8031
+ const trace = createTracer(graph);
8032
+
8033
+ // tracePlugin — also reports each emit; the logger works without it, with a coarser map
8034
+ export const bus = createBus({ trace }).use(tracePlugin());
8035
+
8036
+ // graph.cycles() lists the loops found; graph.toMermaid() draws the map
8037
+ ```
8038
+
8039
+ It reports a node that is in one chain three times, a storm of emits from outside any handler, and a chain that starts too many tasks. It writes to `console.warn`, or to `onCycle`, or to Redux DevTools with `devtools: true`. Nothing is stopped: to cut a loop use [`loopGuardPlugin`](loop-guard.md). The full walk-through, with scenarios you can run, is [Find and cut event loops](debugging-loops.md).
8040
+
8041
+ ### Send safe data to a tool
8042
+
8043
+ A payload or an error can be anything: a class instance, a `Map`, a circular object, a function, an `Error` with a `cause`. A tool that gets such a value as it is can fail or show nothing. The built-in loggers make sure that what leaves your process is plain data:
8044
+
8045
+ | Logger | What it does with a payload and an error |
8046
+ | ------ | ---------------------------------------- |
8047
+ | `reduxDevtools()` | Makes both plain data with `safeSerialize` before the extension gets them. An `Error` is sent as its message. `serialize` changes this. |
8048
+ | `performanceLogger()` | Sends ids and the phase; an error is text. A payload is not sent. |
8049
+ | `consoleLogger()` | Passes the value to `console`, which prints any value and keeps it open for you to inspect. |
8050
+ | `createTraceRecorder()` | Keeps the events as they are. `recorder.toJSON()` and `JSON.stringify(recorder)` give plain data. |
8051
+ | `graphLogger()` | Exports no payload unless you ask, and then makes it plain. |
8052
+
8053
+ `safeSerialize(value)` is exported for your own logger. It never throws and never loops: an `Error` keeps its name, message, stack and cause, a cycle becomes `"[Circular]"`, a `Map` becomes `{ $map }`, a `Set` becomes `{ $set }`, an instance of a class keeps its own fields and its name as `$class`, and a getter is never called. A value that is too deep or too big is cut.
8054
+
8055
+ ```js
8056
+ import { createTracer, reduxDevtools, safeSerialize } from "nanoeffects/trace";
8057
+
8058
+ const trace = createTracer(
8059
+ reduxDevtools({
8060
+ name: "app",
8061
+ // serialize — runs on every payload and error before the extension gets it;
8062
+ // hide a secret first, then make the rest plain
8063
+ serialize: (value) => safeSerialize(value, { maxDepth: 4 }),
8064
+ }),
8065
+ );
8066
+ ```
8067
+
8068
+ `serialize: false` sends values as they are, which is faster and can fail on a circular one.
8069
+
6376
8070
  ### Write your own logger
6377
8071
 
6378
8072
  ```ts
@@ -6402,13 +8096,417 @@ conditions: ["notrace"]
6402
8096
 
6403
8097
  In a build from the sources, `define: { __NANOEFFECTS_TRACE__: "false" }` does the same. With the condition set, that build’s types have no `trace` option: `createBus({ trace })` is a type error. `nanoeffects/trace` has no `notrace` build. The saving is about 0.4 kB gzip of the whole library.
6404
8098
 
8099
+ ---
8100
+ Source: docs/debugging-loops.md
8101
+
8102
+ # Find and cut event loops
8103
+
8104
+ > **Level F.** Read first: [Trace tasks](tracing.md#attach-a-tracer). To only cut a loop, go to [`loopGuardPlugin`](loop-guard.md).
8105
+
8106
+ A loop is two handlers that emit each other's events, or one handler that emits its own. This page finds a loop, shows it, and stops it. Every scenario below is a runnable file in [`examples/debug`](https://github.com/nanoeffects/nanoeffects/tree/main/examples/debug), and a page with buttons and Redux DevTools is in [`examples/debug-playground`](https://github.com/nanoeffects/nanoeffects/tree/main/examples/debug-playground).
8107
+
8108
+ ## On this page
8109
+
8110
+ - [Start from the symptom](#start-from-the-symptom)
8111
+ - [Find a loop](#find-a-loop)
8112
+ - [Read a report](#read-a-report)
8113
+ - [Draw the graph](#draw-the-graph)
8114
+ - [See the loop in Redux DevTools](#see-the-loop-in-redux-devtools)
8115
+ - [Find a loop that no chain shows](#find-a-loop-that-no-chain-shows)
8116
+ - [Break the loop](#break-the-loop)
8117
+ - [Cut a loop at run time](#cut-a-loop-at-run-time)
8118
+ - [Fail a test on a loop](#fail-a-test-on-a-loop)
8119
+ - [Export the graph and the payloads](#export-the-graph-and-the-payloads)
8120
+ - [Know the limits](#know-the-limits)
8121
+
8122
+ ## Start from the symptom
8123
+
8124
+ | What you see | Likely cause | Go to |
8125
+ | ------------ | ------------ | ----- |
8126
+ | The page freezes | A loop of microtasks or a synchronous loop | [Find a loop](#find-a-loop), then [Cut a loop at run time](#cut-a-loop-at-run-time) |
8127
+ | A request repeats on its own | A handler emits the event that starts it | [Read a report](#read-a-report) |
8128
+ | `RangeError: Maximum call stack size exceeded` | A synchronous loop through `emit` | [Find a loop](#find-a-loop) |
8129
+ | A component renders again and again | A value is sent to the bus on every render | [Find a loop that no chain shows](#find-a-loop-that-no-chain-shows) |
8130
+ | You do not know which event leads to which | No map of the events | [Draw the graph](#draw-the-graph) |
8131
+
8132
+ ## Find a loop
8133
+
8134
+ `graphLogger` is a trace logger. Pass it to `createTracer` like any other logger. It builds the map of which event leads to which, and writes a report once when a chain passes the same event three times. Add `tracePlugin` so that it also sees every emit and the names of your listeners. It works without the plugin, with a coarser map.
8135
+
8136
+ ```js
8137
+ // bus.js
8138
+ import { createBus } from "nanoeffects";
8139
+ import { createTracer, graphLogger, tracePlugin } from "nanoeffects/trace";
8140
+
8141
+ // graphLogger — the map of events and the loop finder, in one logger
8142
+ export const graph = graphLogger({ name: "cart" });
8143
+
8144
+ // createTracer — sends every task of the bus to the logger
8145
+ const trace = createTracer(graph);
8146
+
8147
+ // tracePlugin — reports every emit, so the graph also shows events that nothing listens to
8148
+ export const bus = createBus({ trace }).use(tracePlugin());
8149
+ ```
8150
+
8151
+ The handlers are the part of your app that has the loop. Here a cart and a total update each other:
8152
+
8153
+ ```js
8154
+ // cart-handlers.js
8155
+ import { bus } from "./bus";
8156
+
8157
+ // traceId — the name of the listener in the graph and in the report
8158
+ bus.on(
8159
+ "cart/changed",
8160
+ (cart, ctx) => {
8161
+ ctx.emit("total/changed", cart);
8162
+ },
8163
+ { traceId: "recalcTotal" },
8164
+ );
8165
+
8166
+ bus.on(
8167
+ "total/changed",
8168
+ (total, ctx) => {
8169
+ ctx.emit("cart/changed", total);
8170
+ },
8171
+ { traceId: "syncCart" },
8172
+ );
8173
+ ```
8174
+
8175
+ `graphLogger` costs a lookup for each task. Use it while you develop, or with `createTracer(graph, { sample })` in production.
8176
+
8177
+ ## Read a report
8178
+
8179
+ <!-- docs-check: continue -->
8180
+
8181
+ The report is one `console.warn` for each loop:
8182
+
8183
+ ```text
8184
+ [nanoeffects cart] cycle: recalcTotal → total/changed → syncCart → cart/changed → recalcTotal (repeated 3 times, started at cart/changed)
8185
+ recalcTotal at src/cart-handlers.js:6; syncCart at src/cart-handlers.js:14 (only with devPlugin)
8186
+ handlers feed each other: break the loop with a condition, or emit a different event for the result
8187
+ ```
8188
+
8189
+ - **The path** goes from the first turn of the loop back to it. A named listener shows its event before it.
8190
+ - **`started at`** is the first event of the chain.
8191
+ - **`at file:line`** appears when you also use [`devPlugin`](hmr.md#report-leaks-with-devplugin).
8192
+ - **The last line** is advice for this shape of loop.
8193
+
8194
+ To receive the finding in code, pass `onCycle`. It replaces the console report:
8195
+
8196
+ ```js
8197
+ // report.js
8198
+ import { graphLogger } from "nanoeffects/trace";
8199
+
8200
+ export const graph = graphLogger({
8201
+ // onCycle — called once for each loop; the finding has a path, the repeats and the root
8202
+ onCycle: (finding) => {
8203
+ console.error(finding.kind, finding.path.join(" → "));
8204
+ },
8205
+ });
8206
+ ```
8207
+
8208
+ `graph.cycles()` lists the loops found so far, with `turns`: how many times a loop went round after it was found. It also lists cycles that no single chain showed, when the edges of different runs close a loop (`source: "graph"`).
8209
+
8210
+ ## Draw the graph
8211
+
8212
+ <!-- docs-check: continue -->
8213
+
8214
+ The graph has a count on each edge, and the edges of a cycle are marked. Write it in the format your tool reads:
8215
+
8216
+ ```js
8217
+ // draw.js
8218
+ import { graph } from "./bus";
8219
+
8220
+ // Mermaid: paste it into a README, a pull request or https://mermaid.live
8221
+ const mermaid = graph.toMermaid({ group: "prefix" }); // group the events by the part before "/"
8222
+
8223
+ // Graphviz: dot -Tsvg graph.dot > graph.svg
8224
+ const dot = graph.toDot();
8225
+
8226
+ // plain data for your own tools
8227
+ const data = graph.toJSON();
8228
+
8229
+ console.log(mermaid, dot, data);
8230
+ ```
8231
+
8232
+ ```mermaid
8233
+ flowchart LR
8234
+ n0["cart/changed"]
8235
+ n1["recalcTotal"]
8236
+ n2["total/changed"]
8237
+ n3["syncCart"]
8238
+ n0 -->|x21| n1
8239
+ n1 -->|x20| n2
8240
+ n2 -->|x20| n3
8241
+ n3 -->|x20| n0
8242
+ linkStyle 0,1,2,3 stroke:red,stroke-width:2px
8243
+ ```
8244
+
8245
+ The counts (`x21`) are how many times each edge was seen; they depend on how long the loop ran.
8246
+
8247
+ ## See the loop in Redux DevTools
8248
+
8249
+ `devtools: true` sends each finding to the extension as a `cycle/detected` action. Put `reduxDevtools` next to it to also see the emits:
8250
+
8251
+ ```js
8252
+ // devtools-bus.js
8253
+ import { createBus } from "nanoeffects";
8254
+ import { createTracer, graphLogger, reduxDevtools, tracePlugin } from "nanoeffects/trace";
8255
+
8256
+ // devtools: true — one cycle/detected action for each loop, with the path
8257
+ const graph = graphLogger({ name: "app", devtools: true });
8258
+
8259
+ // reduxDevtools — the emits of the bus as actions
8260
+ const trace = createTracer([reduxDevtools({ name: "app" }), graph]);
8261
+
8262
+ export const bus = createBus({ trace }).use(tracePlugin());
8263
+ ```
8264
+
8265
+ Redux DevTools is a browser extension, so open the page in its own tab and pick the instance in its list. Without the extension nothing is sent and nothing throws. You can try it with the buttons of [`examples/debug-playground`](https://github.com/nanoeffects/nanoeffects/tree/main/examples/debug-playground).
8266
+
8267
+ ## Find a loop that no chain shows
8268
+
8269
+ Some loops have no chain of emits:
8270
+
8271
+ - **A component that emits on every render.** `useEmitter` and a `bus.emit` in an effect run outside any handler, so each emit is a root. `graphLogger` counts such emits for each event, and reports a **storm** when one event comes 100 times in a second. Change the limit with `storm: { count, perMs }`, or turn it off with `storm: false`.
8272
+ - **A `bus.emit` inside a handler.** It starts a new chain, so the handler that calls it again does not look like a loop. Use `ctx.emit` inside handlers to keep the chain.
8273
+ - **A chain of generated names** (`step/${n}`) never repeats a name. `graphLogger` reports a **runaway** chain when one root starts more than `maxSteps` tasks (5000 by default).
8274
+
8275
+ ```js
8276
+ // storm.js
8277
+ import { graphLogger } from "nanoeffects/trace";
8278
+
8279
+ export const graph = graphLogger({
8280
+ storm: { count: 50, perMs: 1000 }, // 50 emits of one event in a second from outside
8281
+ maxSteps: 2000, // 2000 tasks started by one root
8282
+ ignore: ["tree/visit"], // a recursion on purpose is not counted
8283
+ });
8284
+ ```
8285
+
8286
+ ## Break the loop
8287
+
8288
+ | Shape | Fix |
8289
+ | ----- | --- |
8290
+ | A handler emits its own event | Emit a different event for the result (`user/loaded`, not `user/load`). |
8291
+ | Two handlers feed each other | Add the condition that ends it, or send the result to a third event that nothing in the loop listens to. |
8292
+ | A job whose outcome has the type of its trigger | Give the outcomes their own types. |
8293
+ | A component emits on every render | Pass `equals` to `useEmitter`, so the same value is not an emit, or `debounce` the handler. |
8294
+ | A failure re-emits the event that failed | Stop after a number of tries with `retry`, not with an event. |
8295
+
8296
+ ## Cut a loop at run time
8297
+
8298
+ The report does not stop a loop. For a loop that must not take the page down, add [`loopGuardPlugin`](loop-guard.md). It refuses the emit that would pass 50 repeats, or a chain of 1000 emits, and reports it:
8299
+
8300
+ ```js
8301
+ // guarded-bus.js
8302
+ import { createBus } from "nanoeffects";
8303
+ import { loopGuardPlugin } from "nanoeffects/guard";
8304
+ import { createTracer, graphLogger, tracePlugin } from "nanoeffects/trace";
8305
+
8306
+ const graph = graphLogger({ name: "app" });
8307
+ const trace = createTracer(graph);
8308
+
8309
+ // loopGuardPlugin — cuts the loop that the graph reports
8310
+ export const bus = createBus({ trace })
8311
+ .use(tracePlugin())
8312
+ .use(loopGuardPlugin({ maxRepeats: 50 }));
8313
+ ```
8314
+
8315
+ The guard is independent of the logger: use either one alone.
8316
+
8317
+ ## Fail a test on a loop
8318
+
8319
+ Run a flow of the app and check that the graph has no cycle. A loop that someone adds later fails the build.
8320
+
8321
+ ```js
8322
+ // bus.js
8323
+ import { createBus } from "nanoeffects";
8324
+ import { createTracer, graphLogger, tracePlugin } from "nanoeffects/trace";
8325
+
8326
+ // the bus.js of "Find a loop": the graph logger on the tracer of the bus
8327
+ export const graph = graphLogger({ name: "cart" });
8328
+ export const bus = createBus({ trace: createTracer(graph) }).use(tracePlugin());
8329
+ ```
8330
+
8331
+ ```js
8332
+ // cart-handlers.js
8333
+ import { bus } from "./bus";
8334
+
8335
+ bus.on("checkout/started", (_order, ctx) => {
8336
+ ctx.emit("checkout/paid");
8337
+ });
8338
+ ```
8339
+
8340
+ ```js
8341
+ // cycles.test.js
8342
+ import { expect, test } from "vitest";
8343
+ import { bus, graph } from "./bus";
8344
+ import "./cart-handlers";
8345
+
8346
+ test("the checkout flow has no event cycles", () => {
8347
+ // run the flow the way a user would
8348
+ bus.emit("checkout/started");
8349
+
8350
+ // cycles() — empty when the graph has no loop
8351
+ expect(graph.cycles()).toEqual([]);
8352
+ });
8353
+ ```
8354
+
8355
+ ## Export the graph and the payloads
8356
+
8357
+ The exports leave payloads out, because a payload can hold private data. Ask for them when you need them:
8358
+
8359
+ ```js
8360
+ // bus.js
8361
+ import { createBus } from "nanoeffects";
8362
+ import { createTracer, graphLogger, tracePlugin } from "nanoeffects/trace";
8363
+
8364
+ // the bus.js of "Find a loop": the graph logger on the tracer of the bus
8365
+ export const graph = graphLogger({ name: "cart" });
8366
+ export const bus = createBus({ trace: createTracer(graph) }).use(tracePlugin());
8367
+ ```
8368
+
8369
+ ```js
8370
+ // payloads.js
8371
+ import { graph } from "./bus";
8372
+
8373
+ // payload: true — adds the last payload and error of each node, made safe
8374
+ const data = graph.toJSON({ payload: true });
8375
+
8376
+ console.log(JSON.stringify(data));
8377
+ ```
8378
+
8379
+ A payload can be a class instance, a `Map`, a circular object or an `Error`, which a plain `JSON.stringify` cannot take. The exports make them plain JSON: an `Error` keeps its name, message, stack and cause, a cycle becomes `"[Circular]"`, a `Map` becomes `{ "$map": [...] }`, and an instance of a class keeps its own fields and its name as `$class`. Getters are never called, and a value that is too deep or too big is cut.
8380
+
8381
+ ## Know the limits
8382
+
8383
+ - A chain that repeats on purpose is reported. List its event in `ignore`.
8384
+ - The report is made once for each loop. `turns` in `graph.cycles()` keeps counting.
8385
+
8386
+ ---
8387
+ Source: docs/loop-guard.md
8388
+
8389
+ # Cut a loop with `loopGuardPlugin`
8390
+
8391
+ > **Level F.** Read first: [Plugins](plugins.md#use-a-plugin). To find a loop before you cut it, see [Find and cut event loops](debugging-loops.md).
8392
+
8393
+ Two handlers that emit each other's events never stop. A loop made of `await` and timers burns CPU and network. A loop made of microtasks freezes the page, because the browser never gets a turn to draw. A report cannot stop any of them. `loopGuardPlugin` can: it follows the chain of emits and refuses the emit that would pass a limit.
8394
+
8395
+ Use it as a safety net, also in production. It needs no tracer and changes nothing while a chain stays under the limits. It is opt-in: a bus that does not use it behaves as before.
8396
+
8397
+ ## On this page
8398
+
8399
+ - [Cut a loop](#cut-a-loop)
8400
+ - [Choose what happens at the limit](#choose-what-happens-at-the-limit)
8401
+ - [Limit the emits that come from outside](#limit-the-emits-that-come-from-outside)
8402
+ - [Leave a recursion out](#leave-a-recursion-out)
8403
+ - [Know what it does not do](#know-what-it-does-not-do)
8404
+
8405
+ ## Cut a loop
8406
+
8407
+ The plugin counts how many times the same event is in the chain of emits that led to an emit. The chain follows `ctx.emit`, also across `await`, timers and microtasks. An emit that would pass a limit is refused, and the loop ends there. Other chains go on, and the next emit from outside starts a chain at zero.
8408
+
8409
+ ```js
8410
+ // bus.js
8411
+ import { createBus } from "nanoeffects";
8412
+ import { loopGuardPlugin } from "nanoeffects/guard";
8413
+
8414
+ // loopGuardPlugin — refuses the emit that would pass a limit of a chain
8415
+ export const bus = createBus().use(
8416
+ loopGuardPlugin({
8417
+ maxRepeats: 50, // one event may be in a chain 50 times
8418
+ maxDepth: 1000, // a chain may be 1000 emits long, whatever the names are
8419
+ }),
8420
+ );
8421
+ ```
8422
+
8423
+ | Option | Default | Cuts |
8424
+ | ------ | ------- | ---- |
8425
+ | `maxRepeats` | 50 | The same event in one chain too often: a loop. |
8426
+ | `maxDepth` | 1000 | A chain that is too long, also when every event has a new name (`step/${n}`). |
8427
+ | `maxPerSecond` | none | Emits of one event from outside any handler that come too fast ([below](#limit-the-emits-that-come-from-outside)). |
8428
+ | `ignore` | none | Event names that the repeat count skips ([below](#leave-a-recursion-out)). |
8429
+
8430
+ ## Choose what happens at the limit
8431
+
8432
+ `onLimit` says what the refused emit does. Either way the plugin reports the finding once for each path. The report has the path of the loop and never the payload.
8433
+
8434
+ ```js
8435
+ // bus.js
8436
+ import { createBus } from "nanoeffects";
8437
+ import { LoopError, loopGuardPlugin } from "nanoeffects/guard";
8438
+
8439
+ export const bus = createBus().use(
8440
+ loopGuardPlugin({
8441
+ onLimit: "drop", // "drop" (default): the emit is not sent; "throw": it throws a LoopError
8442
+ // report — replaces the default console.error
8443
+ report: (finding) => console.error(finding.limit, finding.path.join(" → ")),
8444
+ }),
8445
+ );
8446
+
8447
+ // with onLimit: "throw", the emit that was refused throws, and the error names the path
8448
+ try {
8449
+ bus.emit("cart/changed");
8450
+ } catch (error) {
8451
+ if (error instanceof LoopError) {
8452
+ console.log(error.limit, error.path);
8453
+ }
8454
+ }
8455
+ ```
8456
+
8457
+ - **`"drop"`** is the default. The emit returns as if nobody listened, so no new error appears in an async handler.
8458
+ - **`"throw"`** shows the loop with a stack. In an async handler the error becomes a rejection: handle it with `onError`.
8459
+
8460
+ A loop that is cut can leave the state of the app half done: the handlers that ran have done their part, the ones after the cut have not. Fix the loop, and keep the guard as a net.
8461
+
8462
+ ## Limit the emits that come from outside
8463
+
8464
+ A component that emits on every render has no chain: each emit comes from outside any handler. `maxPerSecond` counts them for each event.
8465
+
8466
+ ```js
8467
+ // bus.js
8468
+ import { createBus } from "nanoeffects";
8469
+ import { loopGuardPlugin } from "nanoeffects/guard";
8470
+
8471
+ // maxPerSecond — over 100 emits of one event in a second from outside are refused
8472
+ export const bus = createBus().use(loopGuardPlugin({ maxPerSecond: 100 }));
8473
+ ```
8474
+
8475
+ It is off by default, because a burst of many emits can be right (a stream of messages).
8476
+
8477
+ ## Leave a recursion out
8478
+
8479
+ A chain that repeats on purpose is cut too. A handler that emits `tree/visit` for each level of a tree, or one that emits `tick` again after a timer for hours, passes `maxRepeats`. List its event in `ignore`. `maxDepth` still applies to it.
8480
+
8481
+ ```js
8482
+ // bus.js
8483
+ import { createBus } from "nanoeffects";
8484
+ import { loopGuardPlugin } from "nanoeffects/guard";
8485
+
8486
+ export const bus = createBus().use(
8487
+ loopGuardPlugin({
8488
+ ignore: ["tree/visit"], // names, or a function of the name
8489
+ }),
8490
+ );
8491
+ ```
8492
+
8493
+ ## Know what it does not do
8494
+
8495
+ - **It does not abort the chain.** It refuses one emit, at the edge where the limit is passed. The handlers that already run, run to the end.
8496
+ - **It follows `ctx.emit`.** A `bus.emit` called from inside a handler is not part of the chain: it starts a new one with the counts at zero. Use `ctx.emit` inside handlers.
8497
+ - **It does not find a loop.** To see the path of a loop, and loops that go through components, use `graphLogger` ([Find and cut event loops](debugging-loops.md)).
8498
+
6405
8499
  ---
6406
8500
  Source: docs/observable.md
6407
8501
 
6408
8502
  # Turn events into Observables
6409
8503
 
8504
+ > **Level F.** Read first: [Guide](guide.md#1-create-a-bus) step 1.
8505
+
6410
8506
  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.
6411
8507
 
8508
+ The same entry has `events`, which needs no stream library: it gives an event to a `for await` loop. See [Read an event with a `for await` loop](#read-an-event-with-a-for-await-loop).
8509
+
6412
8510
  `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.
6413
8511
 
6414
8512
  ## Turn an event into a stream
@@ -6552,6 +8650,47 @@ bus.on(
6552
8650
 
6553
8651
  `switchMap` drops the old inner stream. `latest` aborts the old task through `signal`, so a `fetch` that got the signal really stops.
6554
8652
 
8653
+ ## Read an event with a `for await` loop
8654
+
8655
+ Problem: a task wants to handle the payloads of one event one after another, in order, and stop when the screen closes. `take` waits for one payload, and a listener has no place for a loop.
8656
+
8657
+ `events(source, event, options)` from `nanoeffects/observable` returns an async iterator of the payloads. It listens from the call, so a payload that comes before the first read is kept. `source` is a bus or a task context; with a context the loop also ends when the task aborts.
8658
+
8659
+ ```js
8660
+ import { createBus } from "nanoeffects";
8661
+ import { events } from "nanoeffects/observable";
8662
+
8663
+ const bus = createBus();
8664
+ const controller = new AbortController();
8665
+
8666
+ // events — the "query/changed" payloads one by one; keeps the last 3 while the loop is busy
8667
+ const queries = events(bus, "query/changed", {
8668
+ signal: controller.signal,
8669
+ buffer: 3,
8670
+ });
8671
+
8672
+ async function watchQueries() {
8673
+ for await (const query of queries) {
8674
+ console.log("search for", query); // runs once per payload, in order
8675
+ }
8676
+ }
8677
+
8678
+ watchQueries().catch((reason) => console.log("stopped:", reason)); // the abort rejects the loop
8679
+ bus.emit("query/changed", "cat");
8680
+ controller.abort("screen closed"); // the loop ends with this reason
8681
+ ```
8682
+
8683
+ | Option | What it does | Default |
8684
+ | ------ | ------------ | ------- |
8685
+ | `signal` | Ends the loop. The waiting read rejects with the abort reason. | none |
8686
+ | `buffer` | The most payloads kept while the loop is busy. | `Infinity` |
8687
+ | `overflow` | Which payload goes when the buffer is full: `"drop-oldest"` or `"drop-newest"`. | `"drop-oldest"` |
8688
+
8689
+ - `break`, `return` and a throw inside the loop remove the listener.
8690
+ - With no `buffer`, a loop that never reads keeps every payload. Set `buffer` for a slow loop.
8691
+ - An event of a bus does not end, so the loop ends only by `break`, the `signal` or the task.
8692
+ - Inside a task, `events(ctx, "query/changed")` needs no `signal`: the task ending is the end.
8693
+
6555
8694
  ## Convert back to a stream or a promise
6556
8695
 
6557
8696
  | You need | Write |
@@ -6599,6 +8738,8 @@ Source: docs/hmr.md
6599
8738
 
6600
8739
  # Hot reload and development diagnostics
6601
8740
 
8741
+ > **Level F.** Read first: [Actions, jobs and modules](actions.md).
8742
+
6602
8743
  Use this page in development with modules and hot reload. Skip until you mount domains with `modulePlugin`.
6603
8744
 
6604
8745
  When a file is edited, the bundler loads its new version and runs it again, while the page and the rest of the code stay alive. For a bus that can go wrong in three ways:
@@ -6636,11 +8777,43 @@ bus.use(devPlugin());
6636
8777
 
6637
8778
  - **Listeners that pile up.** When more than `maxListeners` (default `10`) listeners of one event were added and not unsubscribed, one warning names the event and the count. The count follows the function that `on` returns; `bus.off(event, fn)` is not seen.
6638
8779
  - **Modules.** For the app of `modulePlugin` (or the `app` option) it logs `module "user" mounted`, `replaced` when a module of the same name was mounted, and `unmounted`. Only what goes through `app.mount` and `app.unmount` is reported, not sub-modules.
6639
- - **Options.** `leaks: false` and `modules: false` switch a diagnostic off; `maxListeners` sets the limit; `log` receives the messages instead of the console.
8780
+ - **Where a named listener was made.** For a listener with a `traceId`, it notes the file and the line of the `bus.on` call, while the listener is subscribed. [`graphLogger`](debugging-loops.md#read-a-report) shows it in a loop report, so you can open the handler that closes the loop.
8781
+ - **Handlers that ignore the signal.** When a handler emits after its task was aborted (a newer call of `latest`, an unmounted module), the bus drops that emit. One warning then names the handler and the event: the handler did not stop when it was cancelled, usually because `ctx.signal` never reached the request. [Details](#find-a-handler-that-ignores-the-signal)
8782
+ - **Options.** `abandoned: false`, `leaks: false` and `modules: false` switch a diagnostic off; `maxListeners` sets the limit; `log` receives the messages instead of the console.
6640
8783
  - **Disconnect.** `bus.use(devPlugin(...))` returns the function that stops the diagnostics and puts `bus.on` and `app.mount` back.
6641
8784
 
6642
8785
  It changes no behavior: it wraps `bus.on`, `app.mount`, and `app.unmount` and adds messages.
6643
8786
 
8787
+ ### Find a handler that ignores the signal
8788
+
8789
+ A policy such as `latest` cancels the older call by aborting its `ctx.signal`. A handler that does not pass the signal to its request keeps running, and its result arrives after the newer one. The policy drops that result, so nothing looks wrong until the handler does something else with it.
8790
+
8791
+ ```js
8792
+ import { createBus } from "nanoeffects";
8793
+ import { latest } from "nanoeffects/policies";
8794
+
8795
+ const bus = createBus();
8796
+
8797
+ bus.on(
8798
+ "search",
8799
+ latest(async (query, ctx) => {
8800
+ // the signal is not passed, so an older request is not cancelled
8801
+ const items = await fetch(`/api?q=${query}`).then((response) => response.json());
8802
+ ctx.emit("results", items);
8803
+ }),
8804
+ );
8805
+ ```
8806
+
8807
+ With `devPlugin`, the second `search` aborts the first call. When the first call then emits `results`, the bus drops the emit and the plugin warns once:
8808
+
8809
+ ```text
8810
+ [nanoeffects] the handler of "search" emitted "results" after its task was aborted, so the emit was dropped. The handler kept running after it was cancelled: pass ctx.signal to the request (fetch(url, { signal: ctx.signal })) or give ctx to an effect (retry(ctx, ...)).
8811
+ ```
8812
+
8813
+ The check lives in the plugin only: the policies and the bus carry no code for it, and in production `nanoeffects/dev` is empty. It sees the handlers that emit or subscribe with `ctx.on` after the cancel; a handler that never does is not seen.
8814
+
8815
+ A linter finds the same mistake before the code runs, and much more of it: a handler with no `ctx`, a step that does not get the signal while another does, the calls of `Promise.all`, the callbacks of `retry` and `timeout`, a pause, a stream, `bus.emit` in a handler. [Keep the signal through a handler](pass-the-signal.md) lists every situation, what catches it and how to fix it, and shows the setup for ESLint, oxlint and Biome.
8816
+
6644
8817
  ### Keep the import in production
6645
8818
 
6646
8819
  `nanoeffects/dev` is a no-op in a production build. `package.json` maps it to an empty build under the `production` export condition (Vite and webpack set that in production mode), so `bus.use(devPlugin())` can stay without a guard: it touches nothing and costs a few bytes.