nanoeffects 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +2 -2
  2. package/dist/{action-creator-Bd4TBZgl.d.ts → action-creator-BrZF6hXc.d.ts} +0 -4
  3. package/dist/actions.d.ts +41 -25
  4. package/dist/actions.js +1 -1
  5. package/dist/bus-CkTHUAvq.js +1 -0
  6. package/dist/context-COgyNlrC.js +1 -0
  7. package/dist/{context-plugin-BVNKqkIx.d.ts → context-plugin--HkNN1ha.d.ts} +1 -1
  8. package/dist/dev.d.ts +9 -8
  9. package/dist/dev.js +1 -1
  10. package/dist/effects.d.ts +2 -2
  11. package/dist/effects.js +1 -1
  12. package/dist/index.d.ts +2 -2
  13. package/dist/index.js +1 -1
  14. package/dist/match.d.ts +50 -0
  15. package/dist/match.js +1 -0
  16. package/dist/{module-DZ5ngUrM.d.ts → module-DqKegXHt.d.ts} +43 -18
  17. package/dist/notrace/{action-creator-D-y_-xRS.d.ts → action-creator-DHAEgwuh.d.ts} +0 -4
  18. package/dist/notrace/actions.d.ts +90 -39
  19. package/dist/notrace/actions.js +1 -1
  20. package/dist/notrace/bus-BmCTo8BG.js +1 -0
  21. package/dist/notrace/{context-plugin-DDfsaErA.d.ts → context-plugin-BapQkGwQ.d.ts} +1 -1
  22. package/dist/notrace/context-yYU3_9lq.js +1 -0
  23. package/dist/notrace/{delay-B_W1tnkS.js → delay-BosJKFjg.js} +1 -1
  24. package/dist/notrace/effects.d.ts +2 -2
  25. package/dist/notrace/effects.js +1 -1
  26. package/dist/notrace/index.d.ts +2 -2
  27. package/dist/notrace/index.js +1 -1
  28. package/dist/notrace/{plugin-CDAngHwg.d.ts → plugin-Cxadz6uU.d.ts} +2 -2
  29. package/dist/notrace/policies.d.ts +30 -2
  30. package/dist/notrace/policies.js +1 -1
  31. package/dist/notrace/redux.d.ts +1 -1
  32. package/dist/observable.d.ts +1 -1
  33. package/dist/oop.d.ts +131 -0
  34. package/dist/oop.js +1 -0
  35. package/dist/{plugin-DQNPBcxx.d.ts → plugin-DkXCvsmB.d.ts} +2 -2
  36. package/dist/policies.d.ts +3 -2
  37. package/dist/policies.js +1 -1
  38. package/dist/react.d.ts +33 -1
  39. package/dist/react.js +1 -1
  40. package/dist/redux.d.ts +1 -1
  41. package/dist/scope.d.ts +5 -6
  42. package/dist/scope.js +1 -1
  43. package/dist/signal.d.ts +51 -0
  44. package/dist/signal.js +1 -0
  45. package/dist/trace.d.ts +1 -1
  46. package/dist/{types-BJ0fzV1p.d.ts → types-KavbvHix.d.ts} +13 -3
  47. package/dist/with-policy-CqUS-VWW.d.ts +30 -0
  48. package/dist/with-policy-DBV9dh2F.js +1 -0
  49. package/docs/README.md +7 -0
  50. package/docs/actions.md +152 -39
  51. package/docs/cancel-stale-requests.md +111 -0
  52. package/docs/debounce-and-abort.md +106 -0
  53. package/docs/guide.md +143 -19
  54. package/docs/hmr.md +6 -5
  55. package/docs/migrating-from-effector.md +51 -18
  56. package/docs/migrating-from-mitt.md +97 -0
  57. package/docs/migrating-from-rxjs.md +17 -6
  58. package/docs/motivation.md +41 -0
  59. package/docs/observable.md +124 -10
  60. package/docs/oop.md +286 -0
  61. package/docs/package.md +6 -2
  62. package/docs/plugins.md +247 -51
  63. package/docs/policies.md +33 -0
  64. package/docs/prevent-double-submit.md +128 -0
  65. package/docs/query-libraries.md +124 -0
  66. package/docs/react.md +61 -5
  67. package/docs/scope.md +60 -22
  68. package/docs/stores.md +263 -109
  69. package/llms-full.txt +2153 -355
  70. package/llms.txt +10 -3
  71. package/package.json +43 -4
  72. package/skills/nanoeffects/SKILL.md +52 -0
  73. package/dist/bus-BAs9x7IQ.js +0 -1
  74. package/dist/context-CK0ZomWt.js +0 -1
  75. package/dist/notrace/bus-Bdk0YyFQ.js +0 -1
  76. package/dist/notrace/context-CctVmHLC.js +0 -1
package/docs/plugins.md CHANGED
@@ -1,54 +1,222 @@
1
1
  # Choose the plugins of a bus
2
2
 
3
- 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 returns, and in what order to use them. Skip it for a small bus: `createBus()` alone is enough.
3
+ 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.
4
4
 
5
5
  ## Use a plugin
6
6
 
7
- A plugin is a function of the bus. `bus.use(plugin)` calls it with the bus and returns whatever the plugin returns.
7
+ 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.
8
+
9
+ 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 }))`).
10
+
11
+ ## Main plugins
12
+
13
+ These four cover what most apps need: shared dependencies, effects, commands and modules. Start here.
14
+
15
+ | Plugin | What it gives you | Import | `bus.use` returns |
16
+ | ------ | ----------------- | ------ | ----------------- |
17
+ | [`contextPlugin`](#put-a-store-or-a-client-on-every-handler-contextplugin) | Your own fields (a store, a client, a logger) on `ctx` of every handler. | `nanoeffects` | The same bus, typed with the fields |
18
+ | [`effectsPlugin`](#use-race-and-delay-without-ctx-effectsplugin) | `race`, `all`, `delay` and your own effects on `ctx`, called without passing `ctx`. | `nanoeffects/effects` | The same bus, typed with the effects |
19
+ | [`commandsPlugin`](#let-a-test-replace-a-handler-commandsplugin) | The handlers of commands in a registry, so a test can replace one. | `nanoeffects/actions` | The registry `{ run, handle }` |
20
+ | [`modulePlugin`](#mount-a-domain-as-one-unit-moduleplugin) | A module: the listeners, handlers and tasks of one domain, mounted and unmounted with one call. | `nanoeffects/actions` | The app `{ mount, unmount, isMounted, dispose, commands, createModule }` |
21
+
22
+ ### Put a store or a client on every handler: `contextPlugin`
23
+
24
+ Every task gets the same fields on `ctx`: a store, an API client, a logger. The types come from the values. More in [Context fields](context.md).
25
+
26
+ ```js
27
+ import { contextPlugin, createBus } from "nanoeffects";
28
+
29
+ // contextPlugin — every handler gets ctx.store
30
+ const bus = createBus().use(contextPlugin({ store }));
31
+
32
+ bus.on("save", (draft, { store }) => store.put(draft));
33
+ ```
34
+
35
+ ### Use `race` and `delay` without `ctx`: `effectsPlugin`
36
+
37
+ 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).
8
38
 
9
39
  ```js
10
40
  import { createBus } from "nanoeffects";
41
+ import { delay, effectsPlugin, race } from "nanoeffects/effects";
11
42
 
12
- // your own plugin: a function of the bus that returns what you want back
13
- const countPlugin = (bus) => {
14
- let count = 0;
15
- const unbind = bus.on("*", () => {
16
- count += 1;
43
+ // effectsPlugin — race and delay are bound to the task of each handler
44
+ const bus = createBus().use(effectsPlugin({ race, delay }));
45
+
46
+ bus.on("load", async (id, { race, delay }) => {
47
+ const result = await race({
48
+ data: ({ signal }) => api.get(id, { signal }),
49
+ timeout: () => delay(5000),
17
50
  });
18
- return { count: () => count, stop: unbind };
19
- };
51
+ });
52
+ ```
53
+
54
+ ### Let a test replace a handler: `commandsPlugin`
55
+
56
+ Commands get their handlers from a registry that belongs to the bus. A command can be only a contract, and a test binds a fake. More in [Actions](actions.md#describe-events-commands-and-jobs).
20
57
 
58
+ ```js
59
+ import { createBus } from "nanoeffects";
60
+ import { commandsPlugin, createCommand } from "nanoeffects/actions";
61
+
62
+ const getUser = createCommand("user/get"); // a contract: no handler yet
21
63
  const bus = createBus();
22
- // use — calls countPlugin(bus) and returns its result
23
- const counter = bus.use(countPlugin);
24
64
 
25
- bus.emit("greet", "Ada");
26
- console.log(counter.count()); // 1
27
- counter.stop();
65
+ // commandsPlugin — the registry of handlers of this bus
66
+ const commands = bus.use(commandsPlugin());
67
+
68
+ commands.handle(getUser, async (id) => ({ id })); // the latest binding wins
69
+ const user = await bus.emit(getUser("1")); // { id: "1" }
70
+ ```
71
+
72
+ ### Mount a domain as one unit: `modulePlugin`
73
+
74
+ A module groups the listeners, the bound commands and the background tasks of one domain, and ends all of them when it is unmounted. It also makes [hot reload](hmr.md) work. More in [Modules](actions.md#mount-domains-with-modules).
75
+
76
+ ```js
77
+ import { createBus } from "nanoeffects";
78
+ import { modulePlugin } from "nanoeffects/actions";
79
+
80
+ const bus = createBus();
81
+
82
+ // modulePlugin — mounts userModule on the first use of the app or the bus, and returns the app
83
+ const app = bus.use(modulePlugin([userModule]));
84
+
85
+ app.unmount(userModule); // its listeners, bound commands and tasks are gone
28
86
  ```
29
87
 
30
- The bus keeps no list of plugins. A plugin that was used cannot be taken out by the bus; it returns its own `stop` or `disconnect` when it needs one.
88
+ ## Extra plugins
89
+
90
+ Add one of these when you meet its need. None of them is required.
31
91
 
32
- ## See every plugin
92
+ | Plugin | What it gives you | Import | `bus.use` returns |
93
+ | ------ | ----------------- | ------ | ----------------- |
94
+ | [`scopePlugin`](#give-one-command-its-own-fields-scopeplugin) | Its own fields for one command, action, job or module, and a way to replace them in one `emit`. | `nanoeffects/scope` | The same bus |
95
+ | [`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` |
96
+ | [`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 |
97
+ | [`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` |
98
+ | [`reactPlugin`](#tell-the-bus-when-a-react-value-changes-reactplugin) | A React hook that emits when a value of a component changes. | `nanoeffects/react` | The same bus, with `useEmitter` |
99
+ | [`devPlugin`](#find-leaks-while-you-develop-devplugin) | Warnings about listeners that pile up, and messages about modules, while you develop. | `nanoeffects/dev` | A function that disconnects it |
33
100
 
34
- | Plugin | Import | It adds | `bus.use` returns | Page |
35
- | ------ | ------ | ------- | ----------------- | ---- |
36
- | `contextPlugin` | `nanoeffects` | Fields on `ctx` of every task | The same bus, typed with the fields | [Context fields](context.md) |
37
- | `effectsPlugin` | `nanoeffects/effects` | `race`, `all`, `take`, `delay` and your own effects on `ctx`, bound to the task | The same bus, typed with the effects | [Effects](effects.md#put-selected-effects-on-ctx) |
38
- | `scopePlugin` | `nanoeffects/scope` | Fields and effects for one command, action, job, module or `emit` | The same bus | [Scopes](scope.md) |
39
- | `commandsPlugin` | `nanoeffects/actions` | The registry of command handlers, `bus.commands` | The registry `{ run, handle }` | [Actions](actions.md#describe-events-commands-and-jobs) |
40
- | `modulePlugin` | `nanoeffects/actions` | Modules that mount and unmount as one unit | The app `{ mount, unmount, isMounted, dispose, commands, createModule }` | [Modules](actions.md#mount-domains-with-modules) |
41
- | `emitterPlugin` | `nanoeffects/actions` | `bus.emitter(creator)`, a callback that emits an action | The same bus, with `emitter` | [Stores](stores.md#emit-from-a-state-manager) |
42
- | `reactPlugin` | `nanoeffects/react` | `bus.useEmitter(event, value)`, a React hook | The same bus, with `useEmitter` | [React and stores](stores.md#react-sharing-a-value-across-components) |
43
- | `devPlugin` | `nanoeffects/dev` | Warnings about listeners that pile up, and messages about modules | A function that disconnects it | [Hot reload](hmr.md#report-leaks-with-devplugin) |
101
+ ### Give one command its own fields: `scopePlugin`
44
102
 
45
- "The same bus" means you can keep writing `bus` and ignore the result. Keep the result of `contextPlugin` and `effectsPlugin` in TypeScript, though: the type of the bus with its new fields is the type they return (`const bus = createBus().use(contextPlugin({ store }))`).
103
+ A command, an action, a job or a module can carry its own fields and effects, and one `emit` can replace them. A test gives a fake to one call. More in [Scopes](scope.md).
104
+
105
+ ```js
106
+ import { createBus } from "nanoeffects";
107
+ import { createCommand } from "nanoeffects/actions";
108
+ import { scopePlugin } from "nanoeffects/scope";
109
+
110
+ // scopePlugin — makes the scope of a command work on this bus
111
+ const bus = createBus().use(scopePlugin());
112
+
113
+ // the last argument: fields of this command only
114
+ const getUser = createCommand("user/get", (id, { api }) => api.getUser(id), {
115
+ api,
116
+ });
117
+
118
+ const fakeApi = { getUser: async (id) => ({ id, name: "test" }) };
119
+ bus.emit(getUser("1"), { api: fakeApi }); // a fake api for this call only
120
+ ```
121
+
122
+ ### Tie an emit to an outside signal: `signalPlugin`
123
+
124
+ 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).
125
+
126
+ ```js
127
+ import { createBus } from "nanoeffects";
128
+ import { createCommand } from "nanoeffects/actions";
129
+ import { signalPlugin } from "nanoeffects/signal";
130
+
131
+ // signalPlugin — emit takes { signal } for a command, a job or an action
132
+ const bus = createBus().use(signalPlugin());
133
+
134
+ const getUser = createCommand("user/get", (id, { signal }) => api.getUser(id, { signal }));
135
+
136
+ const controller = new AbortController();
137
+ bus.emit(getUser("1"), { signal: controller.signal }); // runs the command
138
+ controller.abort(); // ctx.signal of the handler aborts with it
139
+ ```
140
+
141
+ ### Listen to a group of events: `matchPlugin`
142
+
143
+ `on`, `once` and `off` take a function that picks the events, such as every `user/*` event. A type guard narrows the payload. The subscription listens to every event, so for a few known events `on(creator, handler)` is cheaper. More in the [Guide](guide.md#pick-events-with-a-function).
144
+
145
+ ```js
146
+ import { createBus } from "nanoeffects";
147
+ import { matchPlugin } from "nanoeffects/match";
148
+
149
+ // matchPlugin — on takes a function that picks the events
150
+ const bus = createBus().use(matchPlugin());
151
+
152
+ bus.on(
153
+ (action) => action.type.startsWith("user/"),
154
+ (payload, ctx) => console.log(ctx.type, payload),
155
+ );
156
+ ```
157
+
158
+ ### Hand the bus to a state manager: `emitterPlugin`
159
+
160
+ `bus.emitter(creator)` is a callback that emits an action. A state manager takes it as its subscription callback, so a change of the store becomes an event. More in [Stores](stores.md#emit-from-a-state-manager).
161
+
162
+ ```js
163
+ import { atom } from "nanostores";
164
+ import { createBus } from "nanoeffects";
165
+ import { createAction, emitterPlugin } from "nanoeffects/actions";
166
+
167
+ // emitterPlugin — adds bus.emitter
168
+ const bus = createBus().use(emitterPlugin());
169
+ const nameChanged = createAction("name/changed");
170
+ const $name = atom("");
171
+
172
+ $name.listen(bus.emitter(nameChanged)); // every change of $name emits nameChanged
173
+ ```
174
+
175
+ ### Tell the bus when a React value changes: `reactPlugin`
176
+
177
+ `useEmitter(event, value)` emits after a render commits with a new value. It does not emit on mount, and not twice under StrictMode. It needs React 18 or newer. More in [Stores](stores.md#react-sharing-a-value-across-components).
178
+
179
+ ```jsx
180
+ import { useState } from "react";
181
+ import { createBus } from "nanoeffects";
182
+ import { reactPlugin } from "nanoeffects/react";
183
+
184
+ // reactPlugin — adds bus.useEmitter; destructuring gives a free hook
185
+ const bus = createBus().use(reactPlugin());
186
+ const { useEmitter } = bus;
187
+
188
+ function Profile() {
189
+ const [name, setName] = useState("");
190
+ useEmitter("name/changed", name); // emits after a render with a new name
191
+ return <input value={name} onChange={(event) => setName(event.target.value)} />;
192
+ }
193
+ ```
194
+
195
+ ### Find leaks while you develop: `devPlugin`
196
+
197
+ It warns about listeners that were added and never unbound, and reports the modules that are mounted, replaced and unmounted. In a production build `nanoeffects/dev` is empty, so the call can stay. More in [Hot reload](hmr.md#report-leaks-with-devplugin).
198
+
199
+ ```js
200
+ import { createBus } from "nanoeffects";
201
+ import { modulePlugin } from "nanoeffects/actions";
202
+ import { devPlugin } from "nanoeffects/dev";
203
+
204
+ const bus = createBus();
205
+ bus.use(modulePlugin([userModule]));
206
+
207
+ // devPlugin — warnings and messages in the console while you develop;
208
+ // it finds the app of modulePlugin on the bus, whichever you use first
209
+ const disconnect = bus.use(devPlugin());
210
+
211
+ disconnect(); // stops the diagnostics
212
+ ```
46
213
 
47
214
  ## Put them together
48
215
 
49
- One bus with every plugin that does not need React. The order is explained in the next section.
216
+ One bus with every plugin that does not need React, in a file of its own. The events of the app stay in other files. The plugins work in any order; the next section says what that means for modules.
50
217
 
51
218
  ```js
219
+ // bus.js
52
220
  import { contextPlugin, createBus } from "nanoeffects";
53
221
  import {
54
222
  commandsPlugin,
@@ -57,47 +225,45 @@ import {
57
225
  } from "nanoeffects/actions";
58
226
  import { devPlugin } from "nanoeffects/dev";
59
227
  import { all, effectsPlugin, race } from "nanoeffects/effects";
228
+ import { matchPlugin } from "nanoeffects/match";
60
229
  import { scopePlugin } from "nanoeffects/scope";
61
230
 
62
- const bus = createBus()
231
+ export const bus = createBus()
63
232
  .use(contextPlugin({ store })) // ctx.store on every task
64
233
  .use(effectsPlugin({ race, all })) // ctx.race and ctx.all, bound to the task
65
234
  .use(scopePlugin()) // scopes for commands, actions, jobs, modules and emit
235
+ .use(matchPlugin()) // bus.on takes a function that picks the events
66
236
  .use(emitterPlugin()); // bus.emitter(creator)
67
237
 
68
238
  // commandsPlugin — the registry of command handlers; keep it to bind handlers later
69
- const commands = bus.use(commandsPlugin());
239
+ export const commands = bus.use(commandsPlugin());
70
240
 
71
- // modulePlugin — mounts userModule now; keep app to mount and unmount later
72
- const app = bus.use(modulePlugin([userModule]));
241
+ // modulePlugin — mounts userModule on the first use; keep app to mount and unmount later
242
+ export const app = bus.use(modulePlugin([userModule]));
73
243
 
74
244
  // devPlugin — reports leaks and modules while you develop; keep disconnect to remove it
75
- const disconnect = bus.use(devPlugin({ app }));
245
+ export const disconnect = bus.use(devPlugin());
76
246
  ```
77
247
 
78
- ## Follow the order
248
+ ## Use them in any order
249
+
250
+ The plugins give the same result in any order. Use them as they read best, and put the app of `modulePlugin` where you keep it.
79
251
 
80
- Most plugins do not care about each other. These pairs do:
252
+ Two things happen because modules are mounted late:
81
253
 
82
- | Rule | Why |
83
- | ---- | --- |
84
- | `scopePlugin` after `effectsPlugin` | `effectsPlugin` assigns its effects on `ctx`, so one used later would replace an effect with the same name that a scope put there. |
85
- | `scopePlugin` before `modulePlugin` | `modulePlugin` mounts the modules when it is used, and a module with a scope needs the plugin by then. |
86
- | `commandsPlugin` before `modulePlugin`, if you bind handlers yourself | `modulePlugin` uses the registry that exists. Without one it makes its own, which is `app.commands`. |
87
- | `modulePlugin` before `devPlugin({ app })` | `devPlugin` reports the modules of the `app` that `modulePlugin` returned. |
88
- | `emitterPlugin` and `reactPlugin` anywhere | They only call `bus.emit`, which is read when you call them. |
254
+ - **Modules start on the first use.** `modulePlugin([userModule])` mounts `userModule` when the app or the bus is first used: the first `bus.emit`, or the first call of `app.mount`, `app.unmount`, `app.isMounted`, `app.dispose` or the read of `app.commands`. By then every plugin is on the bus, so a module and the tasks it spawns have the fields, effects and scopes of the plugins you used after `modulePlugin`, and `m.on(fn, handler)` works with `matchPlugin`.
255
+ - **Listeners keep their place.** A `bus.on` after `bus.use(modulePlugin(...))` is held until the modules are mounted, then made, in the order you wrote them. The listeners of a module therefore stay ahead of the ones registered after the plugin, and behind the ones registered before it. The function `bus.on` returns works before and after.
89
256
 
90
- Two more things the checker enforces. A name that is already a context field of the bus is a type error when `contextPlugin` or `effectsPlugin` adds it again. `contextPlugin` does not replace a field that is on `ctx` already, so a scope wins over it in any order.
257
+ Other plugins do not need an order for another reason:
91
258
 
92
- ## Choose by need
259
+ - `commandsPlugin` binds its list in the registry that the bus has, so it can come before or after `modulePlugin`. A custom `bus.commands = { run }` is replaced.
260
+ - `effectsPlugin` and `contextPlugin` do not replace a field that is on `ctx` already, so a scope wins in any order.
261
+ - `devPlugin()` finds the app that `modulePlugin` put on the bus (`bus.app`). `devPlugin({ app })` still works.
262
+ - `matchPlugin` and `devPlugin` both wrap `bus.on`; `devPlugin` counts a function that picks events under `"*"`, and its `disconnect` leaves a wrapper that was put on top of it.
93
263
 
94
- - **A store, a client or a logger on every handler.** `contextPlugin`. Or `createBus({ context })` when you do not need the types from the value.
95
- - **`race`, `all` or your own effect without passing `ctx`.** `effectsPlugin`.
96
- - **Fields for one command, or a fake for one test call.** `scopePlugin`.
97
- - **A request with a result, or handlers that a test can replace.** `commandsPlugin`. Add `modulePlugin` when the domain needs a mount and an unmount.
98
- - **A callback for a state manager, such as `$name.listen(...)`.** `emitterPlugin`.
99
- - **Tell the bus when a value of a React component changes.** `reactPlugin`.
100
- - **Find listeners that were never unbound, or see modules reload.** `devPlugin`. In a production build `nanoeffects/dev` is empty, so the call can stay.
264
+ A name that is already a context field of the bus is a type error when `contextPlugin` or `effectsPlugin` adds it again.
265
+
266
+ A test puts every plugin on a bus in many orders and checks what each one does (`src/tests/plugin-matrix.test.ts`). An order that breaks something is a bug.
101
267
 
102
268
  ## See what is not a plugin
103
269
 
@@ -105,4 +271,34 @@ These connect the bus to something, but are not used with `bus.use`:
105
271
 
106
272
  - `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).
107
273
  - `createBus({ trace })` takes a tracer from `nanoeffects/trace`. See [Tracing](tracing.md).
274
+ - `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).
108
275
  - `createObserve(bus)` from `nanoeffects/observable` turns an event into an Observable. See [Observable](observable.md).
276
+
277
+ ## Write your own plugin
278
+
279
+ A plugin is a function of the bus that returns what you want back. The bus keeps no list of plugins, so a plugin that was used cannot be taken out by the bus; it returns its own `stop` or `disconnect` when it needs one.
280
+
281
+ ```js
282
+ // count-plugin.js
283
+ // your own plugin: a function of the bus that returns what you want back
284
+ export const countPlugin = (bus) => {
285
+ let count = 0;
286
+ const unbind = bus.on("*", () => {
287
+ count += 1;
288
+ });
289
+ return { count: () => count, stop: unbind };
290
+ };
291
+ ```
292
+
293
+ ```js
294
+ import { createBus } from "nanoeffects";
295
+ import { countPlugin } from "./count-plugin";
296
+
297
+ const bus = createBus();
298
+ // use — calls countPlugin(bus) and returns its result
299
+ const counter = bus.use(countPlugin);
300
+
301
+ bus.emit("greet", "Ada");
302
+ console.log(counter.count()); // 1
303
+ counter.stop();
304
+ ```
package/docs/policies.md CHANGED
@@ -114,3 +114,36 @@ Every call returns a promise with the result of its own run. An error rejects on
114
114
  - A waiting call whose task aborts is skipped and resolves with `undefined`.
115
115
  - When a running call aborts, its slot is freed at once and its result is dropped.
116
116
  - Pass `signal` into what `fn` does. A `fn` that ignores it can overlap with the next run.
117
+
118
+ ## Use a policy without a bus
119
+
120
+ **Problem.** You want a debounced input handler or a "latest request wins" fetch, but your code has no bus. A policy needs a context on every call, so `debounce(300, search)("re")` does not work on its own.
121
+
122
+ **When.** One component or module needs a policy and nothing else from the library. If you already have a bus, `bus.on(event, policy)` is simpler.
123
+
124
+ `withPolicy` takes the result of any policy and returns a plain function. It needs no bus and no context argument, and it keeps the state of the policy between calls.
125
+
126
+ ```js
127
+ import { debounce, withPolicy } from "nanoeffects/policies";
128
+
129
+ // debounce — waits for 300 ms of quiet, then runs the callback
130
+ const policy = debounce(300, async (query, { signal }) => {
131
+ await api.search(query, { signal });
132
+ });
133
+
134
+ // withPolicy — a plain function: no bus, no context argument
135
+ const onChange = withPolicy(policy);
136
+
137
+ onChange("re");
138
+ onChange("redux"); // only this call runs, 300 ms after it
139
+
140
+ // cancel — drops a pending call and aborts a running one
141
+ onChange.cancel();
142
+ ```
143
+
144
+ - The callback has the same `(payload, ctx)` shape as for a bus. `ctx.signal`, `ctx.abort` and `ctx.run` work. `ctx.emit` and `ctx.on` do nothing, because there is no bus.
145
+ - Every call returns a promise with the result. A call the policy drops resolves with `undefined`.
146
+ - `cancel()` aborts every call that has not finished. Calls made after it work again.
147
+ - Where `Symbol.dispose` exists, `using onChange = withPolicy(policy)` calls `cancel()` at the end of the scope.
148
+
149
+ In a React component, use [`usePolicy`](react.md#keep-a-debounced-handler-between-renders) instead: it keeps the function between renders.
@@ -0,0 +1,128 @@
1
+ # Prevent a double submit
2
+
3
+ Read this page if a second click on "Pay", "Sign up" or "Save" sends the request twice. It shows how to ignore the second click while the first request runs, how to do it per item, and when to queue the calls instead.
4
+
5
+ ## See the problem
6
+
7
+ A user clicks twice. Both clicks run the handler, and the server gets two requests.
8
+
9
+ ```js
10
+ import { createBus } from "nanoeffects";
11
+
12
+ const bus = createBus(); // the bus: events and handlers
13
+
14
+ // Without a policy every "submitted" starts its own request.
15
+ bus.on("submitted", async (form) => {
16
+ const order = await api.pay(form);
17
+ bus.emit("paid", order);
18
+ });
19
+ ```
20
+
21
+ A `disabled` button helps only until the next render. A flag in a variable (`if (busy) return`) must be reset on every error path and after an abort.
22
+
23
+ ## Ignore clicks while a request runs
24
+
25
+ Wrap the handler in `leading`. While one run is going, new calls are dropped. When the run ends, the next click works.
26
+
27
+ ```js
28
+ import { createBus } from "nanoeffects";
29
+ import { leading } from "nanoeffects/policies";
30
+
31
+ const bus = createBus(); // the bus: events and handlers
32
+
33
+ // leading: a call is dropped while a previous run is still going
34
+ bus.on(
35
+ "submitted",
36
+ leading(async (form, ctx) => {
37
+ const order = await api.pay(form, { signal: ctx.signal });
38
+ ctx.emit("paid", order);
39
+ }),
40
+ );
41
+ ```
42
+
43
+ What to replace: `api.pay` is your own request function.
44
+
45
+ Result: `emit("submitted", form)` twice in a row sends one request. The second call returns at once and does nothing.
46
+
47
+ `leading` frees its lock when the task ends or aborts. If the handler throws, the next click works again, with no flag to reset.
48
+
49
+ ## Allow one run per item
50
+
51
+ A list of rows, each with its own "Delete" button, should not block the whole list. Give each item its own state with `key`.
52
+
53
+ ```js
54
+ import { createBus } from "nanoeffects";
55
+ import { leading } from "nanoeffects/policies";
56
+
57
+ const bus = createBus(); // the bus: events and handlers
58
+
59
+ // leading with key: one running call per row id, other rows are not blocked
60
+ bus.on(
61
+ "row/deleted",
62
+ leading(
63
+ async (row, ctx) => {
64
+ await api.deleteRow(row.id, { signal: ctx.signal });
65
+ ctx.emit("row/removed", row.id);
66
+ },
67
+ { key: (row) => row.id },
68
+ ),
69
+ );
70
+ ```
71
+
72
+ ## Retry only when the network failed
73
+
74
+ A retry after a failed payment can charge twice if the server did run the first request. Retry only on errors where the request did not reach the server, and make the server accept an idempotency key.
75
+
76
+ ```js
77
+ import { createBus } from "nanoeffects";
78
+ import { leading } from "nanoeffects/policies";
79
+ import { retry } from "nanoeffects/effects";
80
+
81
+ const bus = createBus(); // the bus: events and handlers
82
+
83
+ // fetch rejects with a TypeError when the network fails; an HTTP error is a plain Error
84
+ const isNetworkError = (error) => error instanceof TypeError;
85
+
86
+ bus.on(
87
+ "submitted",
88
+ leading(async (form, ctx) => {
89
+ const pay = () => api.pay(form, { signal: ctx.signal });
90
+ // retry: up to 3 tries, 500 ms apart, only when the network failed
91
+ const order = await retry(ctx, pay, { times: 3, delay: 500, shouldRetry: isNetworkError });
92
+ ctx.emit("paid", order);
93
+ }),
94
+ );
95
+ ```
96
+
97
+ Backoff between tries: [Back off between retries](effects.md#back-off-between-retries).
98
+
99
+ ## Guard a handler when you have no bus
100
+
101
+ `withPolicy` turns a policy into a plain function, so a click handler needs no bus.
102
+
103
+ ```js
104
+ import { leading, withPolicy } from "nanoeffects/policies";
105
+
106
+ // leading: ignore new calls while a run is going
107
+ const policy = leading(async (form, { signal }) => {
108
+ await api.save(form, { signal });
109
+ });
110
+
111
+ // withPolicy: a plain function to call from onSubmit
112
+ const submit = withPolicy(policy);
113
+
114
+ submit({ name: "Ada" }); // runs
115
+ submit({ name: "Ada" }); // ignored while the first runs; resolves with undefined
116
+ ```
117
+
118
+ In a React component use [`usePolicy`](react.md#keep-a-debounced-handler-between-renders).
119
+
120
+ ## Pick another policy when clicks must not be lost
121
+
122
+ | You want | Policy |
123
+ | -------- | ------ |
124
+ | Ignore a second click while the first runs (pay, sign up) | `leading` |
125
+ | Run every click, one after another (save a draft, upload files) | `queue` |
126
+ | Keep only the newest click and cancel the older (filters, tabs) | `latest` |
127
+
128
+ Related: [Policies](policies.md), [Build a sign-up form in React](react.md).
@@ -0,0 +1,124 @@
1
+ # Use nanoeffects with React Query and SWR
2
+
3
+ Read this page if you fetch data with [React Query](https://tanstack.com/query) (TanStack Query) or [SWR](https://swr.vercel.app) and want `retry`, `timeout` and the abort of nanoeffects inside the request. It shows one command that both libraries call.
4
+
5
+ You need a React project, `nanoeffects`, and one of the two libraries. `api` below is your own client: replace it with your requests.
6
+
7
+ ## Decide who owns what
8
+
9
+ The libraries and nanoeffects do different jobs, so each keeps its own.
10
+
11
+ | React Query or SWR | nanoeffects |
12
+ | ------------------ | ----------- |
13
+ | `isLoading`, `data`, `error` | `retry`, `timeout`, `latest` and other policies |
14
+ | the cache, one request for the same key | the abort that reaches the request |
15
+ | refetch on focus, on reconnect, on interval | the handler: the same code for a test, a worker and the UI |
16
+
17
+ `isLoading` and `data` come from the library, not from `emit`. `await bus.emit(...)` resolves once, with the result, so it cannot report that the request is still running.
18
+
19
+ ## Make the command
20
+
21
+ A command is the request itself. It lives in files that know nothing about React: the bus, the command with its handler, and the component.
22
+
23
+ ```js
24
+ // bus.js
25
+ import { createBus } from "nanoeffects";
26
+ import { signalPlugin } from "nanoeffects/signal";
27
+
28
+ // createBus — the bus the app shares
29
+ // signalPlugin — lets emit take { signal }, the signal of the library
30
+ export const bus = createBus().use(signalPlugin());
31
+ ```
32
+
33
+ The handler tries the request up to three times and gives up after five seconds for each try. `ctx.signal` is the signal of the task: it aborts when the library aborts the request.
34
+
35
+ ```js
36
+ // user.js
37
+ import { createCommand } from "nanoeffects/actions";
38
+ import { retry, timeout } from "nanoeffects/effects";
39
+
40
+ // createCommand — a command with a handler: emit returns what the handler returns
41
+ export const getUser = createCommand("user/get", async (id, ctx) => {
42
+ // one try: the request, with the signal of timeout so it can stop it
43
+ const load = ({ signal }) => api.getUser(id, { signal });
44
+ // timeout — fail the try after 5 s
45
+ const attempt = () => timeout(ctx, 5000, load);
46
+ // retry — up to 3 tries, 500 ms apart
47
+ return retry(ctx, attempt, { times: 3, delay: 500 });
48
+ });
49
+ ```
50
+
51
+ ## Call it from React Query
52
+
53
+ <!-- docs-check: continue -->
54
+
55
+ React Query gives `queryFn` a `signal` and aborts it when the query becomes inactive or out of date. Pass it to `emit`. The task is aborted with it, so `timeout` and `retry` stop, and so does the request.
56
+
57
+ ```jsx
58
+ // Profile.jsx
59
+ import { useQuery } from "@tanstack/react-query";
60
+ import { bus } from "./bus";
61
+ import { getUser } from "./user";
62
+
63
+ export function Profile({ id }) {
64
+ const { isLoading, data, error } = useQuery({
65
+ queryKey: ["user", id],
66
+ // emit — runs the command; { signal } ties its task to the signal of React Query
67
+ queryFn: ({ signal }) => bus.emit(getUser(id), { signal }),
68
+ });
69
+
70
+ if (isLoading) return <p>Loading…</p>;
71
+ if (error) return <p>{error.message}</p>;
72
+ return <p>{data.name}</p>;
73
+ }
74
+ ```
75
+
76
+ The command rejects when all tries fail, so React Query shows the `error`. React Query counts its own `retry` too: set `retry: false` in `useQuery` when the handler already retries, or the tries multiply.
77
+
78
+ ## Call it from SWR
79
+
80
+ <!-- docs-check: continue -->
81
+
82
+ SWR does not pass a signal to the fetcher, so there is nothing to give to `emit`. The command still brings `retry` and `timeout`.
83
+
84
+ ```jsx
85
+ // Profile.jsx
86
+ import useSWR from "swr";
87
+ import { bus } from "./bus";
88
+ import { getUser } from "./user";
89
+
90
+ // the fetcher gets the key (here the id) and returns a promise
91
+ const fetcher = (id) => bus.emit(getUser(id));
92
+
93
+ export function Profile({ id }) {
94
+ const { isLoading, data, error } = useSWR(id, fetcher);
95
+
96
+ if (isLoading) return <p>Loading…</p>;
97
+ if (error) return <p>{error.message}</p>;
98
+ return <p>{data.name}</p>;
99
+ }
100
+ ```
101
+
102
+ ## Let the bus know the result
103
+
104
+ <!-- docs-check: continue -->
105
+
106
+ A command gives its result only to the caller. If a store or another module must react to a loaded user, the handler can say so:
107
+
108
+ ```js
109
+ // user-events.js
110
+ import { createAction } from "nanoeffects/actions";
111
+
112
+ // createAction — an event with a payload
113
+ export const userLoaded = createAction("user/loaded");
114
+ ```
115
+
116
+ Send it from the handler with `ctx.emit(userLoaded(user))`. Do not replace the command with `createJob` here: a job handles the error itself and resolves with `undefined`, so React Query would never see the failure. Use a job when nothing waits for the result and the app shows a loading state from the bus, see [Show a loading state with a job](actions.md#show-a-loading-state-with-a-job).
117
+
118
+ ## Know the limits
119
+
120
+ - **Without the plugin, `{ signal }` is ignored.** The command runs, but the abort does not reach it. Call `bus.use(signalPlugin())` once, where the bus is made.
121
+ - **Only the call forms take it.** `bus.emit(getUser(id), { signal })` works; `bus.emit("user/get", id, { signal })` does not.
122
+ - **A handler that returns without a promise is finished with the call**, so a signal that aborts later does not reach it. Commands that fetch are async, so this only matters for a sync handler that sets up a callback.
123
+ - **Give each request its own signal for an event.** For a command or a job the link ends when it settles. For `bus.emit(action, { signal })` it stays until the signal aborts.
124
+ - **The signal reaches the tasks that this `emit` starts**, with the listeners of the command's type and the `ctx.run` children. A separate `bus.emit` that the handler makes is not linked: pass `{ signal: ctx.signal }` to it.