nanoeffects 0.3.0 → 0.5.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 +53 -55
- package/dist/{action-Bdfp-Wvd.d.ts → action-creator-DOWcbXpy.d.ts} +79 -73
- package/dist/actions.d.ts +75 -6
- package/dist/actions.js +1 -1
- package/dist/bus-BAs9x7IQ.js +1 -0
- package/dist/{context-DlA80GOA.js → context-CK0ZomWt.js} +1 -1
- package/dist/context-plugin-B0sLWOxR.d.ts +34 -0
- package/dist/dev.d.ts +2 -2
- package/dist/effects.d.ts +153 -56
- package/dist/effects.js +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1 -1
- package/dist/{module-I1ixyJzx.d.ts → module-DCJl-qfi.d.ts} +24 -9
- package/dist/notrace/{action-Bdfp-Wvd.d.ts → action-creator-DOWcbXpy.d.ts} +79 -73
- package/dist/notrace/actions.d.ts +97 -13
- package/dist/notrace/actions.js +1 -1
- package/dist/notrace/bus-Bdk0YyFQ.js +1 -0
- package/dist/notrace/{context-yAWj0BFZ.js → context-CctVmHLC.js} +1 -1
- package/dist/notrace/context-plugin-B0sLWOxR.d.ts +34 -0
- package/dist/notrace/{delay-Cgvn16oY.js → delay-B_W1tnkS.js} +1 -1
- package/dist/notrace/effects.d.ts +153 -56
- 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/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/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/trace.d.ts +1 -1
- package/docs/README.md +30 -9
- package/docs/actions.md +117 -41
- package/docs/context.md +48 -7
- package/docs/effects.md +229 -73
- package/docs/errors.md +23 -10
- package/docs/guide.md +238 -129
- package/docs/hmr.md +25 -13
- 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 +11 -9
- package/docs/package.md +24 -0
- package/docs/policies.md +93 -30
- package/docs/react.md +157 -0
- package/docs/semantics.md +5 -3
- package/docs/stores.md +488 -25
- package/docs/tracing.md +69 -54
- package/docs/typescript.md +153 -0
- package/llms-full.txt +2645 -0
- package/llms.txt +48 -0
- package/package.json +39 -9
- package/dist/bus-CRnlwmQ4.js +0 -1
- package/dist/notrace/bus-Dx0l9X6h.js +0 -1
- package/dist/notrace/event-0RIBGYqS.js +0 -1
|
@@ -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
|
@@ -1,22 +1,24 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Turn events into Observables
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Use this page only when you already work in Observables / RxJS. Prefer `bus.on` with policies for cancellation and tracing.
|
|
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
|
+
|
|
7
|
+
<!-- docs-check: skip - needs rxjs, which the project does not depend on -->
|
|
8
|
+
```js
|
|
6
9
|
import { from } from "rxjs";
|
|
7
10
|
import { debounceTime, distinctUntilChanged, switchMap } from "rxjs/operators";
|
|
8
11
|
import { createObserve } from "nanoeffects/observable";
|
|
9
12
|
|
|
10
|
-
const observe = createObserve(bus);
|
|
13
|
+
const observe = createObserve(bus); // Observable per event name
|
|
11
14
|
|
|
12
|
-
// events of the bus as a stream
|
|
13
15
|
from(observe("typed"))
|
|
14
16
|
.pipe(debounceTime(300), distinctUntilChanged(), switchMap(searchRequest))
|
|
15
17
|
.subscribe((items) => bus.emit("results", items));
|
|
16
18
|
```
|
|
17
19
|
|
|
18
|
-
- `observe(event)` returns an Observable of the payloads of that event. Each `subscribe` is a `bus.on` of its own
|
|
20
|
+
- `observe(event)` returns an Observable of the payloads of that event. Each `subscribe` is a `bus.on` of its own; `unsubscribe()` removes it.
|
|
19
21
|
- `subscribe` takes a function or an object with `next`.
|
|
20
|
-
- The stream never completes and never fails: events are the only thing that arrives. Errors of the handler
|
|
21
|
-
- A subscription made this way is not tied to a task
|
|
22
|
-
- For cancellation, policies and tracing keep to `bus.on` with `latest`, `debounce`, `queue
|
|
22
|
+
- The stream never completes and never fails: events are the only thing that arrives. Errors of the subscriber’s handler belong to the subscriber.
|
|
23
|
+
- A subscription made this way is not tied to a task. End it yourself: `onAbort(ctx, subscription.unsubscribe)` inside a task, or with the signal of a module.
|
|
24
|
+
- For cancellation, policies, and tracing, keep to `bus.on` with `latest`, `debounce`, `queue` — they know about tasks. The Observable is for places where a stream is already how you work.
|
package/docs/package.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Choose a package entry
|
|
2
|
+
|
|
3
|
+
Imports are split so you only load what you use. `createBus` stays small; the rest is another entry.
|
|
4
|
+
|
|
5
|
+
| Import | What is in it |
|
|
6
|
+
| ------ | ------------- |
|
|
7
|
+
| `nanoeffects` | `createBus`, the types |
|
|
8
|
+
| `nanoeffects/policies` | `latest`, `leading`, `debounce`, `throttle`, `queue` |
|
|
9
|
+
| `nanoeffects/effects` | `retry`, `timeout`, `poll`, `delay`, `take`, `all`, `allSettled`, `race`, `effectsPlugin` |
|
|
10
|
+
| `nanoeffects/actions` | `createAction`, `createCommand`, `createJob`, `createModule`, `modulePlugin`, `commandsPlugin` |
|
|
11
|
+
| `nanoeffects/redux` | `createEffectsMiddleware` |
|
|
12
|
+
| `nanoeffects/observable` | `createObserve` |
|
|
13
|
+
| `nanoeffects/react` | `reactPlugin`: adds the `useEmitter` hook to a bus (needs React 18 or newer) |
|
|
14
|
+
| `nanoeffects/dev` | `devPlugin` (no-op in production) |
|
|
15
|
+
| `nanoeffects/trace` | `createTracer` and the loggers |
|
|
16
|
+
|
|
17
|
+
## Give the docs to an AI assistant
|
|
18
|
+
|
|
19
|
+
The package ships two plain-text files for assistants that write code with it:
|
|
20
|
+
|
|
21
|
+
- `llms.txt`: a short index. It states the rules that generated code often gets wrong and links to every page of these docs.
|
|
22
|
+
- `llms-full.txt`: the same summary followed by the text of every linked page, in one file.
|
|
23
|
+
|
|
24
|
+
Find them in `node_modules/nanoeffects/`, or load `llms-full.txt` from a CDN such as `https://unpkg.com/nanoeffects/llms-full.txt`. Both match the installed version.
|