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.
- package/README.md +2 -2
- package/dist/{action-creator-Bd4TBZgl.d.ts → action-creator-BrZF6hXc.d.ts} +0 -4
- package/dist/actions.d.ts +41 -25
- package/dist/actions.js +1 -1
- package/dist/bus-CkTHUAvq.js +1 -0
- package/dist/context-COgyNlrC.js +1 -0
- package/dist/{context-plugin-BVNKqkIx.d.ts → context-plugin--HkNN1ha.d.ts} +1 -1
- package/dist/dev.d.ts +9 -8
- package/dist/dev.js +1 -1
- package/dist/effects.d.ts +2 -2
- package/dist/effects.js +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/match.d.ts +50 -0
- package/dist/match.js +1 -0
- package/dist/{module-DZ5ngUrM.d.ts → module-DqKegXHt.d.ts} +43 -18
- package/dist/notrace/{action-creator-D-y_-xRS.d.ts → action-creator-DHAEgwuh.d.ts} +0 -4
- package/dist/notrace/actions.d.ts +90 -39
- package/dist/notrace/actions.js +1 -1
- package/dist/notrace/bus-BmCTo8BG.js +1 -0
- package/dist/notrace/{context-plugin-DDfsaErA.d.ts → context-plugin-BapQkGwQ.d.ts} +1 -1
- package/dist/notrace/context-yYU3_9lq.js +1 -0
- package/dist/notrace/{delay-B_W1tnkS.js → delay-BosJKFjg.js} +1 -1
- package/dist/notrace/effects.d.ts +2 -2
- package/dist/notrace/effects.js +1 -1
- package/dist/notrace/index.d.ts +2 -2
- package/dist/notrace/index.js +1 -1
- package/dist/notrace/{plugin-CDAngHwg.d.ts → plugin-Cxadz6uU.d.ts} +2 -2
- package/dist/notrace/policies.d.ts +30 -2
- package/dist/notrace/policies.js +1 -1
- package/dist/notrace/redux.d.ts +1 -1
- package/dist/observable.d.ts +1 -1
- package/dist/oop.d.ts +131 -0
- package/dist/oop.js +1 -0
- package/dist/{plugin-DQNPBcxx.d.ts → plugin-DkXCvsmB.d.ts} +2 -2
- package/dist/policies.d.ts +3 -2
- package/dist/policies.js +1 -1
- package/dist/react.d.ts +33 -1
- package/dist/react.js +1 -1
- package/dist/redux.d.ts +1 -1
- package/dist/scope.d.ts +5 -6
- package/dist/scope.js +1 -1
- package/dist/signal.d.ts +51 -0
- package/dist/signal.js +1 -0
- package/dist/trace.d.ts +1 -1
- package/dist/{types-BJ0fzV1p.d.ts → types-KavbvHix.d.ts} +13 -3
- package/dist/with-policy-CqUS-VWW.d.ts +30 -0
- package/dist/with-policy-DBV9dh2F.js +1 -0
- package/docs/README.md +7 -0
- package/docs/actions.md +152 -39
- package/docs/cancel-stale-requests.md +111 -0
- package/docs/debounce-and-abort.md +106 -0
- package/docs/guide.md +143 -19
- package/docs/hmr.md +6 -5
- package/docs/migrating-from-effector.md +51 -18
- package/docs/migrating-from-mitt.md +97 -0
- package/docs/migrating-from-rxjs.md +17 -6
- package/docs/motivation.md +41 -0
- package/docs/observable.md +124 -10
- package/docs/oop.md +286 -0
- package/docs/package.md +6 -2
- package/docs/plugins.md +247 -51
- package/docs/policies.md +33 -0
- package/docs/prevent-double-submit.md +128 -0
- package/docs/query-libraries.md +124 -0
- package/docs/react.md +61 -5
- package/docs/scope.md +60 -22
- package/docs/stores.md +263 -109
- package/llms-full.txt +2153 -355
- package/llms.txt +10 -3
- package/package.json +43 -4
- package/skills/nanoeffects/SKILL.md +52 -0
- package/dist/bus-BAs9x7IQ.js +0 -1
- package/dist/context-CK0ZomWt.js +0 -1
- package/dist/notrace/bus-Bdk0YyFQ.js +0 -1
- package/dist/notrace/context-CctVmHLC.js +0 -1
package/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
|
|
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
|
-
//
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
88
|
+
## Extra plugins
|
|
89
|
+
|
|
90
|
+
Add one of these when you meet its need. None of them is required.
|
|
31
91
|
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
245
|
+
export const disconnect = bus.use(devPlugin());
|
|
76
246
|
```
|
|
77
247
|
|
|
78
|
-
##
|
|
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
|
-
|
|
252
|
+
Two things happen because modules are mounted late:
|
|
81
253
|
|
|
82
|
-
|
|
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
|
-
|
|
257
|
+
Other plugins do not need an order for another reason:
|
|
91
258
|
|
|
92
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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.
|