nanoeffects 0.4.0 → 0.6.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 +4 -1
- package/dist/{action-creator-D48fp13T.d.ts → action-creator-c292nK7v.d.ts} +130 -34
- package/dist/actions.d.ts +54 -5
- package/dist/actions.js +1 -1
- package/dist/{bus-Cho463Sq.js → bus-BAs9x7IQ.js} +1 -1
- package/dist/{context-DlA80GOA.js → context-CK0ZomWt.js} +1 -1
- package/dist/context-plugin-1pYmSQEG.d.ts +34 -0
- package/dist/dev.d.ts +2 -2
- package/dist/effects.d.ts +27 -129
- package/dist/effects.js +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1 -1
- package/dist/{module-DVX8aYlq.d.ts → module-BlF2KSgy.d.ts} +34 -5
- package/dist/notrace/{action-creator-D48fp13T.d.ts → action-creator-DKG_sIia.d.ts} +130 -34
- package/dist/notrace/actions.d.ts +96 -7
- package/dist/notrace/actions.js +1 -1
- package/dist/notrace/{bus-B12SvxLv.js → bus-Bdk0YyFQ.js} +1 -1
- package/dist/notrace/{context-yAWj0BFZ.js → context-CctVmHLC.js} +1 -1
- package/dist/notrace/context-plugin-Dt1IgVo8.d.ts +34 -0
- package/dist/notrace/{delay-Cgvn16oY.js → delay-B_W1tnkS.js} +1 -1
- package/dist/notrace/effects.d.ts +27 -129
- package/dist/notrace/effects.js +1 -1
- package/dist/notrace/index.d.ts +3 -2
- package/dist/notrace/index.js +1 -1
- package/dist/notrace/plugin-B0AXKusc.d.ts +207 -0
- package/dist/notrace/policies.d.ts +1 -1
- package/dist/notrace/policies.js +1 -1
- package/dist/notrace/redux.d.ts +18 -20
- package/dist/notrace/redux.js +1 -1
- package/dist/observable.d.ts +1 -1
- package/dist/plugin-CCYlC0XS.d.ts +207 -0
- package/dist/policies.d.ts +1 -1
- package/dist/policies.js +1 -1
- package/dist/react.d.ts +50 -0
- package/dist/react.js +1 -0
- package/dist/redux.d.ts +18 -20
- package/dist/redux.js +1 -1
- package/dist/scope-key-B7l7mUS5.js +1 -0
- package/dist/scope.d.ts +58 -0
- package/dist/scope.js +1 -0
- package/dist/trace.d.ts +1 -1
- package/dist/types-mLaAx-EZ.d.ts +15 -0
- package/docs/README.md +7 -1
- package/docs/actions.md +25 -1
- package/docs/context.md +26 -2
- package/docs/effects.md +83 -5
- package/docs/guide.md +24 -0
- package/docs/migrating-from-effector.md +293 -0
- package/docs/migrating-from-rxjs.md +127 -0
- package/docs/migrating-from-saga.md +93 -0
- package/docs/observable.md +1 -0
- package/docs/package.md +13 -2
- package/docs/policies.md +1 -0
- package/docs/react.md +157 -0
- package/docs/scope.md +226 -0
- package/docs/stores.md +429 -40
- package/docs/typescript.md +153 -0
- package/llms-full.txt +2925 -0
- package/llms.txt +49 -0
- package/package.json +44 -6
package/docs/effects.md
CHANGED
|
@@ -84,16 +84,94 @@ Each attempt or call of a wrapper gets its own child context. Abort of the calle
|
|
|
84
84
|
- `fn` gets the call number from `0`.
|
|
85
85
|
- An error from `fn` stops polling and rejects. An abort ends it normally and clears the timer.
|
|
86
86
|
|
|
87
|
-
A pause can be a function wherever a time is given
|
|
87
|
+
A pause can be a function wherever a time is given. For retries with a cap and jitter, see [Back off between retries](#back-off-between-retries):
|
|
88
88
|
|
|
89
89
|
```js
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
90
|
+
import { createBus } from "nanoeffects";
|
|
91
|
+
import { delay, poll, retry } from "nanoeffects/effects";
|
|
92
|
+
|
|
93
|
+
const bus = createBus();
|
|
94
|
+
|
|
95
|
+
bus.on("sync", async (_, ctx) => {
|
|
96
|
+
retry(ctx, load, { times: 5, delay: (attempt) => 2 ** attempt * 100 }); // 200, 400, 800, …
|
|
97
|
+
poll(ctx, refresh, (call) => Math.min(1000 * 2 ** call, 60_000)); // slows to once a minute
|
|
98
|
+
await delay(ctx, () => 200 + Math.random() * 200); // 200–400 ms
|
|
99
|
+
});
|
|
93
100
|
```
|
|
94
101
|
|
|
95
102
|
All three are traced: the effect is a group (`retry`, `timeout`, `poll`) and each attempt or call is a `run` child.
|
|
96
103
|
|
|
104
|
+
## Back off between retries
|
|
105
|
+
|
|
106
|
+
Use this when a server or network fails under load. Retrying at once, or at the same fixed pause, sends every client back at the same moment and can keep the server down. Make the pause grow with each attempt, and add jitter so clients spread out.
|
|
107
|
+
|
|
108
|
+
The shortest form is a `delay` function. It needs no import:
|
|
109
|
+
|
|
110
|
+
```js
|
|
111
|
+
import { retry } from "nanoeffects/effects";
|
|
112
|
+
|
|
113
|
+
const loadWithRetry = retry(loadUser, {
|
|
114
|
+
times: 5,
|
|
115
|
+
delay: (attempt) => 2 ** attempt * 100, // 200, 400, 800, 1600 ms
|
|
116
|
+
});
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
When you also want a cap and jitter, build the function with `backoff`:
|
|
120
|
+
|
|
121
|
+
```js
|
|
122
|
+
import { backoff, retry } from "nanoeffects/effects";
|
|
123
|
+
|
|
124
|
+
const loadWithRetry = retry(loadUser, {
|
|
125
|
+
times: 5,
|
|
126
|
+
// backoff — pause 200, 400, 800, … ms, never over 5 s, a random part of it
|
|
127
|
+
delay: backoff({ base: 200, max: 5000, jitter: true }),
|
|
128
|
+
});
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
| Option | Meaning |
|
|
132
|
+
| ------ | ------- |
|
|
133
|
+
| `base` | pause before the second attempt, in ms (default `100`) |
|
|
134
|
+
| `factor` | each pause is this many times longer (default `2`) |
|
|
135
|
+
| `max` | longest pause, in ms (default none) |
|
|
136
|
+
| `jitter` | `true` picks a random pause from 0 up to the computed one (default `false`) |
|
|
137
|
+
|
|
138
|
+
- With `jitter: true` a pause can be close to 0, so treat `base` as a scale, not a minimum.
|
|
139
|
+
- `retry` counts attempts from 1. `poll` counts calls from 0, so with `poll` the first pause is `base / factor`.
|
|
140
|
+
- The result is a plain function `(attempt) => ms`, so it also fits `delay(ctx, …)`.
|
|
141
|
+
|
|
142
|
+
## Put selected effects on `ctx`
|
|
143
|
+
|
|
144
|
+
If handlers call the same helpers often, put them on the task once. A handler then destructures them like `signal`, with no `ctx` argument. Only the effects you pass are imported, so the rest stay out of the bundle ([package entries](package.md)).
|
|
145
|
+
|
|
146
|
+
```js
|
|
147
|
+
import { createBus } from "nanoeffects";
|
|
148
|
+
import { all, delay, effectsPlugin, race } from "nanoeffects/effects";
|
|
149
|
+
|
|
150
|
+
// effectsPlugin — add race, all and delay to the context of every task; returns the same bus
|
|
151
|
+
const bus = createBus().use(effectsPlugin({ race, all, delay }));
|
|
152
|
+
|
|
153
|
+
bus.on("load", async (id, { race, all, delay }) => {
|
|
154
|
+
// race — same as race(ctx, …)
|
|
155
|
+
const result = await race({
|
|
156
|
+
data: ({ signal }) => fetchData(id, { signal }),
|
|
157
|
+
timeout: () => delay(5000), // delay — same as delay(ctx, 5000)
|
|
158
|
+
});
|
|
159
|
+
if ("timeout" in result) throw new Error("timed out");
|
|
160
|
+
|
|
161
|
+
// all — same as all(ctx, …)
|
|
162
|
+
const { user, posts } = await all({
|
|
163
|
+
user: ({ signal }) => fetchUser(id, { signal }),
|
|
164
|
+
posts: ({ signal }) => fetchPosts(id, { signal }),
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
- The key is the name the effect gets on `ctx`. `race`, `all`, `allSettled`, `take` and `delay` are typed by hand.
|
|
170
|
+
- Your own function works too if its first parameter is `{ signal }` (an *effect*): `effectsPlugin({ fetchJson })` gives `ctx.fetchJson(url)`. TypeScript removes the first parameter from its type; for a generic function see [keep a generic effect generic](scope.md#keep-a-generic-effect-generic).
|
|
171
|
+
- Leave `retry`, `timeout` and `poll` out: bound to the task they would look like their handler wrappers.
|
|
172
|
+
- Fields from `createBus({ context })` stay. TypeScript rejects an effect whose name is already a context field of the bus, including one added by an earlier `effectsPlugin`.
|
|
173
|
+
- Free forms keep working: `race(ctx, …)`.
|
|
174
|
+
|
|
97
175
|
## Tie a promise to the task signal
|
|
98
176
|
|
|
99
177
|
`delay(ctx, ms)` waits and rejects on abort (`ms` can be a function).
|
|
@@ -179,7 +257,7 @@ bus.on("login", async (user, ctx) => {
|
|
|
179
257
|
```
|
|
180
258
|
|
|
181
259
|
- Sees only events emitted after the call, takes one, and unsubscribes.
|
|
182
|
-
- `options.predicate` skips payloads it rejects.
|
|
260
|
+
- `options.predicate` skips payloads it rejects. In TypeScript, a type guard (`(payload): payload is Admin`) narrows the type of the result.
|
|
183
261
|
- `options.signal` stops the wait (how a `race` or `timeout` starter cancels it).
|
|
184
262
|
- On abort of the task or of `options.signal`, unsubscribes and rejects with the abort reason.
|
|
185
263
|
|
package/docs/guide.md
CHANGED
|
@@ -47,6 +47,28 @@ bus.on("tick", () => {});
|
|
|
47
47
|
bus.off(); // clear every subscription
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
+
### Listen to every event
|
|
51
|
+
|
|
52
|
+
`bus.on("*", fn)` runs for every event of the bus. It is for logging, debugging, or forwarding events elsewhere. The handler gets the payload and the task context as usual, and `ctx.type` is the name of the event.
|
|
53
|
+
|
|
54
|
+
```js
|
|
55
|
+
import { createBus } from "nanoeffects";
|
|
56
|
+
|
|
57
|
+
const bus = createBus();
|
|
58
|
+
|
|
59
|
+
// "*" — runs for every event, after the listeners of that event
|
|
60
|
+
const unbind = bus.on("*", (payload, ctx) => {
|
|
61
|
+
console.log(ctx.type, payload);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
bus.emit("greet", "Ada"); // greet Ada
|
|
65
|
+
bus.emit("moved", { x: 1, y: 2 }); // moved { x: 1, y: 2 }
|
|
66
|
+
unbind();
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
- An event that only `"*"` listens to counts as heard, so `onUnhandled` does not fire for it.
|
|
70
|
+
- `bus.off("*")` removes every `"*"` listener; `bus.off()` clears the whole bus, `"*"` included.
|
|
71
|
+
|
|
50
72
|
## 2. Use the handler context
|
|
51
73
|
|
|
52
74
|
The second argument is the **task** context for this call. Emit further events through it. Pass `ctx.signal` to `fetch` when you cancel work.
|
|
@@ -294,6 +316,8 @@ Modules also make [hot reload](hmr.md) work. Full API in [Actions](actions.md);
|
|
|
294
316
|
Describe events once when you want `on` / `emit` checked. An event without data is `void`. Skip this while you are learning the bus.
|
|
295
317
|
|
|
296
318
|
```ts
|
|
319
|
+
import { createBus } from "nanoeffects";
|
|
320
|
+
|
|
297
321
|
// Events — event name → payload for this bus (checker only)
|
|
298
322
|
type Events = {
|
|
299
323
|
greet: string;
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# Coming from Effector
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
## Split the work
|
|
6
|
+
|
|
7
|
+
| Job | Use |
|
|
8
|
+
| --- | --- |
|
|
9
|
+
| Hold state (`createStore`, `combine`, `restore`) | Effector, or any store: [Stores](stores.md) |
|
|
10
|
+
| Derive state from events (`.on`, `sample` into a store) | Effector, or any store |
|
|
11
|
+
| Run a request when an event happens, cancel, retry, time out, trace | nanoeffects |
|
|
12
|
+
|
|
13
|
+
nanoeffects has no store. `createStore` can be replaced by any store you like, for example Zustand: a store and its `set` do the job of `createStore` and `.on`. [Stores](stores.md) shows how each of Zustand, Redux, MobX, Jotai and others calls the bus. If you keep Effector, use nanoeffects only for the effects that `createEffect` runs with little control over overlapping calls.
|
|
14
|
+
|
|
15
|
+
## Find the equivalent
|
|
16
|
+
|
|
17
|
+
| Effector | nanoeffects |
|
|
18
|
+
| -------- | ----------- |
|
|
19
|
+
| `createEvent()` | an event name on the bus, or [`createAction`](actions.md) |
|
|
20
|
+
| event call `loaded(user)` | `bus.emit("loaded", user)` |
|
|
21
|
+
| `createEffect(handler)` | a handler: `bus.on(event, handler)` |
|
|
22
|
+
| effect with `done` and `fail` | [`createJob`](actions.md): emits the success and fail actions |
|
|
23
|
+
| `effect.pending` | not provided: keep a flag in your store, or read it from [Tracing](tracing.md) |
|
|
24
|
+
| `sample({ clock, fn, target })` | `bus.on(clock, handler)` and `ctx.emit(target, …)` |
|
|
25
|
+
| `sample({ source: $store, clock })` | read the store from a [context field](context.md) in the handler |
|
|
26
|
+
| `sample({ filter })` | an `if` in the handler |
|
|
27
|
+
| `debounce`, `throttle` from patronum | [`debounce`, `throttle`](policies.md) (same rules as lodash, checked in tests) |
|
|
28
|
+
| take only the last effect call | [`latest`](policies.md) |
|
|
29
|
+
| `attach` | a [context field](context.md): the handler reads it from `ctx` |
|
|
30
|
+
| `fork()` and scope | one `createBus()` per scope |
|
|
31
|
+
| `createStore`, `combine`, `restore` | any store, such as Zustand, Redux, MobX or Jotai: see [Stores](stores.md) |
|
|
32
|
+
| `watch`, `createWatch` | `bus.on(event, handler)` |
|
|
33
|
+
| `event.prepend(fn)` | a function that you write, `(event) => ({ key, value })`; the bus needs nothing |
|
|
34
|
+
| `useStoreMap({ store, keys, fn })` | a selector of your store; with `useState` the component keeps the value |
|
|
35
|
+
| `event.watch((e) => e.preventDefault())` | call `event.preventDefault()` in the `onSubmit` handler |
|
|
36
|
+
|
|
37
|
+
## Rewrite an effect
|
|
38
|
+
|
|
39
|
+
A search that runs on every query, and reacts to the result:
|
|
40
|
+
|
|
41
|
+
<!-- docs-check: skip - needs effector, which the project does not depend on -->
|
|
42
|
+
```js
|
|
43
|
+
// Effector
|
|
44
|
+
import { createEffect, createEvent, createStore, sample } from "effector";
|
|
45
|
+
|
|
46
|
+
const queryChanged = createEvent();
|
|
47
|
+
const searchFx = createEffect((query) => api.search(query));
|
|
48
|
+
const $items = createStore([]).on(searchFx.doneData, (_, items) => items);
|
|
49
|
+
|
|
50
|
+
sample({ clock: queryChanged, target: searchFx });
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
If the user types fast, several `searchFx` calls overlap, and the old ones are not cancelled. The same job in nanoeffects, with the old request cancelled:
|
|
54
|
+
|
|
55
|
+
```js
|
|
56
|
+
import { createBus } from "nanoeffects";
|
|
57
|
+
import { debounce, latest } from "nanoeffects/policies";
|
|
58
|
+
|
|
59
|
+
const bus = createBus(); // the bus: events and handlers
|
|
60
|
+
|
|
61
|
+
// latest: a newer query aborts the request in flight
|
|
62
|
+
const search = latest(async (query, { signal }) => {
|
|
63
|
+
const items = await api.search(query, { signal });
|
|
64
|
+
bus.emit("found", items);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
bus.on("query-changed", debounce(300, search)); // debounce: run after 300 ms of quiet
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The store (`$items`) is not here. Keep it in Effector and update it from the `found` event, or replace it with a store of your choice. The next example does that with Zustand.
|
|
71
|
+
|
|
72
|
+
### Keep the state in Zustand
|
|
73
|
+
|
|
74
|
+
Without Effector, something must hold `$items`. A Zustand store does it: `create` replaces `createStore`, and `setState` replaces `.on(searchFx.doneData, …)`. The handler writes the result to the store directly, so the `found` event is not needed. A component reads the store with a selector, as `useUnit($items)` did.
|
|
75
|
+
|
|
76
|
+
```jsx
|
|
77
|
+
// search-zustand.jsx
|
|
78
|
+
import { create } from "zustand";
|
|
79
|
+
import { createBus } from "nanoeffects";
|
|
80
|
+
import { debounce, latest } from "nanoeffects/policies";
|
|
81
|
+
|
|
82
|
+
// create — Zustand: the store replaces $items
|
|
83
|
+
const useSearchStore = create(() => ({ items: [] }));
|
|
84
|
+
|
|
85
|
+
const bus = createBus(); // the bus: events and handlers
|
|
86
|
+
|
|
87
|
+
// latest — a newer query aborts the request in flight; debounce — wait 300 ms of quiet
|
|
88
|
+
const search = latest(async (query, { signal }) => {
|
|
89
|
+
const items = await api.search(query, { signal });
|
|
90
|
+
useSearchStore.setState({ items }); // Zustand: replaces .on(searchFx.doneData, …)
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
bus.on("query-changed", debounce(300, search));
|
|
94
|
+
|
|
95
|
+
export function Search() {
|
|
96
|
+
const items = useSearchStore((state) => state.items); // Zustand: a selector reads items
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div>
|
|
100
|
+
<input onChange={(event) => bus.emit("query-changed", event.target.value)} />
|
|
101
|
+
<ul>
|
|
102
|
+
{items.map((item) => (
|
|
103
|
+
<li key={item.id}>{item.title}</li>
|
|
104
|
+
))}
|
|
105
|
+
</ul>
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`bus.emit("query-changed", …)` in `onChange` does the work of calling the `queryChanged` event. To also start a task when the store changes, see [Zustand in Stores](stores.md#zustand).
|
|
112
|
+
|
|
113
|
+
## Rewrite a form
|
|
114
|
+
|
|
115
|
+
The form from the [Forms recipe of the Effector docs](https://effector.dev/en/recipes/react/forms/) has five parts: an event for a change of a field (`fieldUpdate`), a store for the values (`$form`), an event for the submit (`formSubmitted`), an effect that sends them (`sendFormFx`), and a `sample` that reads the store when the form is submitted. Four of them keep state or wire units together. Only the effect has a counterpart in nanoeffects.
|
|
116
|
+
|
|
117
|
+
<!-- docs-check: skip - needs effector, which the project does not depend on -->
|
|
118
|
+
```jsx
|
|
119
|
+
// Effector (adapted from the Effector docs)
|
|
120
|
+
import { createEffect, createEvent, createStore, sample } from "effector";
|
|
121
|
+
import { useStoreMap } from "effector-react";
|
|
122
|
+
|
|
123
|
+
const formSubmitted = createEvent();
|
|
124
|
+
const fieldUpdate = createEvent();
|
|
125
|
+
|
|
126
|
+
const sendFormFx = createEffect((params) => {
|
|
127
|
+
console.log(params);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const $form = createStore({});
|
|
131
|
+
$form.on(fieldUpdate, (form, { key, value }) => ({ ...form, [key]: value }));
|
|
132
|
+
|
|
133
|
+
// sample: when the form is submitted, read $form and run the effect with it
|
|
134
|
+
sample({ clock: formSubmitted, source: $form, target: sendFormFx });
|
|
135
|
+
|
|
136
|
+
// prepend: turn a DOM event into the { key, value } that fieldUpdate takes
|
|
137
|
+
const handleChange = fieldUpdate.prepend((event) => ({
|
|
138
|
+
key: event.target.name,
|
|
139
|
+
value: event.target.value,
|
|
140
|
+
}));
|
|
141
|
+
|
|
142
|
+
const Field = ({ name, type, label }) => {
|
|
143
|
+
const value = useStoreMap({
|
|
144
|
+
store: $form,
|
|
145
|
+
keys: [name],
|
|
146
|
+
fn: (values) => values[name] ?? "",
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<div>
|
|
151
|
+
{label} <input name={name} type={type} value={value} onChange={handleChange} />
|
|
152
|
+
</div>
|
|
153
|
+
);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const App = () => (
|
|
157
|
+
<form onSubmit={formSubmitted}>
|
|
158
|
+
<Field name="login" label="Login" />
|
|
159
|
+
<Field name="password" type="password" label="Password" />
|
|
160
|
+
<button type="submit">Submit!</button>
|
|
161
|
+
</form>
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
formSubmitted.watch((event) => {
|
|
165
|
+
event.preventDefault();
|
|
166
|
+
});
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
The same form with nanoeffects. The values live in React state: nanoeffects holds none, and any store of [Stores](stores.md) works the same way. The effect becomes a handler of the submit event. The values travel in the event, so no `sample` and no `source` are needed. First the bus, in its own file:
|
|
170
|
+
|
|
171
|
+
```js
|
|
172
|
+
// form-bus.js
|
|
173
|
+
import { createBus } from "nanoeffects";
|
|
174
|
+
|
|
175
|
+
export const bus = createBus(); // the bus: events and their handlers
|
|
176
|
+
|
|
177
|
+
// bus.on — the job of sendFormFx: runs when the form is submitted
|
|
178
|
+
bus.on("form/submitted", (values) => {
|
|
179
|
+
console.log(values);
|
|
180
|
+
});
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Then the form. `useState` does the work of `$form`, `fieldUpdate` and `prepend`; the event handler does the work of the `watch` that called `preventDefault`:
|
|
184
|
+
|
|
185
|
+
```jsx
|
|
186
|
+
// App.jsx
|
|
187
|
+
import { useState } from "react";
|
|
188
|
+
import { bus } from "./form-bus"; // the bus of the previous snippet
|
|
189
|
+
|
|
190
|
+
function Field({ name, type, label, value, onChange }) {
|
|
191
|
+
return (
|
|
192
|
+
<div>
|
|
193
|
+
{label} <input name={name} type={type} value={value} onChange={onChange} />
|
|
194
|
+
</div>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function App() {
|
|
199
|
+
const [form, setForm] = useState({}); // the values of the form, as $form held them
|
|
200
|
+
|
|
201
|
+
// one handler for every field, as handleChange was
|
|
202
|
+
function handleChange(event) {
|
|
203
|
+
const { name, value } = event.target;
|
|
204
|
+
setForm((current) => ({ ...current, [name]: value }));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function handleSubmit(event) {
|
|
208
|
+
event.preventDefault(); // the page must not reload; no separate watcher is needed
|
|
209
|
+
bus.emit("form/submitted", form); // bus.emit — the values go in the event
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return (
|
|
213
|
+
<form onSubmit={handleSubmit}>
|
|
214
|
+
<Field name="login" label="Login" value={form.login ?? ""} onChange={handleChange} />
|
|
215
|
+
<Field
|
|
216
|
+
name="password"
|
|
217
|
+
type="password"
|
|
218
|
+
label="Password"
|
|
219
|
+
value={form.password ?? ""}
|
|
220
|
+
onChange={handleChange}
|
|
221
|
+
/>
|
|
222
|
+
<button type="submit">Submit!</button>
|
|
223
|
+
</form>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
`useStoreMap` re-renders only the field whose value changed. Here every field re-renders with the form, which is fine for a small form. If it matters, use the selector of your store (Zustand and Jotai have one).
|
|
229
|
+
|
|
230
|
+
If the values live in a store outside React, give the store to the handlers with `createBus({ context: { store } })` ([Context fields](context.md)) and read it in the handler, as `sample` read `$form`.
|
|
231
|
+
|
|
232
|
+
### Replace the log with a request
|
|
233
|
+
|
|
234
|
+
`sendFormFx` only logs. A real sign-up sends a request, and then the double click and the network matter. `leading` drops a second submit while the first one runs, and `ctx.signal` lets the request be cancelled:
|
|
235
|
+
|
|
236
|
+
```js
|
|
237
|
+
import { createBus } from "nanoeffects";
|
|
238
|
+
import { leading } from "nanoeffects/policies";
|
|
239
|
+
|
|
240
|
+
const bus = createBus(); // the bus: events and their handlers
|
|
241
|
+
|
|
242
|
+
// leading — a second submit is ignored while the request is running
|
|
243
|
+
bus.on(
|
|
244
|
+
"form/submitted",
|
|
245
|
+
leading(async (values, ctx) => {
|
|
246
|
+
// api — your client; ctx.signal lets the request be cancelled
|
|
247
|
+
await api.signUp(values, { signal: ctx.signal });
|
|
248
|
+
}),
|
|
249
|
+
);
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
Retrying a failed network request, and cancelling when the form closes, are in [Build a sign-up form in React](react.md).
|
|
253
|
+
|
|
254
|
+
## Use both in one app
|
|
255
|
+
|
|
256
|
+
Keep the Effector stores, and run the effects on the bus. A store calls the bus through `bus.emitter`, and the handler sends the result back as an event that the store listens to.
|
|
257
|
+
|
|
258
|
+
<!-- docs-check: skip - needs effector, which the project does not depend on -->
|
|
259
|
+
```js
|
|
260
|
+
import { createEvent, createStore } from "effector";
|
|
261
|
+
import { createBus } from "nanoeffects";
|
|
262
|
+
import { createAction, emitterPlugin } from "nanoeffects/actions";
|
|
263
|
+
import { latest } from "nanoeffects/policies";
|
|
264
|
+
|
|
265
|
+
const bus = createBus().use(emitterPlugin()); // adds bus.emitter
|
|
266
|
+
|
|
267
|
+
const queryChanged = createAction("query/changed"); // a plain action creator
|
|
268
|
+
|
|
269
|
+
const found = createEvent(); // Effector: the result comes back as an event
|
|
270
|
+
const $items = createStore([]).on(found, (_, items) => items);
|
|
271
|
+
const $query = createStore("");
|
|
272
|
+
|
|
273
|
+
// watch calls back on every change of the store; bus.emitter turns it into an emit
|
|
274
|
+
$query.watch(bus.emitter(queryChanged));
|
|
275
|
+
|
|
276
|
+
bus.on(
|
|
277
|
+
queryChanged,
|
|
278
|
+
latest(async (query, { signal }) => {
|
|
279
|
+
const items = await api.search(query, { signal });
|
|
280
|
+
found(items); // back to Effector
|
|
281
|
+
}),
|
|
282
|
+
);
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
## What is different
|
|
286
|
+
|
|
287
|
+
- **No state.** There is no `createStore`, `combine`, `restore`, `$store.map`. The bus holds listeners only. Take a store such as Zustand for state ([Stores](stores.md)).
|
|
288
|
+
- **No `pending`.** A running task is visible through [Tracing](tracing.md), not through a store. A flag in your store set at the start and the end of the handler does the job.
|
|
289
|
+
- **Cancellation is the default tool.** An Effector effect runs every call to the end. Here `latest` and `ctx.signal` stop what is no longer needed.
|
|
290
|
+
- **Events are matched by name, not by identity.** An event is a string or a `createAction` creator, not a unit that you import and call. Two modules agree on the name.
|
|
291
|
+
- **No graph.** `sample` and `forward` build a static graph. A bus subscribes handlers at runtime and the call order is the order of `on`.
|
|
292
|
+
- **The event carries the data.** `sample({ source })` reads a store at the moment of the clock. Here the sender puts the values in the event (`bus.emit("form/submitted", form)`), or the handler reads a store from a [context field](context.md).
|
|
293
|
+
- **Scopes are buses.** `fork()` becomes a `createBus()` per scope (a test, a request on the server).
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# Use nanoeffects next to RxJS
|
|
2
|
+
|
|
3
|
+
Read this page if your project uses RxJS and you wonder what nanoeffects takes over. It does not replace RxJS. The two split the work: RxJS shapes streams of values, nanoeffects runs the side effects that events start. It maps the operators, shows one search box both ways, and lists what stays in RxJS.
|
|
4
|
+
|
|
5
|
+
## Split the work
|
|
6
|
+
|
|
7
|
+
| Job | Use |
|
|
8
|
+
| --- | --- |
|
|
9
|
+
| Start a request when something happens, cancel it when a newer one arrives, retry it, time it out | nanoeffects |
|
|
10
|
+
| Know which task started which, log and trace it | nanoeffects ([Tracing](tracing.md)) |
|
|
11
|
+
| Combine several streams, keep a running value, buffer, window, merge over time | RxJS |
|
|
12
|
+
| Work with a stream as a value (`map`, `scan`, `combineLatest`) | RxJS |
|
|
13
|
+
|
|
14
|
+
A handler in nanoeffects is one function per event. A pipeline in RxJS is a chain of operators over a stream. If your pipeline is a few operators that end in `switchMap` and a request, it fits a handler with a policy and is shorter. If it combines streams, it stays.
|
|
15
|
+
|
|
16
|
+
## Find the equivalent
|
|
17
|
+
|
|
18
|
+
| RxJS | nanoeffects |
|
|
19
|
+
| ---- | ----------- |
|
|
20
|
+
| `mergeMap` | `bus.on(event, handler)`: every event starts a task |
|
|
21
|
+
| `switchMap` | [`latest`](policies.md) |
|
|
22
|
+
| `exhaustMap` | [`leading`](policies.md) |
|
|
23
|
+
| `concatMap` | [`queue`](policies.md) |
|
|
24
|
+
| `debounceTime` | [`debounce`](policies.md) |
|
|
25
|
+
| `throttleTime` | [`throttle`](policies.md) |
|
|
26
|
+
| `groupBy` + `switchMap` | `latest(handler, { key })` |
|
|
27
|
+
| `retry`, `retryWhen` | [`retry`, `backoff`](effects.md) |
|
|
28
|
+
| `timeout` | [`timeout`](effects.md) |
|
|
29
|
+
| `interval` + `switchMap` | [`poll`](effects.md) |
|
|
30
|
+
| `takeUntil(stop$)` | `ctx.signal`, and [`take`](effects.md) to wait for the stop event |
|
|
31
|
+
| `forkJoin` | [`all`](effects.md) |
|
|
32
|
+
| `race` | [`race`](effects.md) |
|
|
33
|
+
| `firstValueFrom(fromEvent(…))` | [`take(ctx, event)`](effects.md) |
|
|
34
|
+
| `Subject` | `bus.emit` and `bus.on` |
|
|
35
|
+
| `map`, `filter`, `scan`, `combineLatest`, `buffer`, `window`, `share` | stay in RxJS |
|
|
36
|
+
| `distinctUntilChanged` | a check in the handler, or in the store subscription (see [Stores](stores.md)) |
|
|
37
|
+
|
|
38
|
+
A policy and an operator differ in one thing. `switchMap` drops the old inner stream. `latest` aborts the old task through its `signal`, so a `fetch` that got the signal stops. Pass the signal on, or the work goes on in the background.
|
|
39
|
+
|
|
40
|
+
## Rewrite a search box
|
|
41
|
+
|
|
42
|
+
The same job in RxJS: wait for a pause, skip repeats, cancel the old request.
|
|
43
|
+
|
|
44
|
+
<!-- docs-check: skip - needs rxjs, which the project does not depend on -->
|
|
45
|
+
```js
|
|
46
|
+
// RxJS
|
|
47
|
+
import { fromEvent } from "rxjs";
|
|
48
|
+
import { debounceTime, distinctUntilChanged, map, switchMap } from "rxjs/operators";
|
|
49
|
+
|
|
50
|
+
fromEvent(input, "input")
|
|
51
|
+
.pipe(
|
|
52
|
+
map((event) => event.target.value),
|
|
53
|
+
debounceTime(300),
|
|
54
|
+
distinctUntilChanged(),
|
|
55
|
+
switchMap((query) => api.searchStream(query)),
|
|
56
|
+
)
|
|
57
|
+
.subscribe((items) => render(items));
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The same job in nanoeffects:
|
|
61
|
+
|
|
62
|
+
```js
|
|
63
|
+
import { createBus } from "nanoeffects";
|
|
64
|
+
import { debounce, latest } from "nanoeffects/policies";
|
|
65
|
+
|
|
66
|
+
const bus = createBus(); // the bus: events and handlers
|
|
67
|
+
|
|
68
|
+
// latest: a newer search aborts the request in flight
|
|
69
|
+
const searchLatest = latest(async (query, { signal }) => {
|
|
70
|
+
const items = await api.search(query, { signal });
|
|
71
|
+
bus.emit("results", items);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// debounce: run after 300 ms without a new "typed"
|
|
75
|
+
bus.on("typed", debounce(300, searchLatest));
|
|
76
|
+
|
|
77
|
+
bus.on("results", (items) => render(items));
|
|
78
|
+
|
|
79
|
+
const input = document.querySelector("#search"); // the search box
|
|
80
|
+
input.addEventListener("input", (event) => bus.emit("typed", event.target.value));
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The skip of repeated values is not here. Add `if (query === lastQuery) return;` to the handler, or keep that one step in RxJS (next section).
|
|
84
|
+
|
|
85
|
+
## Use both in one app
|
|
86
|
+
|
|
87
|
+
Keep RxJS where a stream is how you think, and let events cross to the bus in either direction.
|
|
88
|
+
|
|
89
|
+
**Bus to RxJS.** `createObserve(bus)` gives an Observable of an event ([Observables](observable.md)).
|
|
90
|
+
|
|
91
|
+
**RxJS to bus.** `bus.emitter(action)` returns a callback that emits the action, so it is the argument of `subscribe` ([Stores](stores.md) has the plugin).
|
|
92
|
+
|
|
93
|
+
<!-- docs-check: skip - needs rxjs, which the project does not depend on -->
|
|
94
|
+
```js
|
|
95
|
+
import { combineLatest } from "rxjs";
|
|
96
|
+
import { createBus } from "nanoeffects";
|
|
97
|
+
import { createAction, emitterPlugin } from "nanoeffects/actions";
|
|
98
|
+
import { latest } from "nanoeffects/policies";
|
|
99
|
+
|
|
100
|
+
const bus = createBus().use(emitterPlugin()); // adds bus.emitter
|
|
101
|
+
|
|
102
|
+
const filtersChanged = createAction("filters/changed"); // a plain action creator
|
|
103
|
+
|
|
104
|
+
// RxJS: combine two streams into one value; nanoeffects does not do that
|
|
105
|
+
const subscription = combineLatest([query$, category$]).subscribe(
|
|
106
|
+
bus.emitter(filtersChanged), // calls bus.emit(filtersChanged(value))
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// nanoeffects: one cancellable request per change of the filters
|
|
110
|
+
bus.on(
|
|
111
|
+
filtersChanged,
|
|
112
|
+
latest(async ([query, category], { signal }) => {
|
|
113
|
+
const items = await api.search(query, category, { signal });
|
|
114
|
+
bus.emit("results", items);
|
|
115
|
+
}),
|
|
116
|
+
);
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The subscription is not tied to a task. Call `subscription.unsubscribe()` when the screen goes away.
|
|
120
|
+
|
|
121
|
+
## What is different
|
|
122
|
+
|
|
123
|
+
- **No streams of values.** A handler gets one payload and runs a task. There is no `pipe`, no operator for the values that follow.
|
|
124
|
+
- **Abort replaces unsubscribe for work in flight.** A stopped task is stopped through its `signal`. Pass it to `fetch` or to the client.
|
|
125
|
+
- **Errors are not part of the stream.** A failed task rejects its own promise. Without `onError` on the bus nobody else hears it ([Errors](errors.md)).
|
|
126
|
+
- **No completion.** An event bus does not end. A task ends when its work ends or its signal aborts.
|
|
127
|
+
- **No marble tests.** Test with the real handler and fake timers.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Coming from redux-saga
|
|
2
|
+
|
|
3
|
+
Read this page if you know redux-saga and want to find the same job in nanoeffects. It maps the names, shows one saga rewritten, and lists what has no equivalent. It does not teach the library: start with the [Guide](guide.md).
|
|
4
|
+
|
|
5
|
+
## Find the equivalent
|
|
6
|
+
|
|
7
|
+
| redux-saga | nanoeffects |
|
|
8
|
+
| ---------- | ----------- |
|
|
9
|
+
| `takeEvery` | `bus.on(event, handler)` |
|
|
10
|
+
| `takeLatest` | [`latest`](policies.md) |
|
|
11
|
+
| `takeLeading` | [`leading`](policies.md) |
|
|
12
|
+
| `debounce`, `throttle` | [`debounce`, `throttle`](policies.md) (same rules as lodash) |
|
|
13
|
+
| `call(fn, x)` | `await ctx.run(task, x)` |
|
|
14
|
+
| `fork(fn, x)` | `ctx.run(task, x)` without `await` |
|
|
15
|
+
| `spawn(fn, x)` | `ctx.emit(event, x)` or `bus.emit(event, x)` |
|
|
16
|
+
| `put(action)` | `ctx.emit(action)` |
|
|
17
|
+
| `take(pattern)` | [`take(ctx, event, { predicate })`](effects.md) |
|
|
18
|
+
| `select` | a [context field](context.md), such as `store` |
|
|
19
|
+
| `all`, `race` | [`all`, `race`](effects.md) |
|
|
20
|
+
| `retry`, `delay` | [`retry`, `delay`](effects.md) |
|
|
21
|
+
| `cancel(task)` | `ctx.abort()` on the context, or a newer call under `latest` |
|
|
22
|
+
| `eventChannel` | `bus.emitter(action)` as the callback of the subscription |
|
|
23
|
+
| `actionChannel` | [`queue`](policies.md) and the other policies |
|
|
24
|
+
|
|
25
|
+
## Rewrite a saga
|
|
26
|
+
|
|
27
|
+
A saga that searches on every new query, cancels the older request, and retries a failed one:
|
|
28
|
+
|
|
29
|
+
<!-- docs-check: skip - redux-saga code, not nanoeffects -->
|
|
30
|
+
```js
|
|
31
|
+
// redux-saga
|
|
32
|
+
function* search({ payload }) {
|
|
33
|
+
const items = yield retry(3, 1000, api.search, payload);
|
|
34
|
+
yield put(found(items));
|
|
35
|
+
}
|
|
36
|
+
function* watchSearch() {
|
|
37
|
+
yield takeLatest("search", search);
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The same job in nanoeffects:
|
|
42
|
+
|
|
43
|
+
```js
|
|
44
|
+
import { createBus } from "nanoeffects";
|
|
45
|
+
import { retry } from "nanoeffects/effects";
|
|
46
|
+
import { latest } from "nanoeffects/policies";
|
|
47
|
+
|
|
48
|
+
const bus = createBus(); // the bus: events and handlers
|
|
49
|
+
|
|
50
|
+
const search = (query, { signal }) => api.search(query, { signal });
|
|
51
|
+
|
|
52
|
+
bus.on(
|
|
53
|
+
"search",
|
|
54
|
+
latest(async (query, ctx) => {
|
|
55
|
+
// latest: a newer "search" aborts this run; ctx.signal is aborted
|
|
56
|
+
const load = retry(search, { times: 3, delay: 1000 }); // retry: up to 3 attempts, 1 s apart
|
|
57
|
+
const items = await ctx.run(load, query); // child task, aborted with this handler
|
|
58
|
+
ctx.emit("found", items); // like put
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Tasks are plain `async` functions. `ctx.signal` replaces `cancel`: pass it to `fetch` or to your client, and the request stops.
|
|
64
|
+
|
|
65
|
+
## Start a task without waiting
|
|
66
|
+
|
|
67
|
+
`fork` and `spawn` differ in what happens when the caller is aborted. Pick by that.
|
|
68
|
+
|
|
69
|
+
```js
|
|
70
|
+
import { createBus } from "nanoeffects";
|
|
71
|
+
|
|
72
|
+
const bus = createBus(); // the bus: events and handlers
|
|
73
|
+
|
|
74
|
+
const loadSidebar = (_, { signal }) => api.sidebar({ signal });
|
|
75
|
+
|
|
76
|
+
bus.on("page-opened", async (_, ctx) => {
|
|
77
|
+
// like fork: a child of this task, aborted with it
|
|
78
|
+
const warmUp = ctx.run(loadSidebar);
|
|
79
|
+
warmUp.catch(console.error); // nobody awaits it, so handle the error here
|
|
80
|
+
|
|
81
|
+
// like spawn: a new task, not stopped when this one is aborted
|
|
82
|
+
ctx.emit("analytics", { page: "home" });
|
|
83
|
+
});
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## What is different
|
|
87
|
+
|
|
88
|
+
- **Errors go up only through `await`.** `await ctx.run(task)` and `all(ctx, …)` throw the error of a child to the caller. A child that nobody awaits does not abort its parent, as a forked child does in redux-saga: handle it yourself, as above. See [Errors](errors.md).
|
|
89
|
+
- **Abort goes down.** Aborting a task aborts its children. A child does not abort its siblings.
|
|
90
|
+
- **Effects are not data.** `yield call(fn)` returns a description that a test can compare. Here `await` calls the function. Test with the real handler and fake timers, as the tests of this library do.
|
|
91
|
+
- **No generators.** Sequential flows are `async` functions with `await take(ctx, event)`.
|
|
92
|
+
- **No `END` of a channel.** A task stops when its signal aborts.
|
|
93
|
+
- **One payload.** A task has one argument, so pass an object where a saga passes several.
|
package/docs/observable.md
CHANGED
|
@@ -4,6 +4,7 @@ Use this page only when you already work in Observables / RxJS. Prefer `bus.on`
|
|
|
4
4
|
|
|
5
5
|
`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.
|
|
6
6
|
|
|
7
|
+
<!-- docs-check: skip - needs rxjs, which the project does not depend on -->
|
|
7
8
|
```js
|
|
8
9
|
import { from } from "rxjs";
|
|
9
10
|
import { debounceTime, distinctUntilChanged, switchMap } from "rxjs/operators";
|