nanoeffects 0.27.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +26 -24
  2. package/dist/{action-creator-D_c223C7.d.ts → action-creator-jeEqIKgK.d.ts} +73 -32
  3. package/dist/actions.d.ts +18 -18
  4. package/dist/actions.js +1 -1
  5. package/dist/bus-0uYn_kRj.js +1 -0
  6. package/dist/context-DtUnzC-1.js +1 -0
  7. package/dist/context-types-Bme1mzkn.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 +2 -2
  12. package/dist/dev.js +1 -1
  13. package/dist/effects.d.ts +2 -2
  14. package/dist/effects.js +1 -1
  15. package/dist/event-DVcW0mME.js +1 -0
  16. package/dist/index.d.ts +2 -2
  17. package/dist/index.js +1 -1
  18. package/dist/match.d.ts +3 -3
  19. package/dist/match.js +1 -1
  20. package/dist/{module-WX0Gngnt.d.ts → module-BWMW83qk.d.ts} +38 -38
  21. package/dist/notrace/{action-creator-Bux7Zzsl.d.ts → action-creator-xpQ_I2h8.d.ts} +73 -32
  22. package/dist/notrace/actions.d.ts +52 -52
  23. package/dist/notrace/actions.js +1 -1
  24. package/dist/notrace/bus-37nlVljc.js +1 -0
  25. package/dist/notrace/effects.d.ts +2 -2
  26. package/dist/notrace/index.d.ts +2 -2
  27. package/dist/notrace/index.js +1 -1
  28. package/dist/notrace/{plugin-CATXOxtj.d.ts → plugin-6LhNx0th.d.ts} +1 -1
  29. package/dist/notrace/policies.d.ts +1 -1
  30. package/dist/notrace/policies.js +1 -1
  31. package/dist/notrace/redux.d.ts +1 -1
  32. package/dist/observable.d.ts +43 -1
  33. package/dist/observable.js +1 -1
  34. package/dist/onAbort-BgMfspK4.js +1 -0
  35. package/dist/oop.d.ts +3 -3
  36. package/dist/oop.js +1 -1
  37. package/dist/{plugin-vvkmIIz2.d.ts → plugin-BaErevLy.d.ts} +2 -2
  38. package/dist/policies.d.ts +2 -2
  39. package/dist/policies.js +1 -1
  40. package/dist/react.d.ts +2 -2
  41. package/dist/react.js +1 -1
  42. package/dist/redux.d.ts +1 -1
  43. package/dist/scope.d.ts +9 -9
  44. package/dist/signal-field-key-CWT-3OXC.js +1 -0
  45. package/dist/signal.d.ts +34 -6
  46. package/dist/signal.js +1 -1
  47. package/dist/svelte.d.ts +1 -1
  48. package/dist/trace.d.ts +20 -5
  49. package/dist/trace.js +1 -1
  50. package/dist/{types-Bf-LX_tE.d.ts → types-D5xsR37j.d.ts} +2 -2
  51. package/dist/vue.d.ts +1 -1
  52. package/dist/{with-policy-CPjx0sLm.d.ts → with-policy-BZVRBUOm.d.ts} +1 -1
  53. package/dist/{with-policy-D-SQRkPh.js → with-policy-C6BIiPIi.js} +1 -1
  54. package/docs/README.md +14 -1
  55. package/docs/actions.md +28 -2
  56. package/docs/chat-example.md +16 -4
  57. package/docs/chat-widget.md +135 -0
  58. package/docs/context.md +33 -1
  59. package/docs/effects.md +20 -9
  60. package/docs/guide.md +21 -3
  61. package/docs/migrating-from-effector.md +11 -0
  62. package/docs/observable.md +43 -0
  63. package/docs/oop.md +18 -1
  64. package/docs/package.md +22 -13
  65. package/docs/plugins.md +69 -11
  66. package/docs/query-libraries.md +273 -5
  67. package/docs/react.md +12 -2
  68. package/docs/scope.md +14 -2
  69. package/docs/semantics.md +10 -0
  70. package/docs/server.md +3 -3
  71. package/docs/stability.md +2 -2
  72. package/docs/starter.md +3 -3
  73. package/docs/tracing.md +77 -9
  74. package/docs/typescript.md +1 -1
  75. package/llms-full.txt +816 -72
  76. package/llms.txt +8 -7
  77. package/package.json +6 -6
  78. package/skills/nanoeffects/SKILL.md +2 -2
  79. package/dist/bus-DQDHUN5e.js +0 -1
  80. package/dist/context-UcmrAIhP.js +0 -1
  81. package/dist/context-types-nGtzhjxD.d.ts +0 -14
  82. package/dist/event-0RIBGYqS.js +0 -1
  83. package/dist/notrace/bus-CGi2lrd5.js +0 -1
  84. package/dist/notrace/promise-CSLpzwSR.js +0 -1
package/llms-full.txt CHANGED
@@ -10,7 +10,7 @@ Rules that generated code often gets wrong:
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
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.
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
@@ -28,6 +28,23 @@ Climb one step at a time: small bus → policies → effects → handlers and mo
28
28
 
29
29
  Runnable code for every step: [`examples/guide.ts`](../examples/guide.ts).
30
30
 
31
+ ## On this page
32
+
33
+ - [1. Create a bus](#1-create-a-bus)
34
+ - [Listen to every event](#listen-to-every-event)
35
+ - [Pick events with a function](#pick-events-with-a-function)
36
+ - [2. Use the handler context](#2-use-the-handler-context)
37
+ - [3. Abort the previous run with `latest`](#3-abort-the-previous-run-with-latest)
38
+ - [4. Pick another policy when overlap differs](#4-pick-another-policy-when-overlap-differs)
39
+ - [5. Add retry and timeout](#5-add-retry-and-timeout)
40
+ - [6. Run work in parallel or wait for an event](#6-run-work-in-parallel-or-wait-for-an-event)
41
+ - [7. Name events three ways](#7-name-events-three-ways)
42
+ - [8. Bind a handler to an action later](#8-bind-a-handler-to-an-action-later)
43
+ - [9. Mount a domain as a module](#9-mount-a-domain-as-a-module)
44
+ - [10. Add types when you want checking (optional)](#10-add-types-when-you-want-checking-optional)
45
+ - [11. Isolate errors and warn on typos](#11-isolate-errors-and-warn-on-typos)
46
+ - [Where next](#where-next)
47
+
31
48
  ## 1. Create a bus
32
49
 
33
50
  `on` subscribes. `emit` calls the subscribers. `on` returns the unsubscribe function.
@@ -48,7 +65,7 @@ bus.emit("greet", "Bob"); // nobody listens
48
65
 
49
66
  Handlers run at once, in subscription order.
50
67
 
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.
68
+ `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`.
52
69
 
53
70
  ```js
54
71
  import { createBus } from "nanoeffects";
@@ -289,10 +306,11 @@ import { all, delay, race, take } from "nanoeffects/effects";
289
306
  const bus = createBus();
290
307
 
291
308
  // all — run together; one failure cancels the rest
309
+ // child — the context of that starter: it has the `signal`, so the request stops with the group
292
310
  bus.on("search", async (id, ctx) => {
293
311
  const { user, posts } = await all(ctx, {
294
- user: ({ signal }) => api.user(id, { signal }),
295
- posts: ({ signal }) => api.posts(id, { signal }),
312
+ user: (child) => api.user(id, child),
313
+ posts: (child) => api.posts(id, child),
296
314
  });
297
315
  ctx.emit("loaded", { user, posts });
298
316
  });
@@ -563,7 +581,7 @@ Pass `--template` to skip the question. With npm, put `--` before the option.
563
581
  | `react` | The same handler with `useEmitter` and `useEventState`, and `usePolicy` for a debounced save in a component | You build a React app |
564
582
  | `vanilla-ts` | The `vanilla` app in TypeScript | You want the compiler to check events and handlers |
565
583
  | `react-ts` | The `react` app in TypeScript | You build a React app in TypeScript |
566
- | `domains-ts` | Commands, 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 |
584
+ | `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
585
  | `vue-ts` | The `react-ts` app in Vue 3, with `watchEmitter` and `useEventRef` from `nanoeffects/vue` | You build a Vue 3 app |
568
586
  | `svelte-ts` | The `react-ts` app in Svelte 5, with `eventStore` from `nanoeffects/svelte` | You build a Svelte app |
569
587
 
@@ -611,13 +629,13 @@ npm create nanoeffects@latest my-app -- --template domains-ts
611
629
  | ---- | --- |
612
630
  | `src/bus.ts` | Creates the bus. It imports no events and no domain. |
613
631
  | `src/app.ts` | Mounts the domains with `modulePlugin`. |
614
- | `src/domains/catalog/` | Searching products: `catalog.ts` (events and a command), `catalog-handlers.ts` (`debounce` and `latest`), `catalog-module.ts`. |
632
+ | `src/domains/catalog/` | Searching products: `catalog.ts` (events and an action with a handler), `catalog-handlers.ts` (`debounce` and `latest`), `catalog-module.ts`. |
615
633
  | `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
634
  | `src/ui/` | The components. None of them calls the fake server in `src/api.ts`. |
617
635
 
618
636
  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.
619
637
 
620
- The commands, 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).
638
+ 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).
621
639
 
622
640
  ## Read the files
623
641
 
@@ -696,24 +714,33 @@ Source: docs/package.md
696
714
 
697
715
  Imports are split so you only load what you use. `createBus` stays small; the rest is another entry.
698
716
 
717
+ ## Start with these three
718
+
699
719
  | Import | What is in it |
700
720
  | ------ | ------------- |
701
721
  | `nanoeffects` | `createBus`, the types |
702
722
  | `nanoeffects/policies` | `latest`, `leading`, `debounce`, `throttle`, `queue` |
703
723
  | `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 command, 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) |
724
+
725
+ ## Add the rest when you need it
726
+
727
+ Each row is optional. The "when" column says what need brings you to it.
728
+
729
+ | Import | What is in it | When |
730
+ | ------ | ------------- | ---- |
731
+ | `nanoeffects/actions` | `createAction`, `createJob`, `createModule`, `modulePlugin`, `actionsPlugin` | `emit` must return a result, or domain logic leaves the UI |
732
+ | `nanoeffects/match` | `matchPlugin`: `on`, `once` and `off` that take a function which picks the events | `on` must take a function that picks events |
733
+ | `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 |
734
+ | `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 |
735
+ | `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 |
736
+ | `nanoeffects/scope` | `scopePlugin`, `effect`: fields for one action, job, module or `emit` (the types `Scope`, `Effect`) | one action or module needs its own fields |
737
+ | `nanoeffects/redux` | `createEffectsMiddleware` | you already use Redux |
738
+ | `nanoeffects/observable` | `createObserve`, `Subject` | you need an Observable (RxJS) |
739
+ | `nanoeffects/react` | `reactPlugin`: adds the `useEmitter` hook to a bus (needs React 18 or newer) | React components |
740
+ | `nanoeffects/svelte` | `sveltePlugin`: adds `eventStore` to a bus (needs Svelte 5 or newer) | Svelte components |
741
+ | `nanoeffects/vue` | `vuePlugin`: adds `watchEmitter` and `useEventRef` to a bus (needs Vue 3.3 or newer) | Vue components |
742
+ | `nanoeffects/dev` | `devPlugin` (no-op in production) | you look for leaks in development |
743
+ | `nanoeffects/trace` | `createTracer`, the loggers and `tracePlugin` (names for the trace) | you want to see what ran and why |
717
744
 
718
745
  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.
719
746
 
@@ -804,8 +831,8 @@ import { race, retry } from "nanoeffects/effects";
804
831
  const bus = createBus();
805
832
 
806
833
  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 });
834
+ // with ctx: you pass the context, and the function takes the signal from it
835
+ const user = await retry(ctx, () => fetchUser(id, { signal: ctx.signal }), { times: 3 });
809
836
 
810
837
  // as a starter: a group (race, all, allSettled, timeout) calls it with its child
811
838
  const result = await race(ctx, {
@@ -828,12 +855,52 @@ Source: docs/plugins.md
828
855
 
829
856
  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.
830
857
 
858
+ ## On this page
859
+
860
+ - [Use a plugin](#use-a-plugin)
861
+ - [Pick a plugin by task](#pick-a-plugin-by-task)
862
+ - [Main plugins](#main-plugins)
863
+ - [Put a store or a client on every handler: `contextPlugin`](#put-a-store-or-a-client-on-every-handler-contextplugin)
864
+ - [Use `race` and `delay` without `ctx`: `effectsPlugin`](#use-race-and-delay-without-ctx-effectsplugin)
865
+ - [Bind or replace a handler: `actionsPlugin`](#bind-or-replace-a-handler-actionsplugin)
866
+ - [Mount a domain as one unit: `modulePlugin`](#mount-a-domain-as-one-unit-moduleplugin)
867
+ - [Extra plugins](#extra-plugins)
868
+ - [Give one action its own fields: `scopePlugin`](#give-one-action-its-own-fields-scopeplugin)
869
+ - [Tie an emit or a listener to an outside signal: `signalPlugin`](#tie-an-emit-or-a-listener-to-an-outside-signal-signalplugin)
870
+ - [Name the origin and the flow of a trace: `tracePlugin`](#name-the-origin-and-the-flow-of-a-trace-traceplugin)
871
+ - [Listen to a group of events: `matchPlugin`](#listen-to-a-group-of-events-matchplugin)
872
+ - [Hand the bus to a state manager: `emitterPlugin`](#hand-the-bus-to-a-state-manager-emitterplugin)
873
+ - [Tell the bus when a React value changes: `reactPlugin`](#tell-the-bus-when-a-react-value-changes-reactplugin)
874
+ - [Find leaks while you develop: `devPlugin`](#find-leaks-while-you-develop-devplugin)
875
+ - [Put them together](#put-them-together)
876
+ - [Use them in any order](#use-them-in-any-order)
877
+ - [See what is not a plugin](#see-what-is-not-a-plugin)
878
+ - [Write your own plugin](#write-your-own-plugin)
879
+
831
880
  ## Use a plugin
832
881
 
833
882
  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.
834
883
 
835
884
  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 }))`).
836
885
 
886
+ ## Pick a plugin by task
887
+
888
+ Find your need in the left column. No row fits: you need no plugin.
889
+
890
+ | I need | Use |
891
+ | ------ | --- |
892
+ | A store, client or logger on every handler | [`contextPlugin`](#put-a-store-or-a-client-on-every-handler-contextplugin) |
893
+ | `race`, `all` or `delay` without passing `ctx` | [`effectsPlugin`](#use-race-and-delay-without-ctx-effectsplugin) |
894
+ | A test that replaces a handler | [`actionsPlugin`](#bind-or-replace-a-handler-actionsplugin) |
895
+ | One domain mounted and unmounted as a unit | [`modulePlugin`](#mount-a-domain-as-one-unit-moduleplugin) |
896
+ | Fields for one action or module only | [`scopePlugin`](#give-one-action-its-own-fields-scopeplugin) |
897
+ | Tasks that stop when a request, socket or query is cancelled | [`signalPlugin`](#tie-an-emit-or-a-listener-to-an-outside-signal-signalplugin) |
898
+ | One listener for a group of events | [`matchPlugin`](#listen-to-a-group-of-events-matchplugin) |
899
+ | A callback for a state manager that emits | [`emitterPlugin`](#hand-the-bus-to-a-state-manager-emitterplugin) |
900
+ | A React component that sends its value to the bus | [`reactPlugin`](#tell-the-bus-when-a-react-value-changes-reactplugin) |
901
+ | Names in the trace | [`tracePlugin`](#name-the-origin-and-the-flow-of-a-trace-traceplugin) |
902
+ | Warnings about leaks while you develop | [`devPlugin`](#find-leaks-while-you-develop-devplugin) |
903
+
837
904
  ## Main plugins
838
905
 
839
906
  These four cover what most apps need: shared dependencies, effects, handlers and modules. Start here.
@@ -859,6 +926,8 @@ const bus = createBus().use(contextPlugin({ store }));
859
926
  bus.on("save", (draft, { store }) => store.put(draft));
860
927
  ```
861
928
 
929
+ 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).
930
+
862
931
  ### Use `race` and `delay` without `ctx`: `effectsPlugin`
863
932
 
864
933
  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).
@@ -874,7 +943,7 @@ const bus = createBus().use(effects);
874
943
 
875
944
  bus.on("load", async (id, { race, delay }) => {
876
945
  const result = await race({
877
- data: ({ signal }) => api.get(id, { signal }),
946
+ data: (child) => api.get(id, child),
878
947
  timeout: () => delay(5000),
879
948
  });
880
949
  });
@@ -911,7 +980,7 @@ const bus = createBus();
911
980
  // modulePlugin — mounts userModule at the end of this block, or at the first use if that is earlier, and returns the app
912
981
  const app = bus.use(modulePlugin([userModule]));
913
982
 
914
- app.unmount(userModule); // its listeners, bound commands and tasks are gone
983
+ app.unmount(userModule); // its listeners, bound actions and tasks are gone
915
984
  ```
916
985
 
917
986
  ## Extra plugins
@@ -921,7 +990,7 @@ Add one of these when you meet its need. None of them is required.
921
990
  | Plugin | What it gives you | Import | `bus.use` returns |
922
991
  | ------ | ----------------- | ------ | ----------------- |
923
992
  | [`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` |
993
+ | [`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
994
  | [`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
995
  | [`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
996
  | [`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 |
@@ -937,10 +1006,10 @@ import { createBus } from "nanoeffects";
937
1006
  import { createAction } from "nanoeffects/actions";
938
1007
  import { scopePlugin } from "nanoeffects/scope";
939
1008
 
940
- // scopePlugin — makes the scope of a command work on this bus
1009
+ // scopePlugin — makes the scope of an action work on this bus
941
1010
  const bus = createBus().use(scopePlugin());
942
1011
 
943
- // the last argument: fields of this command only
1012
+ // the last argument: fields of this action only
944
1013
  const getUser = createAction("user/get", (id, { api }) => api.getUser(id), {
945
1014
  api,
946
1015
  });
@@ -949,25 +1018,41 @@ const fakeApi = { getUser: async (id) => ({ id, name: "test" }) };
949
1018
  bus.emit(getUser("1"), { api: fakeApi }); // a fake api for this call only
950
1019
  ```
951
1020
 
952
- ### Tie an emit to an outside signal: `signalPlugin`
1021
+ ### Tie an emit or a listener to an outside signal: `signalPlugin`
953
1022
 
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).
1023
+ 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
1024
 
956
1025
  ```js
957
1026
  import { createBus } from "nanoeffects";
958
1027
  import { createAction } from "nanoeffects/actions";
959
1028
  import { signalPlugin } from "nanoeffects/signal";
960
1029
 
961
- // signalPlugin — emit takes { signal } for a command, a job or an action
1030
+ // signalPlugin — emit takes { signal } for an action, a job or an action
962
1031
  const bus = createBus().use(signalPlugin());
963
1032
 
964
1033
  const getUser = createAction("user/get", (id, { signal }) => api.getUser(id, { signal }));
965
1034
 
966
1035
  const controller = new AbortController();
967
- bus.emit(getUser("1"), { signal: controller.signal }); // runs the command
1036
+ bus.emit(getUser("1"), { signal: controller.signal }); // runs the action
968
1037
  controller.abort(); // ctx.signal of the handler aborts with it
969
1038
  ```
970
1039
 
1040
+ 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.
1041
+
1042
+ ```js
1043
+ import { createBus } from "nanoeffects";
1044
+ import { signalPlugin } from "nanoeffects/signal";
1045
+
1046
+ // signalPlugin — on and once take { signal } in their options
1047
+ const bus = createBus().use(signalPlugin());
1048
+
1049
+ const controller = new AbortController();
1050
+ bus.on("saved", (draft) => console.log(draft), { signal: controller.signal }); // listens until the abort
1051
+ controller.abort(); // the listener is gone: bus.get("saved") is empty
1052
+ ```
1053
+
1054
+ 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.
1055
+
971
1056
  ### Name the origin and the flow of a trace: `tracePlugin`
972
1057
 
973
1058
  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).
@@ -1080,7 +1165,7 @@ import { scopePlugin } from "nanoeffects/scope";
1080
1165
  export const bus = createBus()
1081
1166
  .use(contextPlugin({ store })) // ctx.store on every task
1082
1167
  .use(effectsPlugin({ race, all })) // ctx.race and ctx.all, bound to the task
1083
- .use(scopePlugin()) // scopes for commands, actions, jobs, modules and emit
1168
+ .use(scopePlugin()) // scopes for actions, jobs, modules and emit
1084
1169
  .use(matchPlugin()) // bus.on takes a function that picks the events
1085
1170
  .use(emitterPlugin()); // bus.emitter(creator)
1086
1171
 
@@ -1121,7 +1206,7 @@ These connect the bus to something, but are not used with `bus.use`:
1121
1206
  - `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
1207
  - `createBus({ trace })` takes a tracer from `nanoeffects/trace`. See [Tracing](tracing.md).
1123
1208
  - `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).
1209
+ - `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
1210
 
1126
1211
  ## Write your own plugin
1127
1212
 
@@ -1700,6 +1785,18 @@ Use this page for retry, timeouts, waiting, or parallel work — not as the firs
1700
1785
 
1701
1786
  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
1787
 
1788
+ ## On this page
1789
+
1790
+ - [Wrap a handler with retry and timeout](#wrap-a-handler-with-retry-and-timeout)
1791
+ - [Options for retry, timeout, and poll](#options-for-retry-timeout-and-poll)
1792
+ - [Back off between retries](#back-off-between-retries)
1793
+ - [Put selected effects on `ctx`](#put-selected-effects-on-ctx)
1794
+ - [Tie a promise to the task signal](#tie-a-promise-to-the-task-signal)
1795
+ - [Run cleanup on abort](#run-cleanup-on-abort)
1796
+ - [Wait for the next event with `take`](#wait-for-the-next-event-with-take)
1797
+ - [Run several starters together](#run-several-starters-together)
1798
+ - [Pass `ctx` outside a task](#pass-ctx-outside-a-task)
1799
+
1703
1800
  ## Wrap a handler with retry and timeout
1704
1801
 
1705
1802
  `retry`, `timeout`, and `poll` have two forms:
@@ -1745,9 +1842,7 @@ bus.on("load", async (id, ctx) => {
1745
1842
  () => fetchUser(id, { signal: ctx.signal }),
1746
1843
  { times: 4 },
1747
1844
  );
1748
- const data = await timeout(ctx, 5000, ({ signal }) => {
1749
- return fetchData(id, { signal });
1750
- });
1845
+ const data = await timeout(ctx, 5000, (child) => fetchData(id, child));
1751
1846
  await poll(ctx, () => refresh(), 10_000);
1752
1847
  ctx.emit("ready", { user, data });
1753
1848
  });
@@ -1856,7 +1951,8 @@ const bus = createBus().use(effects);
1856
1951
  bus.on("load", async (id, { race, all, delay }) => {
1857
1952
  // race — same as race(ctx, …)
1858
1953
  const result = await race({
1859
- data: ({ signal }) => fetchData(id, { signal }),
1954
+ // child — the context of this starter; it has the `signal`, so the request stops with the race
1955
+ data: (child) => fetchData(id, child),
1860
1956
  // a starter gets its own child with the same bound effects, so delay(5000) stops with the race
1861
1957
  timeout: ({ delay }) => delay(5000),
1862
1958
  });
@@ -1864,8 +1960,8 @@ bus.on("load", async (id, { race, all, delay }) => {
1864
1960
 
1865
1961
  // all — same as all(ctx, …)
1866
1962
  const { user, posts } = await all({
1867
- user: ({ signal }) => fetchUser(id, { signal }),
1868
- posts: ({ signal }) => fetchPosts(id, { signal }),
1963
+ user: (child) => fetchUser(id, child),
1964
+ posts: (child) => fetchPosts(id, child),
1869
1965
  });
1870
1966
  });
1871
1967
  ```
@@ -1988,8 +2084,8 @@ const bus = createBus();
1988
2084
 
1989
2085
  bus.on("open", async (id, ctx) => {
1990
2086
  const { user, posts } = await allSettled(ctx, {
1991
- user: ({ signal }) => fetchUser(id, { signal }),
1992
- posts: ({ signal }) => fetchPosts(id, { signal }),
2087
+ user: (child) => fetchUser(id, child),
2088
+ posts: (child) => fetchPosts(id, child),
1993
2089
  });
1994
2090
 
1995
2091
  if (user.status === "fulfilled") {
@@ -2095,7 +2191,7 @@ bus.on("sync", async (ids, ctx) => {
2095
2191
  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.
2096
2192
 
2097
2193
 
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`.
2194
+ **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, and `child` has a `signal`, so pass it as the options: `(child) => fetchUser(id, child)` (`fetch` reads only `signal` from it). The same holds for `all`, `allSettled` and `timeout`.
2099
2195
 
2100
2196
  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.
2101
2197
 
@@ -2160,6 +2256,32 @@ A chat with two entities and three features, jobs under policies and effects, an
2160
2256
 
2161
2257
  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
2258
 
2259
+ ## Pick the word: event, action, job, module
2260
+
2261
+ Four names, one idea each. Take the first row that fits.
2262
+
2263
+ | Word | What it is | Use it when |
2264
+ | ---- | ---------- | ----------- |
2265
+ | Event | A name and a payload; `createAction("x")` with no handler. | The UI or a handler tells that something happened. |
2266
+ | Action with a handler | The same, plus work: `createAction("x", handler)`. `emit` returns the result. | The caller needs a value back. |
2267
+ | Job | An action with a handler that also emits `pending`, `success`, `fail` and `cancel`. | The UI shows a loading state. |
2268
+ | Module | A group of listeners, handlers and tasks, mounted and unmounted with one call. | One domain must start and stop as a unit. |
2269
+
2270
+ Start with events. Add a handler when you need a result, a job when you need a status, a module when you need a unit.
2271
+
2272
+ ## On this page
2273
+
2274
+ - [Pick the word: event, action, job, module](#pick-the-word-event-action-job-module)
2275
+ - [Return a value from `emit` with a handler](#return-a-value-from-emit-with-a-handler)
2276
+ - [Describe events, handlers and jobs](#describe-events-handlers-and-jobs)
2277
+ - [Actions](#actions)
2278
+ - [Bind a handler later](#bind-a-handler-later)
2279
+ - [Say what a bound handler returns](#say-what-a-bound-handler-returns)
2280
+ - [Jobs](#jobs)
2281
+ - [Show a loading state with a job](#show-a-loading-state-with-a-job)
2282
+ - [Mount domains with modules](#mount-domains-with-modules)
2283
+ - [Give an action or a module its own fields](#give-an-action-or-a-module-its-own-fields)
2284
+
2163
2285
  ## Return a value from `emit` with a handler
2164
2286
 
2165
2287
  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.
@@ -2314,7 +2436,7 @@ On a bus without an event map you can say it where you call: `const id = bus.emi
2314
2436
  - `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
2437
  - `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
2438
  - 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.
2439
+ - 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
2440
  - Calling the job only builds `{ type, payload }`; the handler runs on `emit`.
2319
2441
  - 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
2442
 
@@ -2476,7 +2598,7 @@ If the `setup` of one module throws, the modules of that call that were already
2476
2598
  - **A module is known by its name.** Mounting a module whose name is taken replaces the mounted one (see [Hot reload](hmr.md)).
2477
2599
  - **Registry.** If `bus.use(actionsPlugin())` ran first, that registry is used; otherwise the module plugin attaches its own. Either way it is `bus.actions` and `app.actions`.
2478
2600
  - A disposed app cannot mount modules.
2479
- - **The given modules are mounted at the end of the block that made the app**, or at the first use of the app or the bus if that comes first (the first `bus.emit`, or a call of `mount`, `unmount`, `isMounted`, `dispose`, or the read of `commands`). Nothing needs to use the app: `bus.use(modulePlugin([a, b]))` is enough to start them. The plugins that you use after `modulePlugin` in the same block still reach them, because the modules wait until that block has finished. A plugin used after an `await` can come too late: use every plugin before the first `await`. The `bus.on` calls made in between are held and made right after, so the listeners of a module stay ahead of them. See [using plugins in any order](plugins.md#use-them-in-any-order).
2601
+ - **The given modules are mounted at the end of the block that made the app**, or at the first use of the app or the bus if that comes first (the first `bus.emit`, or a call of `mount`, `unmount`, `isMounted`, `dispose`, or the read of `actions`). Nothing needs to use the app: `bus.use(modulePlugin([a, b]))` is enough to start them. The plugins that you use after `modulePlugin` in the same block still reach them, because the modules wait until that block has finished. A plugin used after an `await` can come too late: use every plugin before the first `await`. The `bus.on` calls made in between are held and made right after, so the listeners of a module stay ahead of them. See [using plugins in any order](plugins.md#use-them-in-any-order).
2480
2602
  - **To mount by hand instead**, pass no modules: `modulePlugin()` starts nothing, and `app.mount(...)` starts the modules when you want.
2481
2603
  - Module context = task context plus `handle`, `use`, and `spawn`:
2482
2604
  - `handle` — give an action a handler (one that has none, or an override).
@@ -3198,11 +3320,24 @@ A handler gets the store from `ctx.store` and calls a mutation, whatever it hand
3198
3320
  ---
3199
3321
  Source: docs/query-libraries.md
3200
3322
 
3201
- # Use nanoeffects with React Query and SWR
3323
+ # Use nanoeffects with React Query, SWR and RTK Query
3202
3324
 
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.
3325
+ 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.
3204
3326
 
3205
- You need a React project, `nanoeffects`, and one of the two libraries. `api` below is your own client: replace it with your requests.
3327
+ You need a React project, `nanoeffects`, and one of the libraries. `api` below is your own client: replace it with your requests.
3328
+
3329
+ ## On this page
3330
+
3331
+ - [Decide who owns what](#decide-who-owns-what)
3332
+ - [Make the action](#make-the-action)
3333
+ - [Call it from React Query](#call-it-from-react-query)
3334
+ - [Call it from SWR](#call-it-from-swr)
3335
+ - [Let the bus know the result](#let-the-bus-know-the-result)
3336
+ - [Call it from RTK Query](#call-it-from-rtk-query)
3337
+ - [Give the client its signal once](#give-the-client-its-signal-once)
3338
+ - [Push a live stream into the cache](#push-a-live-stream-into-the-cache)
3339
+ - [Refetch queries when the bus says so](#refetch-queries-when-the-bus-says-so)
3340
+ - [Know the limits](#know-the-limits)
3206
3341
 
3207
3342
  ## Decide who owns what
3208
3343
 
@@ -3263,7 +3398,7 @@ import { getUser } from "./user";
3263
3398
  export function Profile({ id }) {
3264
3399
  const { isLoading, data, error } = useQuery({
3265
3400
  queryKey: ["user", id],
3266
- // emit — runs the command; { signal } ties its task to the signal of React Query
3401
+ // emit — runs the action; { signal } ties its task to the signal of React Query
3267
3402
  queryFn: ({ signal }) => bus.emit(getUser(id), { signal }),
3268
3403
  });
3269
3404
 
@@ -3315,9 +3450,264 @@ export const userLoaded = createAction("user/loaded");
3315
3450
 
3316
3451
  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).
3317
3452
 
3453
+ ## Call it from RTK Query
3454
+
3455
+ <!-- docs-check: new -->
3456
+
3457
+ 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.
3458
+
3459
+ ```js
3460
+ // bus.js
3461
+ import { createBus } from "nanoeffects";
3462
+ import { signalPlugin } from "nanoeffects/signal";
3463
+
3464
+ // signalPlugin — lets emit take { signal }
3465
+ export const bus = createBus().use(signalPlugin());
3466
+ ```
3467
+
3468
+ ```js
3469
+ // user.js
3470
+ import { createAction } from "nanoeffects/actions";
3471
+
3472
+ // createAction with a handler — emit returns what the handler returns
3473
+ export const getUser = createAction("user/get", (id, { signal }) =>
3474
+ api.getUser(id, { signal }),
3475
+ );
3476
+ ```
3477
+
3478
+ ```js
3479
+ // api-slice.js
3480
+ import { createApi, fakeBaseQuery } from "@reduxjs/toolkit/query/react";
3481
+ import { bus } from "./bus";
3482
+ import { getUser } from "./user";
3483
+
3484
+ export const userApi = createApi({
3485
+ reducerPath: "userApi",
3486
+ baseQuery: fakeBaseQuery(), // RTK Query — every endpoint brings its own queryFn
3487
+ endpoints: (build) => ({
3488
+ getUser: build.query({
3489
+ // emit — runs the action; { signal } ties its task to the request of RTK Query
3490
+ queryFn: async (id, { signal }) => {
3491
+ const user = await bus.emit(getUser(id), { signal });
3492
+ return { data: user };
3493
+ },
3494
+ }),
3495
+ }),
3496
+ });
3497
+ ```
3498
+
3499
+ ## Give the client its signal once
3500
+
3501
+ <!-- docs-check: new -->
3502
+
3503
+ 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).
3504
+
3505
+ `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).
3506
+
3507
+ ```js
3508
+ // bus.js
3509
+ import axios from "axios";
3510
+ import { createBus } from "nanoeffects";
3511
+ import { contextPlugin } from "nanoeffects/context";
3512
+ import { fromSignal, signalPlugin } from "nanoeffects/signal";
3513
+
3514
+ // fromSignal — http is a new axios client for each task, bound to the signal of that task
3515
+ const http = fromSignal((signal) => axios.create({ baseURL: "/api", signal }));
3516
+
3517
+ // contextPlugin — every handler gets ctx.http
3518
+ const context = contextPlugin({ http });
3519
+
3520
+ // signalPlugin — emit takes { signal }, so the signal of the query reaches the task
3521
+ export const bus = createBus().use(signalPlugin()).use(context);
3522
+ ```
3523
+
3524
+ ```js
3525
+ // user.js
3526
+ import { createAction } from "nanoeffects/actions";
3527
+
3528
+ // the handler never sees a signal: ctx.http stops when the task aborts
3529
+ export const getUser = createAction("user/get", async (id, { http }) => {
3530
+ const response = await http.get(`/users/${id}`);
3531
+ return response.data;
3532
+ });
3533
+ ```
3534
+
3535
+ ```jsx
3536
+ // Profile.jsx
3537
+ import { useQuery } from "@tanstack/react-query";
3538
+ import { bus } from "./bus";
3539
+ import { getUser } from "./user";
3540
+
3541
+ export function Profile({ id }) {
3542
+ const { data } = useQuery({
3543
+ queryKey: ["user", id],
3544
+ // emit — the signal of React Query aborts the task, and with it the request of ctx.http
3545
+ queryFn: ({ signal }) => bus.emit(getUser(id), { signal }),
3546
+ });
3547
+
3548
+ return <p>{data?.name}</p>;
3549
+ }
3550
+ ```
3551
+
3552
+ ## Push a live stream into the cache
3553
+
3554
+ <!-- docs-check: new -->
3555
+
3556
+ 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.
3557
+
3558
+ `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).
3559
+
3560
+ ```js
3561
+ // bus.js
3562
+ import { createBus } from "nanoeffects";
3563
+
3564
+ // createBus — the bus the socket code and the components share
3565
+ export const bus = createBus();
3566
+ ```
3567
+
3568
+ ```js
3569
+ // prices.js
3570
+ import { createAction } from "nanoeffects/actions";
3571
+
3572
+ // createAction — the event the socket code emits for every new price
3573
+ export const priceChanged = createAction("price/changed");
3574
+ ```
3575
+
3576
+ ```jsx
3577
+ // Price.jsx
3578
+ import { useEffect } from "react";
3579
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
3580
+ import { events } from "nanoeffects/observable";
3581
+ import { bus } from "./bus";
3582
+ import { priceChanged } from "./prices";
3583
+
3584
+ export function Price({ symbol }) {
3585
+ const queryClient = useQueryClient();
3586
+ const { data } = useQuery({
3587
+ queryKey: ["price", symbol],
3588
+ queryFn: () => api.getPrice(symbol), // the first value; the stream keeps it fresh
3589
+ });
3590
+
3591
+ useEffect(() => {
3592
+ const controller = new AbortController();
3593
+ // events — the newest price event, one at a time
3594
+ const prices = events(bus, priceChanged, {
3595
+ signal: controller.signal,
3596
+ buffer: 1,
3597
+ });
3598
+
3599
+ async function follow() {
3600
+ for await (const price of prices) {
3601
+ if (price.symbol === symbol) {
3602
+ queryClient.setQueryData(["price", symbol], price.value); // write into the cache
3603
+ }
3604
+ }
3605
+ }
3606
+
3607
+ follow().catch((error) => {
3608
+ if (!controller.signal.aborted) throw error; // the abort ends the loop; anything else is a bug
3609
+ });
3610
+
3611
+ return () => controller.abort(); // the component left: the loop ends, the listener goes
3612
+ }, [queryClient, symbol]);
3613
+
3614
+ return <p>{data}</p>;
3615
+ }
3616
+ ```
3617
+
3618
+ SWR does the same with `useSWRSubscription` from `swr/subscription`. Its subscribe function gets `next` to push a value and returns the cleanup:
3619
+
3620
+ ```jsx
3621
+ // Price.jsx
3622
+ import useSWRSubscription from "swr/subscription";
3623
+ import { events } from "nanoeffects/observable";
3624
+ import { bus } from "./bus";
3625
+ import { priceChanged } from "./prices";
3626
+
3627
+ export function Price({ symbol }) {
3628
+ const { data } = useSWRSubscription(["price", symbol], (_key, { next }) => {
3629
+ const controller = new AbortController();
3630
+ // events — the newest price event, one at a time
3631
+ const prices = events(bus, priceChanged, {
3632
+ signal: controller.signal,
3633
+ buffer: 1,
3634
+ });
3635
+
3636
+ async function follow() {
3637
+ for await (const price of prices) {
3638
+ if (price.symbol === symbol) {
3639
+ next(null, price.value); // SWR — push the value to the hook
3640
+ }
3641
+ }
3642
+ }
3643
+
3644
+ follow().catch((error) => {
3645
+ if (!controller.signal.aborted) next(error);
3646
+ });
3647
+
3648
+ return () => controller.abort(); // SWR calls this when the last component of the key leaves
3649
+ });
3650
+
3651
+ return <p>{data}</p>;
3652
+ }
3653
+ ```
3654
+
3655
+ ## Refetch queries when the bus says so
3656
+
3657
+ <!-- docs-check: new -->
3658
+
3659
+ 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.
3660
+
3661
+ This needs `signalPlugin`, because `{ signal }` in the options of `on` is read by the plugin. Without it the listeners stay after the component leaves.
3662
+
3663
+ ```js
3664
+ // bus.js
3665
+ import { createBus } from "nanoeffects";
3666
+ import { signalPlugin } from "nanoeffects/signal";
3667
+
3668
+ // signalPlugin — on takes { signal } and removes the listener when it aborts
3669
+ export const bus = createBus().use(signalPlugin());
3670
+ ```
3671
+
3672
+ ```js
3673
+ // user-events.js
3674
+ import { createAction } from "nanoeffects/actions";
3675
+
3676
+ // createAction — events the save code emits
3677
+ export const userSaved = createAction("user/saved");
3678
+ export const userDeleted = createAction("user/deleted");
3679
+ ```
3680
+
3681
+ ```jsx
3682
+ // UserList.jsx
3683
+ import { useEffect } from "react";
3684
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
3685
+ import { bus } from "./bus";
3686
+ import { userDeleted, userSaved } from "./user-events";
3687
+
3688
+ export function UserList() {
3689
+ const queryClient = useQueryClient();
3690
+ const { data } = useQuery({ queryKey: ["users"], queryFn: () => api.listUsers() });
3691
+
3692
+ useEffect(() => {
3693
+ const controller = new AbortController();
3694
+ const { signal } = controller;
3695
+ const refresh = () => queryClient.invalidateQueries({ queryKey: ["users"] });
3696
+
3697
+ // on with { signal } — both listeners go when the component leaves
3698
+ bus.on(userSaved, refresh, { signal });
3699
+ bus.on(userDeleted, refresh, { signal });
3700
+
3701
+ return () => controller.abort();
3702
+ }, [queryClient]);
3703
+
3704
+ return <ul>{data?.map((user) => <li key={user.id}>{user.name}</li>)}</ul>;
3705
+ }
3706
+ ```
3707
+
3318
3708
  ## Know the limits
3319
3709
 
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.
3710
+ - **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
3711
  - **Only the call forms take it.** `bus.emit(getUser(id), { signal })` works; `bus.emit("user/get", id, { signal })` does not.
3322
3712
  - **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
3713
  - **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.
@@ -3367,7 +3757,7 @@ The action loads a user. It tries the request up to three times, and gives up on
3367
3757
  import { createAction } from "nanoeffects/actions";
3368
3758
  import { retry, timeout } from "nanoeffects/effects";
3369
3759
 
3370
- // createAction — a command with a handler: emit returns what the handler returns
3760
+ // createAction — an action with a handler: emit returns what the handler returns
3371
3761
  export const getUser = createAction("user/get", async (id, ctx) => {
3372
3762
  // one try: the request, with the signal of timeout so it can stop it
3373
3763
  const load = ({ signal }) => api.getUser(id, { signal });
@@ -3408,7 +3798,7 @@ app.get("/users/:id", async (request, reply) => {
3408
3798
  });
3409
3799
 
3410
3800
  try {
3411
- // emit with { signal } — aborting the controller aborts the command: timeout, retry and the request
3801
+ // emit with { signal } — aborting the controller aborts the action: timeout, retry and the request
3412
3802
  const call = getUser(request.params.id);
3413
3803
  return await bus.emit(call, { signal: controller.signal });
3414
3804
  } catch (error) {
@@ -3461,7 +3851,7 @@ import { createAction } from "nanoeffects/actions";
3461
3851
  import { poll } from "nanoeffects/effects";
3462
3852
  import { latest } from "nanoeffects/policies";
3463
3853
 
3464
- // createAction — a command with a handler
3854
+ // createAction — an action with a handler
3465
3855
  // latest — a new subscription stops the old one; key — but only the old one of the same socket
3466
3856
  export const watchPrices = createAction(
3467
3857
  "prices/watch",
@@ -3540,6 +3930,16 @@ Use this page when a React screen starts requests on user input and you need the
3540
3930
 
3541
3931
  You need a React project and `nanoeffects` installed. The form talks to your own client, `api` below: replace it with your requests.
3542
3932
 
3933
+ ## On this page
3934
+
3935
+ - [See what goes wrong without a bus](#see-what-goes-wrong-without-a-bus)
3936
+ - [Wire the bus once](#wire-the-bus-once)
3937
+ - [Connect the form](#connect-the-form)
3938
+ - [Stop running work when the form closes](#stop-running-work-when-the-form-closes)
3939
+ - [Show the last value of an event as state](#show-the-last-value-of-an-event-as-state)
3940
+ - [Keep a debounced handler between renders](#keep-a-debounced-handler-between-renders)
3941
+ - [Where to go next](#where-to-go-next)
3942
+
3543
3943
  ## See what goes wrong without a bus
3544
3944
 
3545
3945
  A registration form has three problems that `useEffect` and `fetch` leave to you:
@@ -3611,7 +4011,7 @@ The handlers know nothing about React. A test can emit `username/typed` and read
3611
4011
 
3612
4012
  <!-- docs-check: continue -->
3613
4013
 
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.
4014
+ 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
4015
 
3616
4016
  ```jsx
3617
4017
  // SignUpForm.jsx
@@ -3812,7 +4212,7 @@ export const Search = ({ wait }) => {
3812
4212
  - A policy as a plain function without a bus: [Use a policy without a bus](policies.md#use-a-policy-without-a-bus).
3813
4213
  - `retry`, `timeout` and the other effects: [Effects](effects.md).
3814
4214
  - 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).
4215
+ - React Query or SWR instead of your own state: [Use nanoeffects with React Query, SWR and RTK Query](query-libraries.md).
3816
4216
 
3817
4217
  ---
3818
4218
  Source: docs/chat-example.md
@@ -3833,6 +4233,18 @@ npm run dev
3833
4233
 
3834
4234
  `npm run dev` starts the server and Vite together. Start it with `CHAOS=off npm run dev` to turn the failures off.
3835
4235
 
4236
+ ## On this page
4237
+
4238
+ - [Read how the server fails](#read-how-the-server-fails)
4239
+ - [See how the files are split](#see-how-the-files-are-split)
4240
+ - [Load a history that must not race](#load-a-history-that-must-not-race)
4241
+ - [Send a message that can fail](#send-a-message-that-can-fail)
4242
+ - [Search where one room is slow](#search-where-one-room-is-slow)
4243
+ - [Wait for a room before marking a result](#wait-for-a-room-before-marking-a-result)
4244
+ - [Poll for new messages](#poll-for-new-messages)
4245
+ - [Know where each value lives](#know-where-each-value-lives)
4246
+ - [Where to go next](#where-to-go-next)
4247
+
3836
4248
  ## Read how the server fails
3837
4249
 
3838
4250
  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:
@@ -3867,7 +4279,7 @@ The code is in layers, and a file imports only from the layers below it. The com
3867
4279
 
3868
4280
  That rule is why `show-history` exists. Selecting a room must load its history, but `room` does not know `message` and `message` does not know `room`. The feature `show-history` is the one place that knows both: it listens to `rooms/selected` and starts the job `messages/loadHistory`. The same goes for a message that arrives: `receive-messages` emits `incoming/received`, an event that the `message` entity owns and listens to.
3869
4281
 
3870
- A folder has its events (`room.ts`, which imports no bus), its work (`room-handlers.ts`), the state it keeps (`room-store.ts`) and a module (`room-module.ts`) that wires them. Another layer reaches an entity through its events and commands, never through its handlers or its store. Events and commands both live in the events file. An event (`createAction` with no handler) says that something happened, and any feature may listen: `rooms/selected` is heard by two. A command (`createAction` with a handler) is an order with exactly one handler, as `search/openResult` and `messages/mark` are. The handler is given to `createAction`, so the command works wherever it is called and can return a result, and the module binds it with `module.use` for as long as it is mounted. A job is a command that also sends its outcomes, but its handler is bound in the module (`module.handle`): a job usually runs under a policy, and the type of its outcomes is exact only when the handler is not part of the type.
4282
+ A folder has its events (`room.ts`, which imports no bus), its work (`room-handlers.ts`), the state it keeps (`room-store.ts`) and a module (`room-module.ts`) that wires them. Another layer reaches an entity through its actions, never through its handlers or its store. All actions live in the events file. An action with no handler says that something happened, and any feature may listen: `rooms/selected` is heard by two. An action with a handler is an order with exactly one handler, as `search/openResult` and `messages/mark` are. The handler is given to `createAction`, so the action works wherever it is called and can return a result, and the module binds it with `module.use` for as long as it is mounted. A job is an action with a handler that also sends its outcomes, but its handler is bound in the module (`module.handle`): a job usually runs under a policy, and the type of its outcomes is exact only when the handler is not part of the type.
3871
4283
 
3872
4284
  The layers come from [Feature-Sliced Design](https://feature-sliced.design/), reduced to the three that an app of this size needs.
3873
4285
 
@@ -3883,7 +4295,7 @@ A job sends `pending` when its handler starts and exactly one of `success`, `fai
3883
4295
  // message.js
3884
4296
  import { createJob } from "nanoeffects/actions";
3885
4297
 
3886
- // createJob — a command that also sends pending, success, fail and cancel
4298
+ // createJob — an action with a handler that also sends pending, success, fail and cancel
3887
4299
  export const loadHistory = createJob("messages/loadHistory");
3888
4300
  ```
3889
4301
 
@@ -4014,7 +4426,7 @@ export const goToResult = async (hit, ctx) => {
4014
4426
  ctx.emit("rooms/selected", hit.roomId);
4015
4427
  await loaded;
4016
4428
  }
4017
- // ctx.emit with a command — the message entity marks the message
4429
+ // ctx.emit with an action that has a handler — the message entity marks the message
4018
4430
  ctx.emit(markMessage(hit.id));
4019
4431
  };
4020
4432
  ```
@@ -4066,10 +4478,149 @@ Each entity and each feature that has state keeps it in a [Zustand](https://zust
4066
4478
 
4067
4479
  ## Where to go next
4068
4480
 
4069
- - The API of commands, jobs and modules: [Actions, jobs and modules](actions.md).
4481
+ - The API of actions, jobs and modules: [Actions, jobs and modules](actions.md).
4070
4482
  - Policies such as `latest` and `queue`: [Policies](policies.md). Effects such as `allSettled`, `take` and `poll`: [Effects](effects.md).
4071
4483
  - A smaller project with two domains: [Start with domains](starter.md#start-with-domains). Its two domains do not talk to each other, so it needs no layers.
4072
4484
 
4485
+ ---
4486
+ Source: docs/chat-widget.md
4487
+
4488
+ # Build a chat widget that listens only while it is open
4489
+
4490
+ 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.
4491
+
4492
+ 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:
4493
+
4494
+ ```bash
4495
+ cd examples/chat-widget
4496
+ npm install
4497
+ npm run dev
4498
+ ```
4499
+
4500
+ It is a level D example: the logic lives in one module, and the page only mounts and unmounts it. For an app with several features see [Build a chat with entities, features, modules and a small server](chat-example.md).
4501
+
4502
+ ## Know what happens on open and on close
4503
+
4504
+ | Moment | What happens |
4505
+ | ------ | ------------ |
4506
+ | Open | The module is mounted: the bot handler is subscribed, the operator messages are polled, the `Escape` listener is added. The panel is drawn and listens to the bus. |
4507
+ | The user sends a text | The bot is asked, one text after another, with a time limit and a retry. |
4508
+ | `Escape` | The module emits `chat/closeRequested`; the launcher closes the chat. |
4509
+ | Close | The panel is removed and unsubscribed, the module is unmounted. A request in flight is aborted, the polling stops, the `Escape` listener goes away. |
4510
+
4511
+ ## Find the parts in the files
4512
+
4513
+ | File | Job |
4514
+ | ---- | --- |
4515
+ | `src/bus.ts` | The bus, and `modulePlugin()` with no module: the launcher mounts the module itself. |
4516
+ | `src/chat.ts` | The events only: `messageSent`, `messageAdded`, `typingChanged`, `closeRequested`. |
4517
+ | `src/chat-handlers.ts` | `askBot` (`timeout` and `retry` inside) and `watchOperator` (`poll`): plain `async` functions that know nothing about the page. |
4518
+ | `src/chat-module.ts` | The list of what is alive while the chat is open. |
4519
+ | `src/panel.ts` | The view: shows the lines, sends the texts, returns one function that removes it. |
4520
+ | `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. |
4521
+
4522
+ ## Bind everything in one module
4523
+
4524
+ A module is the list of what is alive while the chat is open. When it unmounts, the bus undoes every line of it.
4525
+
4526
+ <!-- docs-check: skip - the other files of the example are in examples/chat-widget -->
4527
+ ```js
4528
+ // chat-module.js
4529
+ import { createModule } from "nanoeffects/actions";
4530
+ import { queue } from "nanoeffects/policies";
4531
+ import { closeRequested, messageSent } from "./chat";
4532
+ import { askBot, watchOperator } from "./chat-handlers";
4533
+
4534
+ // a task that stays pending until the module unmounts; its signal then aborts
4535
+ const untilAborted = (ctx) =>
4536
+ new Promise((resolve) => {
4537
+ ctx.signal.addEventListener("abort", resolve, { once: true });
4538
+ });
4539
+
4540
+ // a DOM listener that lives as long as the module: `signal` removes it when it aborts
4541
+ const listenToEscape = async (_, ctx) => {
4542
+ const onKey = (event) => {
4543
+ if (event.key === "Escape") {
4544
+ ctx.emit(closeRequested());
4545
+ }
4546
+ };
4547
+ document.addEventListener("keydown", onKey, { signal: ctx.signal });
4548
+ await untilAborted(ctx);
4549
+ };
4550
+
4551
+ export const chatModule = createModule("chat", (module) => {
4552
+ // module.run — runs askBot as a child task of the module, so an unmount aborts it
4553
+ const answer = (text) => module.run(askBot, text);
4554
+ // on + queue — answer the texts one after another, in the order they were sent
4555
+ module.on(messageSent, queue(answer));
4556
+ // spawn — a background task that lives as long as the module
4557
+ module.spawn(watchOperator);
4558
+ module.spawn(listenToEscape);
4559
+ });
4560
+ ```
4561
+
4562
+ Three details that make the cleanup complete:
4563
+
4564
+ - **`module.run`, not `askBot` directly.** A handler given to `module.on` runs as a child of whoever emitted the event (here the panel), so unmounting stops listening but does not abort a request that is already running. Running it with `module.run` makes it a child of the module.
4565
+ - **`queue`, not `latest`.** `queue` keeps the order when the user sends two texts fast. [`latest`](policies.md) would abort the first answer, which is wrong for a conversation.
4566
+ - **A DOM listener with `signal`.** `addEventListener` takes a `signal` option and removes the listener when it aborts. The task stays pending until then, because a task that has finished is not aborted again.
4567
+
4568
+ ## Open and close from the launcher
4569
+
4570
+ `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`.
4571
+
4572
+ <!-- docs-check: skip - the other files of the example are in examples/chat-widget -->
4573
+ ```js
4574
+ // main.js
4575
+ import { app, bus } from "./bus";
4576
+ import { closeRequested } from "./chat";
4577
+ import { chatModule } from "./chat-module";
4578
+ import { renderPanel } from "./panel";
4579
+
4580
+ const button = document.querySelector("#chat-launcher");
4581
+ let close = null; // set while the chat is open
4582
+
4583
+ const open = () => {
4584
+ const stopModule = app.mount(chatModule); // subscriptions, polling and Escape start
4585
+ const stopPanel = renderPanel(document.body);
4586
+ // the module emits closeRequested on Escape; nothing does while the chat is closed
4587
+ const stopEscape = bus.on(closeRequested, () => close?.());
4588
+ close = () => {
4589
+ stopEscape();
4590
+ stopPanel();
4591
+ stopModule(); // everything the module started ends, a running request included
4592
+ close = null;
4593
+ };
4594
+ };
4595
+
4596
+ button.addEventListener("click", () => {
4597
+ if (close) {
4598
+ close();
4599
+ } else {
4600
+ open();
4601
+ }
4602
+ });
4603
+ ```
4604
+
4605
+ 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.
4606
+
4607
+ ## Check that closing cleans up
4608
+
4609
+ 1. Open the chat and send a text. The bot answers after about a second. Every third question fails, and the retry asks again.
4610
+ 2. Send a text and close the chat before the answer arrives. No line appears, also when you open the chat again.
4611
+ 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.
4612
+ 4. Wait 10 s with the chat closed. The operator is not polled: no line from the operator appears.
4613
+ 5. Press `Escape` with the chat closed. Nothing happens. Open the chat and press it again: it closes.
4614
+ 6. Open the chat again. The old lines are back, and the polling runs.
4615
+
4616
+ `src/tests/chat-widget-example.test.ts` checks the same points without a browser.
4617
+
4618
+ ## Where to go next
4619
+
4620
+ - Show an unread count on the launcher while the chat is closed: a second module that stays mounted and listens only to `chat/messageAdded`.
4621
+ - 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`).
4622
+ - Watch the whole thing in the [trace](tracing.md): the module, the bot task, the retries and the polling are tasks of one tree.
4623
+
4073
4624
  ---
4074
4625
  Source: docs/vue.md
4075
4626
 
@@ -4281,7 +4832,7 @@ Library fields (`signal`, `emit`, `run`, …) win on name clashes. `context` may
4281
4832
 
4282
4833
  To get `race`, `all`, `take` and the like on every task without passing `ctx`, see [`effectsPlugin`](effects.md#put-selected-effects-on-ctx).
4283
4834
 
4284
- To give fields to **one** command, action, job or module, or to replace them for one `emit`, see [scopes](scope.md).
4835
+ To give fields to **one** action, job or module, or to replace them for one `emit`, see [scopes](scope.md).
4285
4836
 
4286
4837
  ## Type the fields (optional)
4287
4838
 
@@ -4305,6 +4856,38 @@ bus.on("save", (draft, { store }) => store.put(draft));
4305
4856
  - 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.
4306
4857
  - The plugin returns the same bus, so keep the result of the chain: `const bus = createBus().use(contextPlugin({ store }))`.
4307
4858
 
4859
+ ## Build a field from the signal of each task
4860
+
4861
+ 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.
4862
+
4863
+ `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.
4864
+
4865
+ ```js
4866
+ import { createBus } from "nanoeffects";
4867
+ import { contextPlugin } from "nanoeffects/context";
4868
+ import { fromSignal } from "nanoeffects/signal";
4869
+ import axios from "axios";
4870
+
4871
+ // contextPlugin — every task gets these fields
4872
+ // fromSignal — http is made from the signal of the task that reads ctx.http
4873
+ const bus = createBus().use(
4874
+ contextPlugin({
4875
+ logger: console, // a plain field: the same object for every task
4876
+ http: fromSignal((signal) => axios.create({ signal })),
4877
+ }),
4878
+ );
4879
+
4880
+ bus.on("user/open", async (id, ctx) => {
4881
+ const response = await ctx.http.get(`/users/${id}`); // aborts with the task
4882
+ ctx.logger.log(response.data);
4883
+ });
4884
+ ```
4885
+
4886
+ - `make` runs at the first read, so a handler that never uses `ctx.http` pays nothing for it.
4887
+ - A task that `ctx.run` starts builds its own value from its own signal, so aborting the child stops only the child's requests.
4888
+ - The field is not copied by `{ ...ctx }` and `Object.keys(ctx)` does not list it. Read `ctx.http` first if you copy a context.
4889
+ - Without `contextPlugin` a `fromSignal` marker is only an object.
4890
+
4308
4891
  ### Name the fields in the event map instead
4309
4892
 
4310
4893
  `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.
@@ -4329,6 +4912,18 @@ A **scope** is a plain object. Every key goes on `ctx` as it is (`cache`, `api`,
4329
4912
 
4330
4913
  You give a scope to the thing that uses it. A call can replace it. Nothing else changes: handlers still get `(payload, ctx)`.
4331
4914
 
4915
+ ## On this page
4916
+
4917
+ - [Turn scopes on](#turn-scopes-on)
4918
+ - [Give an action its own fields](#give-an-action-its-own-fields)
4919
+ - [Bind an effect to the task](#bind-an-effect-to-the-task)
4920
+ - [Replace a field for one call](#replace-a-field-for-one-call)
4921
+ - [Give an action, a job or a module its own fields](#give-an-action-a-job-or-a-module-its-own-fields)
4922
+ - [Which value wins](#which-value-wins)
4923
+ - [Keep a generic effect generic](#keep-a-generic-effect-generic)
4924
+ - [Type the fields (optional)](#type-the-fields-optional)
4925
+ - [Limits](#limits)
4926
+
4332
4927
  ## Turn scopes on
4333
4928
 
4334
4929
  Scopes are a plugin, so a bus that does not use them pays nothing.
@@ -4359,7 +4954,7 @@ import { createAction } from "nanoeffects/actions";
4359
4954
  export const getUser = createAction(
4360
4955
  "user/get",
4361
4956
  (id, { api, cache }) => {
4362
- // api and cache are on ctx for this command only
4957
+ // api and cache are on ctx for this action only
4363
4958
  return cache.get(id) ?? api.getUser(id);
4364
4959
  },
4365
4960
  { api, cache: new Map() },
@@ -4508,7 +5103,7 @@ When several places set the same field, the strongest one wins:
4508
5103
  | Strength | Where it is set |
4509
5104
  | -------- | --------------- |
4510
5105
  | 1 (strongest) | the second argument of `emit` |
4511
- | 2 | the scope of the action, command or job |
5106
+ | 2 | the scope of the action or job |
4512
5107
  | 3 | the scope of the module that owns the handler |
4513
5108
  | 4 (weakest) | the bus: `createBus({ context })`, `contextPlugin`, `effectsPlugin` |
4514
5109
 
@@ -4602,6 +5197,23 @@ Use this page when **your domain logic lives in classes**: a service, a store or
4602
5197
 
4603
5198
  If your logic is plain functions, stay with [modules](actions.md#mount-domains-with-modules): a module is the same idea without a class.
4604
5199
 
5200
+ ## On this page
5201
+
5202
+ - [The problem without it](#the-problem-without-it)
5203
+ - [Make an independent emitter with `Emitter`](#make-an-independent-emitter-with-emitter)
5204
+ - [Type the events (optional)](#type-the-events-optional)
5205
+ - [Give a class the methods of a shared bus](#give-a-class-the-methods-of-a-shared-bus)
5206
+ - [Extend the result with no class of your own](#extend-the-result-with-no-class-of-your-own)
5207
+ - [Let the class make its bus](#let-the-class-make-its-bus)
5208
+ - [End the subscriptions with `dispose`](#end-the-subscriptions-with-dispose)
5209
+ - [Stream an event with a `Subject`](#stream-an-event-with-a-subject)
5210
+ - [Keep a method of your own](#keep-a-method-of-your-own)
5211
+ - [Subscribe a method with `@On` and `@Once`](#subscribe-a-method-with-on-and-once)
5212
+ - [Decorate the class with `@WithBus`](#decorate-the-class-with-withbus)
5213
+ - [Pass a method by hand](#pass-a-method-by-hand)
5214
+ - [Type the instances (optional)](#type-the-instances-optional)
5215
+ - [What is not done](#what-is-not-done)
5216
+
4605
5217
  ## The problem without it
4606
5218
 
4607
5219
  A class that listens to the bus keeps a reference to it, and keeps every unsubscribe function in a list to call when the object goes away. Forget one and the old object keeps handling events. `extendBus` does both for you.
@@ -4903,7 +5515,7 @@ export const SearchService = extendBus(bus, SearchServiceBase);
4903
5515
 
4904
5516
  Things to know:
4905
5517
 
4906
- - The event is a name, an action creator or a command, as for `bus.on`. For a creator or a command the payload of the method is checked against it.
5518
+ - The event is a name or an action creator, as for `bus.on`. For a creator the payload of the method is checked against it.
4907
5519
  - The second argument, `wrap`, takes the bound handler and returns the one to subscribe: `latest`, or `(handler) => debounce(300, handler)`. A policy needs it: `latest(this.search)` wraps the function without `this`, and the bound handler is what `wrap` gets.
4908
5520
  - The subscriptions are made right after the constructor of `Base`. An event that is emitted inside that constructor does not reach them.
4909
5521
  - A subclass inherits them. A method that a subclass overrides is subscribed only if the override is decorated.
@@ -5032,7 +5644,7 @@ export const getUser = createAction(
5032
5644
  },
5033
5645
  );
5034
5646
 
5035
- // createModule: m.use binds the handler the command carries, while the module is mounted
5647
+ // createModule: m.use binds the handler the action carries, while the module is mounted
5036
5648
  export const userModule = createModule("user", (m) => {
5037
5649
  m.use(getUser);
5038
5650
  });
@@ -5282,6 +5894,17 @@ Source: docs/migrating-from-effector.md
5282
5894
 
5283
5895
  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.
5284
5896
 
5897
+ ## On this page
5898
+
5899
+ - [Split the work](#split-the-work)
5900
+ - [Find the equivalent](#find-the-equivalent)
5901
+ - [Rewrite an effect](#rewrite-an-effect)
5902
+ - [Keep the state in Zustand](#keep-the-state-in-zustand)
5903
+ - [Rewrite a form](#rewrite-a-form)
5904
+ - [Replace the log with a request](#replace-the-log-with-a-request)
5905
+ - [Use both in one app](#use-both-in-one-app)
5906
+ - [What is different](#what-is-different)
5907
+
5285
5908
  ## Split the work
5286
5909
 
5287
5910
  | Job | Use |
@@ -5912,6 +6535,16 @@ Source: docs/semantics.md
5912
6535
 
5913
6536
  Guarantees and sharp edges. Read when something surprises you; not required to start.
5914
6537
 
6538
+ ## Five rules to know first
6539
+
6540
+ 1. A handler is `(payload, ctx)`. Use `ctx.signal`, `ctx.emit` and `ctx.on`; the rest of `ctx` is optional.
6541
+ 2. Pass `ctx.signal` to what the handler calls (`fetch`, a client), so an abort stops the work.
6542
+ 3. An aborted task delivers no result and emits nothing.
6543
+ 4. `emit` is synchronous: the first handler error is thrown, unless `onError` is set.
6544
+ 5. Rejections of async handlers are not handled without `onError`.
6545
+
6546
+ Details of each rule and the rest:
6547
+
5915
6548
  - **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.
5916
6549
  - **`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.
5917
6550
  - **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.
@@ -5927,6 +6560,22 @@ Find out what your app did and why: which events were emitted, which calls a pol
5927
6560
 
5928
6561
  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.
5929
6562
 
6563
+ ## On this page
6564
+
6565
+ - [Attach a tracer](#attach-a-tracer)
6566
+ - [Show every emit](#show-every-emit)
6567
+ - [Show which subscription began a chain](#show-which-subscription-began-a-chain)
6568
+ - [Follow one flow with a traceGroupId](#follow-one-flow-with-a-tracegroupid)
6569
+ - [Name a listener](#name-a-listener)
6570
+ - [Keep the chain: `ctx.emit`, not `bus.emit`](#keep-the-chain-ctxemit-not-busemit)
6571
+ - [Turn the names off](#turn-the-names-off)
6572
+ - [Use built-in loggers](#use-built-in-loggers)
6573
+ - [Cut volume in production](#cut-volume-in-production)
6574
+ - [Keep the last events for an incident](#keep-the-last-events-for-an-incident)
6575
+ - [Configure Redux DevTools](#configure-redux-devtools)
6576
+ - [Write your own logger](#write-your-own-logger)
6577
+ - [Drop tracing from a build](#drop-tracing-from-a-build)
6578
+
5930
6579
  ## Attach a tracer
5931
6580
 
5932
6581
  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.
@@ -6009,7 +6658,7 @@ The event changes nothing else: the other tasks keep their `parentId`, `rootId`
6009
6658
 
6010
6659
  ## Show which subscription began a chain
6011
6660
 
6012
- **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.
6661
+ **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.
6013
6662
 
6014
6663
  **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.
6015
6664
 
@@ -6037,8 +6686,8 @@ In a component, the same option goes in the options of the hook: `useEmitter(nam
6037
6686
 
6038
6687
  ```
6039
6688
  [nanoeffects] Profile · run start
6040
- [nanoeffects] user/save · command start
6041
- [nanoeffects] user/save · command done
6689
+ [nanoeffects] user/save · action start
6690
+ [nanoeffects] user/save · action done
6042
6691
  [nanoeffects] Profile · run done
6043
6692
  ```
6044
6693
 
@@ -6055,9 +6704,9 @@ Every event of a task below another one has `rootId`, the id of the root of the
6055
6704
 
6056
6705
  **Problem.** A flow such as a checkout has several roots (a click, a timer, a store) and many branches. Parent ids give a tree, but you want to filter the log by the flow.
6057
6706
 
6058
- **When.** One business flow runs through several commands, and you read the log by flow.
6707
+ **When.** One business flow runs through several actions, and you read the log by flow.
6059
6708
 
6060
- Give the flow a `traceGroupId` in the scope of the command, the job or the action that starts it. Every task that it starts through `ctx.emit` or `ctx.run` has the same `traceGroupId`, and so does every trace event of those tasks. A task that declares its own `traceGroupId` uses it for itself and for what it starts.
6709
+ Give the flow a `traceGroupId` in the scope of the action or the job that starts it. Every task that it starts through `ctx.emit` or `ctx.run` has the same `traceGroupId`, and so does every trace event of those tasks. A task that declares its own `traceGroupId` uses it for itself and for what it starts.
6061
6710
 
6062
6711
  ```js
6063
6712
  import { createBus } from "nanoeffects";
@@ -6078,7 +6727,7 @@ const pay = createAction("checkout/pay", async () => {}, {
6078
6727
  ```
6079
6728
 
6080
6729
  ```
6081
- [nanoeffects] [checkout] checkout/pay · command start
6730
+ [nanoeffects] [checkout] checkout/pay · action start
6082
6731
  ```
6083
6732
 
6084
6733
  The emitters take it too: `useEmitter(cartChanged, cart, { traceId: "Cart", traceGroupId: "checkout" })`. `traceGroupId` works without `scopePlugin`, and needs `tracePlugin`.
@@ -6108,7 +6757,7 @@ bus.once("name/changed", (name) => console.log(name), { traceId: "auditLog" });
6108
6757
 
6109
6758
  ```
6110
6759
  [nanoeffects] saveNotifier · run start
6111
- [nanoeffects] user/save · command start
6760
+ [nanoeffects] user/save · action start
6112
6761
  [nanoeffects] saveNotifier · run done
6113
6762
  ```
6114
6763
 
@@ -6125,7 +6774,7 @@ The names work wherever an event or a listener is made:
6125
6774
  | A listener, `bus.on` and `bus.once` | `{ traceId }` as the third argument |
6126
6775
  | A listener of a module or of an instance of `extendBus` | the same third argument of its `on` and `once` |
6127
6776
  | A method with `@On` or `@Once` | `{ traceId }` in the decorator |
6128
- | A command, a job or an action | `traceGroupId` in its scope |
6777
+ | An action or a job | `traceGroupId` in its scope |
6129
6778
 
6130
6779
  ## Turn the names off
6131
6780
 
@@ -6153,7 +6802,7 @@ const bus = createBus({
6153
6802
  | `reduxDevtools()` | Sends the emits to Redux DevTools, and with `policies` the stages of the handlers as `type/effect/phase`; state is `{ running }` |
6154
6803
  | `consoleLogger()` | Prints one line per phase, indented by task depth |
6155
6804
  | `performanceLogger()` | User Timing measures for the browser Performance panel |
6156
- | `createTraceRecorder()` | Keeps events in `recorder.events`, for tests |
6805
+ | `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)) |
6157
6806
 
6158
6807
  ### Cut volume in production
6159
6808
 
@@ -6169,8 +6818,60 @@ createTracer(logger, {
6169
6818
  - `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.
6170
6819
  - `filter` runs on each event before any logger sees it.
6171
6820
 
6821
+ ### Keep the last events for an incident
6822
+
6823
+ `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.
6824
+
6825
+ <!-- docs-check: new -->
6826
+
6827
+ ```js
6828
+ // bus.js
6829
+ import { createBus } from "nanoeffects";
6830
+ import { createTraceRecorder, createTracer } from "nanoeffects/trace";
6831
+
6832
+ // A logger that keeps the last 500 events and drops the older ones.
6833
+ export const recorder = createTraceRecorder({ capacity: 500 });
6834
+
6835
+ // Feed the recorder from the tracer.
6836
+ const trace = createTracer(recorder);
6837
+
6838
+ export const bus = createBus({ trace });
6839
+ ```
6840
+
6841
+ ```js
6842
+ // incident.js
6843
+ import { recorder } from "./bus";
6844
+
6845
+ // Keep what is safe to send. An event also has `payload` and `error`.
6846
+ const withoutPayload = ({ id, parentId, type, effect, phase }) => ({
6847
+ id,
6848
+ parentId,
6849
+ type,
6850
+ effect,
6851
+ phase,
6852
+ });
6853
+
6854
+ window.addEventListener("error", () => {
6855
+ const events = recorder.events; // a snapshot, oldest event first
6856
+ const safe = events.map(withoutPayload);
6857
+ const body = JSON.stringify(safe);
6858
+ navigator.sendBeacon("/api/incident", body);
6859
+ });
6860
+ ```
6861
+
6862
+ What to know before you send the events:
6863
+
6864
+ - **`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.
6865
+ - **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.
6866
+ - **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.
6867
+ - **`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.
6868
+ - **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).
6869
+ - **A `capacity` that is not a positive integer** (`0`, a negative number, `NaN`) means no limit.
6870
+
6172
6871
  ### Configure Redux DevTools
6173
6872
 
6873
+ <!-- docs-check: new -->
6874
+
6174
6875
  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.
6175
6876
 
6176
6877
  Quiet by default, so you see what happened before you see how:
@@ -6270,6 +6971,8 @@ Source: docs/observable.md
6270
6971
 
6271
6972
  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.
6272
6973
 
6974
+ 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).
6975
+
6273
6976
  `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.
6274
6977
 
6275
6978
  ## Turn an event into a stream
@@ -6413,6 +7116,47 @@ bus.on(
6413
7116
 
6414
7117
  `switchMap` drops the old inner stream. `latest` aborts the old task through `signal`, so a `fetch` that got the signal really stops.
6415
7118
 
7119
+ ## Read an event with a `for await` loop
7120
+
7121
+ 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.
7122
+
7123
+ `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.
7124
+
7125
+ ```js
7126
+ import { createBus } from "nanoeffects";
7127
+ import { events } from "nanoeffects/observable";
7128
+
7129
+ const bus = createBus();
7130
+ const controller = new AbortController();
7131
+
7132
+ // events — the "query/changed" payloads one by one; keeps the last 3 while the loop is busy
7133
+ const queries = events(bus, "query/changed", {
7134
+ signal: controller.signal,
7135
+ buffer: 3,
7136
+ });
7137
+
7138
+ async function watchQueries() {
7139
+ for await (const query of queries) {
7140
+ console.log("search for", query); // runs once per payload, in order
7141
+ }
7142
+ }
7143
+
7144
+ watchQueries().catch((reason) => console.log("stopped:", reason)); // the abort rejects the loop
7145
+ bus.emit("query/changed", "cat");
7146
+ controller.abort("screen closed"); // the loop ends with this reason
7147
+ ```
7148
+
7149
+ | Option | What it does | Default |
7150
+ | ------ | ------------ | ------- |
7151
+ | `signal` | Ends the loop. The waiting read rejects with the abort reason. | none |
7152
+ | `buffer` | The most payloads kept while the loop is busy. | `Infinity` |
7153
+ | `overflow` | Which payload goes when the buffer is full: `"drop-oldest"` or `"drop-newest"`. | `"drop-oldest"` |
7154
+
7155
+ - `break`, `return` and a throw inside the loop remove the listener.
7156
+ - With no `buffer`, a loop that never reads keeps every payload. Set `buffer` for a slow loop.
7157
+ - An event of a bus does not end, so the loop ends only by `break`, the `signal` or the task.
7158
+ - Inside a task, `events(ctx, "query/changed")` needs no `signal`: the task ending is the end.
7159
+
6416
7160
  ## Convert back to a stream or a promise
6417
7161
 
6418
7162
  | You need | Write |