nanoeffects 0.4.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.
Files changed (53) hide show
  1. package/README.md +4 -1
  2. package/dist/{action-creator-D48fp13T.d.ts → action-creator-DOWcbXpy.d.ts} +16 -2
  3. package/dist/actions.d.ts +38 -3
  4. package/dist/actions.js +1 -1
  5. package/dist/{bus-Cho463Sq.js → bus-BAs9x7IQ.js} +1 -1
  6. package/dist/{context-DlA80GOA.js → context-CK0ZomWt.js} +1 -1
  7. package/dist/context-plugin-B0sLWOxR.d.ts +34 -0
  8. package/dist/dev.d.ts +2 -2
  9. package/dist/effects.d.ts +153 -56
  10. package/dist/effects.js +1 -1
  11. package/dist/index.d.ts +3 -2
  12. package/dist/index.js +1 -1
  13. package/dist/{module-DVX8aYlq.d.ts → module-DCJl-qfi.d.ts} +16 -2
  14. package/dist/notrace/{action-creator-D48fp13T.d.ts → action-creator-DOWcbXpy.d.ts} +16 -2
  15. package/dist/notrace/actions.d.ts +52 -3
  16. package/dist/notrace/actions.js +1 -1
  17. package/dist/notrace/{bus-B12SvxLv.js → bus-Bdk0YyFQ.js} +1 -1
  18. package/dist/notrace/{context-yAWj0BFZ.js → context-CctVmHLC.js} +1 -1
  19. package/dist/notrace/context-plugin-B0sLWOxR.d.ts +34 -0
  20. package/dist/notrace/{delay-Cgvn16oY.js → delay-B_W1tnkS.js} +1 -1
  21. package/dist/notrace/effects.d.ts +153 -56
  22. package/dist/notrace/effects.js +1 -1
  23. package/dist/notrace/index.d.ts +3 -2
  24. package/dist/notrace/index.js +1 -1
  25. package/dist/notrace/policies.d.ts +1 -1
  26. package/dist/notrace/policies.js +1 -1
  27. package/dist/notrace/redux.d.ts +18 -20
  28. package/dist/notrace/redux.js +1 -1
  29. package/dist/observable.d.ts +1 -1
  30. package/dist/policies.d.ts +1 -1
  31. package/dist/policies.js +1 -1
  32. package/dist/react.d.ts +50 -0
  33. package/dist/react.js +1 -0
  34. package/dist/redux.d.ts +18 -20
  35. package/dist/redux.js +1 -1
  36. package/dist/trace.d.ts +1 -1
  37. package/docs/README.md +7 -1
  38. package/docs/actions.md +1 -1
  39. package/docs/context.md +24 -2
  40. package/docs/effects.md +82 -5
  41. package/docs/guide.md +2 -0
  42. package/docs/migrating-from-effector.md +293 -0
  43. package/docs/migrating-from-rxjs.md +127 -0
  44. package/docs/migrating-from-saga.md +93 -0
  45. package/docs/observable.md +1 -0
  46. package/docs/package.md +12 -2
  47. package/docs/policies.md +1 -0
  48. package/docs/react.md +157 -0
  49. package/docs/stores.md +429 -40
  50. package/docs/typescript.md +153 -0
  51. package/llms-full.txt +2645 -0
  52. package/llms.txt +48 -0
  53. package/package.json +35 -6
@@ -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.
@@ -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";
package/docs/package.md CHANGED
@@ -6,9 +6,19 @@ Imports are split so you only load what you use. `createBus` stays small; the re
6
6
  | ------ | ------------- |
7
7
  | `nanoeffects` | `createBus`, the types |
8
8
  | `nanoeffects/policies` | `latest`, `leading`, `debounce`, `throttle`, `queue` |
9
- | `nanoeffects/effects` | `retry`, `timeout`, `poll`, `delay`, `take`, `all`, `allSettled`, `race`, … |
10
- | `nanoeffects/actions` | `createAction`, `createCommand`, `createJob`, `createModule`, `modulePlugin` |
9
+ | `nanoeffects/effects` | `retry`, `timeout`, `poll`, `delay`, `take`, `all`, `allSettled`, `race`, `effectsPlugin` |
10
+ | `nanoeffects/actions` | `createAction`, `createCommand`, `createJob`, `createModule`, `modulePlugin`, `commandsPlugin` |
11
11
  | `nanoeffects/redux` | `createEffectsMiddleware` |
12
12
  | `nanoeffects/observable` | `createObserve` |
13
+ | `nanoeffects/react` | `reactPlugin`: adds the `useEmitter` hook to a bus (needs React 18 or newer) |
13
14
  | `nanoeffects/dev` | `devPlugin` (no-op in production) |
14
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.
package/docs/policies.md CHANGED
@@ -16,6 +16,7 @@ const bus = createBus();
16
16
  bus.on(
17
17
  "search",
18
18
  latest(async (query, { signal }) => {
19
+ // signal — lets latest cancel this request, not only drop its result
19
20
  await api.search(query, { signal });
20
21
  }),
21
22
  );
package/docs/react.md ADDED
@@ -0,0 +1,157 @@
1
+ # Build a sign-up form in React
2
+
3
+ Use this page when a React screen starts requests on user input and you need them not to race: a username check while typing, a submit button, a flaky network. It builds one registration form. The bus lives outside React; the component only subscribes to it.
4
+
5
+ You need a React project and `nanoeffects` installed. The form talks to your own client, `api` below: replace it with your requests.
6
+
7
+ ## See what goes wrong without a bus
8
+
9
+ A registration form has three problems that `useEffect` and `fetch` leave to you:
10
+
11
+ - **A stale answer.** The user types `ann`, then `anna`. The answer for `ann` arrives last and says the name is free, although `anna` is taken.
12
+ - **A double submit.** A second click on "Sign up" sends a second request while the first one is running.
13
+ - **A flaky network.** One failed request should be retried, but a "name is taken" answer should not.
14
+
15
+ Each has a policy or an effect: `latest`, `leading`, `retry`. They live in one place, outside the component.
16
+
17
+ ## Wire the bus once
18
+
19
+ Put the bus and its handlers in their own file. Create it once, outside any component: a bus created in the body of a component would be a new bus, without subscribers, on every render.
20
+
21
+ ```js
22
+ // registration.js
23
+ import { createBus } from "nanoeffects";
24
+ import { retry } from "nanoeffects/effects";
25
+ import { debounce, latest, leading } from "nanoeffects/policies";
26
+
27
+ // fetch rejects with a TypeError when the network fails; an HTTP error is a plain Error
28
+ const isNetworkError = (error) => error instanceof TypeError;
29
+
30
+ export const bus = createBus(); // the bus: created once, at the top of the module
31
+
32
+ // debounce — wait 300 ms of quiet; latest — abort the check of the previous name
33
+ bus.on(
34
+ "username/typed",
35
+ debounce(
36
+ 300,
37
+ latest(async (name, ctx) => {
38
+ // api — your client; pass ctx.signal so latest can cancel the request itself
39
+ const available = await api.usernameAvailable(name, { signal: ctx.signal });
40
+ ctx.emit("username/checked", { name, available });
41
+ }),
42
+ ),
43
+ );
44
+
45
+ // leading — a second click is ignored while the first sign up is running
46
+ bus.on(
47
+ "form/submitted",
48
+ leading(async (form, ctx) => {
49
+ const signUp = () => api.signUp(form, { signal: ctx.signal });
50
+ try {
51
+ // retry — up to 3 tries, 500 ms apart, and only when the network failed
52
+ const user = await retry(ctx, signUp, {
53
+ times: 3,
54
+ delay: 500,
55
+ shouldRetry: isNetworkError,
56
+ });
57
+ ctx.emit("form/succeeded", user);
58
+ } catch (error) {
59
+ ctx.emit("form/failed", error.message);
60
+ }
61
+ }),
62
+ );
63
+ ```
64
+
65
+ The handlers know nothing about React. A test can emit `username/typed` and read `username/checked` without rendering anything: the same wiring is tested in [`examples/registration-form.ts`](../examples/registration-form.ts) and `src/tests/registration-form.test.ts`.
66
+
67
+ ## Connect the form
68
+
69
+ The component keeps what it shows in `useState`. It subscribes in `useEffect` and emits events from its handlers. `bus.on` returns the function that unsubscribes; returning it from the effect removes the listener on unmount.
70
+
71
+ ```jsx
72
+ // SignUpForm.jsx
73
+ import { useEffect, useState } from "react";
74
+ import { bus } from "./registration"; // the bus of the previous snippet
75
+
76
+ export function SignUpForm() {
77
+ const [name, setName] = useState("");
78
+ const [password, setPassword] = useState("");
79
+ const [available, setAvailable] = useState(null); // null: not checked yet
80
+ const [status, setStatus] = useState("idle"); // idle, sending, done or failed
81
+ const [error, setError] = useState("");
82
+
83
+ useEffect(() => {
84
+ // bus.on — listen for what the handlers report; each call returns an unsubscribe function
85
+ const unbindChecked = bus.on("username/checked", (result) => {
86
+ setAvailable(result.available);
87
+ });
88
+ const unbindSucceeded = bus.on("form/succeeded", () => setStatus("done"));
89
+ const unbindFailed = bus.on("form/failed", (message) => {
90
+ setStatus("failed");
91
+ setError(message);
92
+ });
93
+
94
+ return () => {
95
+ unbindChecked();
96
+ unbindSucceeded();
97
+ unbindFailed();
98
+ };
99
+ }, []);
100
+
101
+ function handleName(event) {
102
+ const value = event.target.value;
103
+ setName(value);
104
+ setAvailable(null);
105
+ bus.emit("username/typed", value); // bus.emit — the handler debounces and checks the name
106
+ }
107
+
108
+ function handleSubmit(event) {
109
+ event.preventDefault();
110
+ setStatus("sending");
111
+ bus.emit("form/submitted", { name, password }); // a second click is dropped by leading
112
+ }
113
+
114
+ return (
115
+ <form onSubmit={handleSubmit}>
116
+ <input value={name} onChange={handleName} placeholder="Username" />
117
+ {available === false && <p>This name is taken</p>}
118
+ <input
119
+ type="password"
120
+ value={password}
121
+ onChange={(event) => setPassword(event.target.value)}
122
+ />
123
+ <button type="submit" disabled={status === "sending"}>Sign up</button>
124
+ {status === "failed" && <p>{error}</p>}
125
+ {status === "done" && <p>Welcome!</p>}
126
+ </form>
127
+ );
128
+ }
129
+ ```
130
+
131
+ The `disabled` button is only for the look. The form stays correct without it: `leading` drops a second submit while the first one runs.
132
+
133
+ In development, React StrictMode runs the effect twice (subscribe, unsubscribe, subscribe). That is safe here, because the cleanup removes exactly what the effect added.
134
+
135
+ ## Stop running work when the form closes
136
+
137
+ Unsubscribing stops the component from hearing results, but a request that has started keeps running. To cancel it, the component emits `form/closed` when it unmounts, and each handler that should stop listens for it with `ctx.on`:
138
+
139
+ ```js
140
+ import { latest } from "nanoeffects/policies";
141
+
142
+ // the username check of registration.js, with one new line
143
+ const checkName = latest(async (name, ctx) => {
144
+ // ctx.on — a subscription of this task; aborting the task also drops the result
145
+ ctx.on("form/closed", () => ctx.abort());
146
+ const available = await api.usernameAvailable(name, { signal: ctx.signal });
147
+ ctx.emit("username/checked", { name, available });
148
+ });
149
+ ```
150
+
151
+ In the component, add `bus.emit("form/closed")` to the cleanup function of the effect. This is optional: skip it when finishing a request after unmount does no harm, as for a cheap check.
152
+
153
+ ## Where to go next
154
+
155
+ - The policies behind this form: [Policies](policies.md).
156
+ - `retry`, `timeout` and the other effects: [Effects](effects.md).
157
+ - A state library instead of `useState`: [Stores](stores.md).