nanoeffects 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/actions.d.ts +38 -22
- package/dist/actions.js +1 -1
- package/dist/bus-CkTHUAvq.js +1 -0
- package/dist/context-COgyNlrC.js +1 -0
- package/dist/effects.js +1 -1
- package/dist/index.js +1 -1
- package/dist/notrace/actions.d.ts +38 -22
- package/dist/notrace/actions.js +1 -1
- package/dist/notrace/bus-BmCTo8BG.js +1 -0
- package/dist/notrace/context-yYU3_9lq.js +1 -0
- package/dist/notrace/{delay-B_W1tnkS.js → delay-BosJKFjg.js} +1 -1
- package/dist/notrace/effects.js +1 -1
- package/dist/notrace/index.js +1 -1
- package/dist/notrace/policies.d.ts +30 -2
- package/dist/notrace/policies.js +1 -1
- package/dist/oop.js +1 -1
- package/dist/policies.d.ts +2 -1
- package/dist/policies.js +1 -1
- package/dist/react.d.ts +33 -1
- package/dist/react.js +1 -1
- package/dist/scope.js +1 -1
- package/dist/signal.d.ts +51 -0
- package/dist/signal.js +1 -0
- package/dist/with-policy-CqUS-VWW.d.ts +30 -0
- package/dist/with-policy-DBV9dh2F.js +1 -0
- package/docs/README.md +6 -0
- package/docs/actions.md +73 -4
- package/docs/cancel-stale-requests.md +111 -0
- package/docs/debounce-and-abort.md +106 -0
- package/docs/guide.md +1 -1
- package/docs/migrating-from-effector.md +2 -2
- package/docs/migrating-from-mitt.md +97 -0
- package/docs/motivation.md +41 -0
- package/docs/observable.md +124 -10
- package/docs/package.md +4 -2
- package/docs/plugins.md +20 -0
- package/docs/policies.md +33 -0
- package/docs/prevent-double-submit.md +128 -0
- package/docs/query-libraries.md +124 -0
- package/docs/react.md +44 -0
- package/llms-full.txt +939 -26
- package/llms.txt +9 -3
- package/package.json +25 -4
- package/skills/nanoeffects/SKILL.md +52 -0
- package/dist/bus-BAs9x7IQ.js +0 -1
- package/dist/context-CK0ZomWt.js +0 -1
- package/dist/notrace/bus-Bdk0YyFQ.js +0 -1
- package/dist/notrace/context-CctVmHLC.js +0 -1
package/dist/signal.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { B as EventMap, C as Emit, E as EventLike, a as EmitScope, c as WithScope, h as Bus, v as CommandCall } from "./action-creator-BrZF6hXc.js";
|
|
2
|
+
//#region src/signal/plugin.d.ts
|
|
3
|
+
/** What the second argument of `emit` takes besides the fields of a scope. */
|
|
4
|
+
type SignalOption = {
|
|
5
|
+
/** Aborts the tasks that this `emit` starts, with its reason, when it aborts. */
|
|
6
|
+
signal?: AbortSignal;
|
|
7
|
+
};
|
|
8
|
+
/** `emit` of a bus with `signalPlugin`: the call forms take `{ signal }` as their second argument. */
|
|
9
|
+
type SignalEmit<E extends EventMap> = Emit<E> & {
|
|
10
|
+
<P, R, F>(call: CommandCall<P, R> & WithScope<F>, options: SignalOption & EmitScope<E, F>): R;
|
|
11
|
+
<P, R>(call: CommandCall<P, R>, options: SignalOption & EmitScope<E>): R;
|
|
12
|
+
(event: EventLike<E>, options: SignalOption & EmitScope<E>): void;
|
|
13
|
+
};
|
|
14
|
+
/** A bus with `signalPlugin`: the same bus, with the `emit` that takes a signal. */
|
|
15
|
+
type SignalBus<E extends EventMap> = Bus<E> & {
|
|
16
|
+
emit: SignalEmit<E>;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Lets `bus.emit(call, { signal })` tie the tasks that this emit starts to an
|
|
20
|
+
* outside `AbortSignal`: when it aborts, their `ctx.signal` aborts with the same
|
|
21
|
+
* reason, so a handler, `latest`, `retry` and `timeout` stop as for any abort. It
|
|
22
|
+
* is made for libraries that own the request and give you a signal, such as the
|
|
23
|
+
* `queryFn` of React Query. The tasks that count are those made while the call
|
|
24
|
+
* to `emit` runs (the command or job and the listeners of its type, and their
|
|
25
|
+
* `ctx.run` children); a separate `emit` that a handler makes later is not
|
|
26
|
+
* linked. `emit` returns what it returns without the plugin.
|
|
27
|
+
*
|
|
28
|
+
* For a command or a job the link ends when its promise settles; a handler
|
|
29
|
+
* that returns without a promise is finished with the call, so it is not
|
|
30
|
+
* linked after it. For an event the link stays until the signal aborts, so
|
|
31
|
+
* give each request its own signal.
|
|
32
|
+
*
|
|
33
|
+
* `{ signal }` can share the object with the fields of a scope (see
|
|
34
|
+
* `scopePlugin`); `signal` is never put on `ctx` by a scope. Without this
|
|
35
|
+
* plugin the signal is ignored. Returns the same bus, typed with the new `emit`.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```ts
|
|
39
|
+
* import { createBus } from "nanoeffects";
|
|
40
|
+
* import { signalPlugin } from "nanoeffects/signal";
|
|
41
|
+
*
|
|
42
|
+
* const bus = createBus().use(signalPlugin()); // emit takes a signal now
|
|
43
|
+
*
|
|
44
|
+
* // React Query aborts `signal` when the component unmounts or the key changes
|
|
45
|
+
* const queryFn = ({ signal }: { signal: AbortSignal }) =>
|
|
46
|
+
* bus.emit(getUser("1"), { signal });
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export declare const signalPlugin: () => <E extends EventMap>(bus: Bus<E>) => SignalBus<E>;
|
|
50
|
+
//#endregion
|
|
51
|
+
export type { SignalBus, SignalEmit, SignalOption };
|
package/dist/signal.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{t as e}from"./onAbort-BgMfspK4.js";import{n as t}from"./promise-CSLpzwSR.js";import{n}from"./bus-CkTHUAvq.js";const r=e=>typeof e==`object`&&!!e,i=(e,t)=>{let n=t[0];if(!r(n)||!r(e)&&typeof e!=`function`)return;let i=Reflect.get(n,`signal`);return i instanceof AbortSignal?i:void 0},a=(r,i,a,o)=>{let s=e({signal:r},e=>{for(let{target:t}of i)t.abort(e)});return t(o)?o.then(s,s):n(a)&&s(),o},o=()=>e=>{let t,n,r=e.addContext;e.addContext=e=>{r?.(e),t?.push({target:e}),n?.aborted&&e.abort(n.reason)};let o=e.emit;return e.emit=(e,...r)=>{let s=i(e,r);if(!(s||t))return o(e,...r);let c=t,l=n,u=[];t=s?u:void 0,n=s;let d;try{d=o(e,...r)}finally{t=c,n=l}return s?a(s,u,e,d):d},e};export{o as signalPlugin};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { B as EventMap, H as PayloadOf, W as Task } from "./action-creator-BrZF6hXc.js";
|
|
2
|
+
//#region src/policies/with-policy.d.ts
|
|
3
|
+
/** What `withPolicy` returns: the task as a plain function that can be cancelled. */
|
|
4
|
+
type PolicyFunction<P, R> = {
|
|
5
|
+
(...args: PayloadOf<P>): Promise<Awaited<R> | undefined>;
|
|
6
|
+
/** Aborts every call that has not finished. Calls made after it work as before. */
|
|
7
|
+
cancel: (reason?: unknown) => void;
|
|
8
|
+
/** Same as `cancel`; present where `Symbol.dispose` exists, for `using`. */
|
|
9
|
+
[Symbol.dispose]?: () => void;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Makes a policy (or any task) a plain function that needs no bus and no
|
|
13
|
+
* context. The returned function keeps the state of the policy between calls,
|
|
14
|
+
* so a debounced one waits across calls. `cancel()` aborts what is pending or
|
|
15
|
+
* running; calls after it work again. The callback keeps the `(payload, ctx)`
|
|
16
|
+
* shape; `ctx.signal`, `ctx.abort` and `ctx.run` work, `ctx.emit` and `ctx.on`
|
|
17
|
+
* do nothing because there is no bus.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```ts
|
|
21
|
+
* const onChange = withPolicy(debounce(300, (query: string) => search(query)));
|
|
22
|
+
*
|
|
23
|
+
* onChange("re");
|
|
24
|
+
* onChange("redux"); // only this one runs, 300 ms after it
|
|
25
|
+
* onChange.cancel(); // or nothing runs at all
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
declare const withPolicy: <P, R>(task: Task<P, EventMap, R>) => PolicyFunction<P, R>;
|
|
29
|
+
//#endregion
|
|
30
|
+
export { withPolicy as n, PolicyFunction as t };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{n as e}from"./context-COgyNlrC.js";const t=Reflect.get(Symbol,`dispose`),n=n=>{let r=new AbortController,i=async(...t)=>{let i=t[0],{ctx:a,unlink:o}=e({event:{type:`withPolicy`,payload:i},parent:{signal:r.signal}});try{return await n(i,a)}finally{o()}},a=e=>{r.abort(e),r=new AbortController},o=Object.assign(i,{cancel:a});return t&&Reflect.set(o,t,()=>a()),o};export{n as t};
|
package/docs/README.md
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
The [Guide](guide.md) is the ladder: small bus → policies for async UI → effects when you need them → commands and modules for domain logic off the UI.
|
|
6
6
|
|
|
7
|
+
Why the library exists, and when to pick it over Redux, Effector or RxJS (SDKs, shared libraries, Module Federation): [Why nanoeffects exists](motivation.md).
|
|
8
|
+
|
|
7
9
|
Code for every guide step: [`examples/guide.ts`](../examples/guide.ts) (type-checked and tested).
|
|
8
10
|
|
|
9
11
|
## Jump by project need
|
|
@@ -16,6 +18,10 @@ Code for every guide step: [`examples/guide.ts`](../examples/guide.ts) (type-che
|
|
|
16
18
|
| Domain logic off the UI | [Actions, commands, modules](actions.md) |
|
|
17
19
|
| Already on Redux / Zustand / MobX / Pinia | [Stores](stores.md) |
|
|
18
20
|
| A React form with async checks | [Build a sign-up form in React](react.md) |
|
|
21
|
+
| Old responses overwrite new ones | [Cancel stale requests](cancel-stale-requests.md) |
|
|
22
|
+
| Debounced input that must also cancel the old request | [Debounce and abort](debounce-and-abort.md) |
|
|
23
|
+
| A second click sends a second request | [Prevent a double submit](prevent-double-submit.md) |
|
|
24
|
+
| Using mitt, nanoevents or EventEmitter | [Coming from mitt](migrating-from-mitt.md) |
|
|
19
25
|
| Moving from redux-saga | [Coming from redux-saga](migrating-from-saga.md) |
|
|
20
26
|
| Using RxJS | [Use nanoeffects next to RxJS](migrating-from-rxjs.md) |
|
|
21
27
|
| Moving from Effector | [Coming from Effector](migrating-from-effector.md) |
|
package/docs/actions.md
CHANGED
|
@@ -110,15 +110,84 @@ The registry lives in `nanoeffects/actions`, so a bus without one carries none o
|
|
|
110
110
|
|
|
111
111
|
### Jobs
|
|
112
112
|
|
|
113
|
-
`createJob(type, handler, { success, fail })` makes a command that also sends its outcome as actions, when nobody waits for the result. It returns the command with `loadUser.success` and `loadUser.
|
|
113
|
+
`createJob(type, handler, { pending, success, fail, cancel })` makes a command that also sends its outcome as actions, when nobody waits for the result. It returns the command with `loadUser.pending`, `loadUser.success`, `loadUser.fail` and `loadUser.cancel` on it.
|
|
114
114
|
|
|
115
|
+
- When the handler starts, emits `loadUser.pending(payload)`.
|
|
115
116
|
- On success, emits `loadUser.success(result)`.
|
|
116
117
|
- On failure, emits `loadUser.fail(error)`.
|
|
117
|
-
-
|
|
118
|
+
- When the task is aborted before the handler settles, emits `loadUser.cancel(reason)` instead of `success` or `fail`. After `pending`, exactly one of the three follows.
|
|
119
|
+
- `pending` / `success` / `fail` / `cancel` are a string (job makes an action creator) or an existing action creator. Missing ones become `<type>/pending`, `<type>/success`, `<type>/fail` or `<type>/cancel`.
|
|
118
120
|
- `bus.emit(loadUser(id))` returns a promise of the result. Handler errors are always handled: they go to `fail` and the promise resolves with `undefined`. For a result that must reject, use a command.
|
|
119
|
-
- Outcome actions are emitted as the task that ran the job (also for a handler bound with `commands.handle`).
|
|
120
|
-
- A missing handler is not an outcome: `emit` throws `No handler for command "..."
|
|
121
|
+
- Outcome actions are emitted as the task that ran the job (also for a handler bound with `commands.handle`). `success` and `fail` are not sent after abort; `cancel` is, once the handler settles.
|
|
122
|
+
- A missing handler is not an outcome: `emit` throws `No handler for command "..."` and nothing is sent.
|
|
121
123
|
- Calling the job only builds `{ type, payload }`; the handler runs on `emit`.
|
|
124
|
+
- The outcomes are events like any other: with `onUnhandled` set on the bus, it is called for a `pending` or a `cancel` that nobody listens to.
|
|
125
|
+
|
|
126
|
+
## Show a loading state with a job
|
|
127
|
+
|
|
128
|
+
**Problem.** A spinner needs a flag that is `true` while a request runs. Setting it by hand in the handler (`loading = true`, then `try` / `finally`) breaks when the task is aborted: `ctx.emit` does nothing after an abort, so a flag that is cleared through the bus stays `true`.
|
|
129
|
+
|
|
130
|
+
**When.** The UI shows a loading state for a request, and the request can be aborted (a page is left, `ctx.abort()` is called). Skip it when nothing shows the state.
|
|
131
|
+
|
|
132
|
+
A job sends one action when the handler starts and one when it ends, whatever way it ends. A store sets the flag on `pending` and clears it on `success`, `fail` and `cancel`.
|
|
133
|
+
|
|
134
|
+
```js
|
|
135
|
+
// bus.js
|
|
136
|
+
import { createBus } from "nanoeffects";
|
|
137
|
+
|
|
138
|
+
// createBus — the bus the app shares
|
|
139
|
+
export const bus = createBus();
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
```js
|
|
143
|
+
// user.js
|
|
144
|
+
import { createJob } from "nanoeffects/actions";
|
|
145
|
+
|
|
146
|
+
// createJob — a command that also sends pending, success, fail and cancel
|
|
147
|
+
export const loadUser = createJob("user/load", fetchUser);
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
```js
|
|
151
|
+
// user-store.js
|
|
152
|
+
import { bus } from "./bus";
|
|
153
|
+
import { loadUser } from "./user";
|
|
154
|
+
|
|
155
|
+
export const state = { loading: false, user: null };
|
|
156
|
+
|
|
157
|
+
// pending — the handler starts: show the spinner
|
|
158
|
+
bus.on(loadUser.pending, () => {
|
|
159
|
+
state.loading = true;
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// success — the handler returned a user
|
|
163
|
+
bus.on(loadUser.success, (user) => {
|
|
164
|
+
state.loading = false;
|
|
165
|
+
state.user = user;
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// fail — the handler threw
|
|
169
|
+
bus.on(loadUser.fail, () => {
|
|
170
|
+
state.loading = false;
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// cancel — the task was aborted: the only way to clear the flag then
|
|
174
|
+
bus.on(loadUser.cancel, () => {
|
|
175
|
+
state.loading = false;
|
|
176
|
+
});
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
```js
|
|
180
|
+
// page.js
|
|
181
|
+
import { bus } from "./bus";
|
|
182
|
+
import { loadUser } from "./user";
|
|
183
|
+
|
|
184
|
+
// emit — runs the job; state.loading is true as soon as it returns
|
|
185
|
+
bus.emit(loadUser("1"));
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
`cancel` is sent when the handler settles, not at the moment of the abort. A handler that stops on `ctx.signal` (as `fetch` does) settles at once. A handler that ignores the signal sends `cancel` when it finishes, and one that never finishes sends nothing.
|
|
189
|
+
|
|
190
|
+
**Limit: policies.** A policy such as `latest`, `leading`, `throttle` or `queue` wraps the handler, inside the job. The job does not know what the policy decides, so `pending` is sent for every call. A call that the policy drops, and a run that `latest` replaces, then close with `success(undefined)`, not with `cancel`. A flag set by `pending` is exact for a job without a policy, and for an abort of the task or of its parent. With a policy, count the calls or use the result of `emit`.
|
|
122
191
|
|
|
123
192
|
## Mount domains with modules
|
|
124
193
|
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# Cancel stale requests and fix race conditions
|
|
2
|
+
|
|
3
|
+
Read this page if an older response can overwrite a newer one: a search box that shows the results of the previous query, a filter that flickers, a form that submits twice. It shows the cause, the fix with `latest`, and how to add a pause, a retry and a timeout. It needs only the bus from the [README](../README.md).
|
|
4
|
+
|
|
5
|
+
## See the problem
|
|
6
|
+
|
|
7
|
+
A user types `ab`, then `abc`. Two requests start. If the one for `ab` is slower, it finishes last and its results replace the right ones.
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
import { createBus } from "nanoeffects";
|
|
11
|
+
|
|
12
|
+
const bus = createBus(); // the bus: events and handlers
|
|
13
|
+
|
|
14
|
+
// Without a policy every "search" starts its own request, and the slowest one wins.
|
|
15
|
+
bus.on("search", async (query) => {
|
|
16
|
+
const items = await api.search(query);
|
|
17
|
+
bus.emit("results", items);
|
|
18
|
+
});
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Checking a counter by hand (`if (id !== lastId) return`) hides the old result, but the old request still runs. Calling `controller.abort()` by hand works, but you must keep the controller somewhere and abort it before each new call.
|
|
22
|
+
|
|
23
|
+
## Keep only the latest request
|
|
24
|
+
|
|
25
|
+
Wrap the handler in `latest`. A new `"search"` aborts the task of the previous one, and an aborted task delivers no result.
|
|
26
|
+
|
|
27
|
+
```js
|
|
28
|
+
import { createBus } from "nanoeffects";
|
|
29
|
+
import { latest } from "nanoeffects/policies";
|
|
30
|
+
|
|
31
|
+
const bus = createBus(); // the bus: events and handlers
|
|
32
|
+
|
|
33
|
+
// latest: a newer "search" aborts the older run
|
|
34
|
+
bus.on(
|
|
35
|
+
"search",
|
|
36
|
+
latest(async (query, { signal }) => {
|
|
37
|
+
// signal: pass it on so the browser cancels the request, not only drops the result
|
|
38
|
+
const items = await api.search(query, { signal });
|
|
39
|
+
bus.emit("results", items);
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
bus.on("results", (items) => render(items));
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
What to replace: `api.search` is your own request function. It must accept `{ signal }` and give it to `fetch`. Without it, `latest` still ignores the old result, but the request keeps running.
|
|
47
|
+
|
|
48
|
+
Result: `emit("search", "ab")` followed by `emit("search", "abc")` shows only the results for `abc`, and the request for `ab` is cancelled.
|
|
49
|
+
|
|
50
|
+
## Wait for a pause in typing
|
|
51
|
+
|
|
52
|
+
Add `debounce` outside `latest`. The search starts after 300 ms without a new keystroke.
|
|
53
|
+
|
|
54
|
+
```js
|
|
55
|
+
import { createBus } from "nanoeffects";
|
|
56
|
+
import { debounce, latest } from "nanoeffects/policies";
|
|
57
|
+
|
|
58
|
+
const bus = createBus(); // the bus: events and handlers
|
|
59
|
+
|
|
60
|
+
// latest: a newer search aborts the request in flight
|
|
61
|
+
const searchLatest = latest(async (query, { signal }) => {
|
|
62
|
+
const items = await api.search(query, { signal });
|
|
63
|
+
bus.emit("results", items);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// debounce: run after 300 ms without a new "typed"
|
|
67
|
+
bus.on("typed", debounce(300, searchLatest));
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Retry a failed request and limit its time
|
|
71
|
+
|
|
72
|
+
Use `retry` and `timeout` around the request. The pause grows with each attempt.
|
|
73
|
+
|
|
74
|
+
```js
|
|
75
|
+
import { createBus } from "nanoeffects";
|
|
76
|
+
import { latest } from "nanoeffects/policies";
|
|
77
|
+
import { backoff, retry, timeout } from "nanoeffects/effects";
|
|
78
|
+
|
|
79
|
+
const bus = createBus(); // the bus: events and handlers
|
|
80
|
+
|
|
81
|
+
// timeout: abort one attempt after 5 s
|
|
82
|
+
const searchOnce = timeout(5000, (query, { signal }) => api.search(query, { signal }));
|
|
83
|
+
|
|
84
|
+
// retry: up to 4 attempts, backoff makes the pause longer each time
|
|
85
|
+
const searchWithRetry = retry(searchOnce, {
|
|
86
|
+
times: 4,
|
|
87
|
+
delay: backoff({ base: 200, max: 5000, jitter: true }),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// latest: a newer search aborts the whole retry chain
|
|
91
|
+
bus.on(
|
|
92
|
+
"search",
|
|
93
|
+
latest(async (query, ctx) => {
|
|
94
|
+
const items = await ctx.run(searchWithRetry, query); // ctx.run: a child task, aborted with this one
|
|
95
|
+
ctx.emit("results", items);
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Pick another policy for another race
|
|
101
|
+
|
|
102
|
+
| Situation | Policy |
|
|
103
|
+
| --------- | ------ |
|
|
104
|
+
| Only the newest result matters (search, filter, tab switch) | `latest` |
|
|
105
|
+
| A second click while the first call runs must be ignored (submit, pay) | `leading` |
|
|
106
|
+
| Every call must run, one after another (save, upload) | `queue` |
|
|
107
|
+
| Calls come in bursts and you want one at the end | `debounce` |
|
|
108
|
+
| Calls come in bursts and you want one per period | `throttle` |
|
|
109
|
+
| Each user or item has its own race | add `{ key }`, for example `latest(handler, { key })` |
|
|
110
|
+
|
|
111
|
+
All policies and their options: [Policies](policies.md). The same step by step in a form: [Build a sign-up form in React](react.md).
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Debounce a request and abort the previous one
|
|
2
|
+
|
|
3
|
+
Read this page if you debounce user input (a search box, an autosave, a resize handler) and you also want the request in flight to stop when a new one starts. `debounce` alone waits for a pause. It does not cancel a request that already started. This page shows the two policies together, the order to put them in, and when to use `throttle` instead.
|
|
4
|
+
|
|
5
|
+
## Know what each policy does
|
|
6
|
+
|
|
7
|
+
| Policy | What it does | What it does not do |
|
|
8
|
+
| ------ | ------------ | ------------------- |
|
|
9
|
+
| `debounce(ms, fn)` | runs `fn` after `ms` without a new call | does not cancel a run that already started |
|
|
10
|
+
| `latest(fn)` | aborts the previous run when a new one starts | does not wait for a pause |
|
|
11
|
+
|
|
12
|
+
Use both for a search box. `debounce` sends fewer requests. `latest` cancels the slow one when the user types again after the pause.
|
|
13
|
+
|
|
14
|
+
## Combine them
|
|
15
|
+
|
|
16
|
+
Put `debounce` outside and `latest` inside. The event waits for a pause first. When the run starts, it aborts the previous run.
|
|
17
|
+
|
|
18
|
+
```js
|
|
19
|
+
import { createBus } from "nanoeffects";
|
|
20
|
+
import { debounce, latest } from "nanoeffects/policies";
|
|
21
|
+
|
|
22
|
+
const bus = createBus(); // the bus: events and handlers
|
|
23
|
+
|
|
24
|
+
// latest: a newer run aborts the older request
|
|
25
|
+
const searchLatest = latest(async (query, { signal }) => {
|
|
26
|
+
// signal: pass it on so the browser cancels the request itself
|
|
27
|
+
const items = await api.search(query, { signal });
|
|
28
|
+
bus.emit("results", items);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// debounce: run searchLatest after 300 ms without a new "typed"
|
|
32
|
+
bus.on("typed", debounce(300, searchLatest));
|
|
33
|
+
|
|
34
|
+
bus.on("results", (items) => render(items));
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
What to replace: `api.search` is your own request function. It must accept `{ signal }` and give it to `fetch`. `render` shows the items.
|
|
38
|
+
|
|
39
|
+
Result: typing `a`, `ab`, `abc` within 300 ms sends one request for `abc`. If the user types `abcd` while that request runs, the request for `abc` is aborted and one for `abcd` starts after the pause.
|
|
40
|
+
|
|
41
|
+
## Run the first call at once
|
|
42
|
+
|
|
43
|
+
By default `debounce` waits even before the first call. For an input where the first key press should react at once, add `leading: true`. The first call of a burst runs immediately, and the last one runs after the pause.
|
|
44
|
+
|
|
45
|
+
```js
|
|
46
|
+
import { createBus } from "nanoeffects";
|
|
47
|
+
import { debounce } from "nanoeffects/policies";
|
|
48
|
+
|
|
49
|
+
const bus = createBus(); // the bus: events and handlers
|
|
50
|
+
|
|
51
|
+
const showSuggestions = (query) => console.log("suggest", query);
|
|
52
|
+
|
|
53
|
+
// debounce with leading: the first call runs at once, the last one after 300 ms of quiet
|
|
54
|
+
bus.on("typed", debounce(300, showSuggestions, { leading: true }));
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`leading` and `trailing` follow lodash. All combinations: [Policies](policies.md#configure-debounce-and-throttle).
|
|
58
|
+
|
|
59
|
+
## Use throttle for continuous input
|
|
60
|
+
|
|
61
|
+
`debounce` never runs while calls keep coming. For a scroll or a drag you want updates during the movement. Use `throttle`: at most one run per period.
|
|
62
|
+
|
|
63
|
+
```js
|
|
64
|
+
import { createBus } from "nanoeffects";
|
|
65
|
+
import { throttle } from "nanoeffects/policies";
|
|
66
|
+
|
|
67
|
+
const bus = createBus(); // the bus: events and handlers
|
|
68
|
+
|
|
69
|
+
const saveScroll = (position) => localStorage.setItem("scroll", String(position));
|
|
70
|
+
|
|
71
|
+
// throttle: at most one run per 100 ms while "scrolled" keeps coming
|
|
72
|
+
bus.on("scrolled", throttle(100, saveScroll));
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Debounce without a bus
|
|
76
|
+
|
|
77
|
+
If the page has no bus, wrap the same pair with `withPolicy`. The result is a plain function that you call from an input handler.
|
|
78
|
+
|
|
79
|
+
```js
|
|
80
|
+
import { debounce, latest, withPolicy } from "nanoeffects/policies";
|
|
81
|
+
|
|
82
|
+
// latest: a newer run aborts the older request
|
|
83
|
+
const searchLatest = latest(async (query, { signal }) => {
|
|
84
|
+
const items = await api.search(query, { signal });
|
|
85
|
+
console.log(items);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// withPolicy: debounce by 300 ms, called as a plain function
|
|
89
|
+
const onTyped = withPolicy(debounce(300, searchLatest));
|
|
90
|
+
|
|
91
|
+
onTyped("a");
|
|
92
|
+
onTyped("ab"); // only "ab" runs, 300 ms later
|
|
93
|
+
|
|
94
|
+
// cancel: drop a pending call and abort a running request, for example on page leave
|
|
95
|
+
onTyped.cancel();
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
In a React component use [`usePolicy`](react.md#keep-a-debounced-handler-between-renders), which also cancels on unmount.
|
|
99
|
+
|
|
100
|
+
## Check these when it does not cancel
|
|
101
|
+
|
|
102
|
+
- The request did not get `signal`. `latest` then drops the old result but the request goes on.
|
|
103
|
+
- The order is reversed. Write `debounce(300, latest(handler))`, as above, so `debounce` receives the events and `latest` guards the request.
|
|
104
|
+
- Several inputs share one event. Give each its own event name, or use `{ key }`, for example `latest(handler, { key: (input) => input.id })`.
|
|
105
|
+
|
|
106
|
+
Related: [Cancel stale requests](cancel-stale-requests.md), [Policies](policies.md).
|
package/docs/guide.md
CHANGED
|
@@ -217,7 +217,7 @@ const searchLatest = latest(async (query, { signal }) => {
|
|
|
217
217
|
bus.on("search", debounce(300, searchLatest));
|
|
218
218
|
```
|
|
219
219
|
|
|
220
|
-
Full options: [Policies](policies.md).
|
|
220
|
+
Full options: [Policies](policies.md). No bus? A policy also works as a plain function: [Use a policy without a bus](policies.md#use-a-policy-without-a-bus).
|
|
221
221
|
|
|
222
222
|
## 5. Add retry and timeout
|
|
223
223
|
|
|
@@ -20,7 +20,7 @@ nanoeffects has no store. `createStore` can be replaced by any store you like, f
|
|
|
20
20
|
| event call `loaded(user)` | `bus.emit("loaded", user)` |
|
|
21
21
|
| `createEffect(handler)` | a handler: `bus.on(event, handler)` |
|
|
22
22
|
| effect with `done` and `fail` | [`createJob`](actions.md): emits the success and fail actions |
|
|
23
|
-
| `effect.pending` |
|
|
23
|
+
| `effect.pending` | `job.pending`, `job.success`, `job.fail` and `job.cancel`: set and clear the flag in your store ([Show a loading state](actions.md#show-a-loading-state-with-a-job)) |
|
|
24
24
|
| `sample({ clock, fn, target })` | `bus.on(clock, handler)` and `ctx.emit(target, …)` |
|
|
25
25
|
| `sample({ source: $store, clock })` | read the store from a [context field](context.md) in the handler |
|
|
26
26
|
| `sample({ filter })` | an `if` in the handler |
|
|
@@ -318,7 +318,7 @@ bus.on(
|
|
|
318
318
|
## What is different
|
|
319
319
|
|
|
320
320
|
- **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)).
|
|
321
|
-
-
|
|
321
|
+
- **`pending` is an action, not a store.** `createJob` sends `pending` when the handler starts, and `success`, `fail` or `cancel` when it ends; you keep the flag in your store ([Show a loading state](actions.md#show-a-loading-state-with-a-job)). With a policy such as `latest` around the handler the flag is not exact yet, which that section explains. A running task is also visible through [Tracing](tracing.md).
|
|
322
322
|
- **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.
|
|
323
323
|
- **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.
|
|
324
324
|
- **No graph.** `sample` and `forward` build a static graph. A bus subscribes handlers at runtime and the call order is the order of `on`.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# Coming from mitt, nanoevents or EventEmitter
|
|
2
|
+
|
|
3
|
+
Read this page if your app uses a small event emitter (`mitt`, `nanoevents`, Node's `EventEmitter`, `tiny-emitter`) and its handlers start to do async work: requests, timers, retries. It maps the calls, shows what you gain, and shows what to check before you switch.
|
|
4
|
+
|
|
5
|
+
## When to switch
|
|
6
|
+
|
|
7
|
+
An emitter calls a function and forgets it. That is enough while handlers are synchronous. Switch when handlers start async work and you hit one of these:
|
|
8
|
+
|
|
9
|
+
- an older request finishes after a newer one and overwrites its result;
|
|
10
|
+
- you keep `AbortController` objects by hand;
|
|
11
|
+
- you write debounce, throttle or retry around handlers yourself;
|
|
12
|
+
- you cannot tell which event started which request.
|
|
13
|
+
|
|
14
|
+
If handlers stay synchronous, keep your emitter. nanoeffects costs about 1.4 kB gzip (1.3 kB brotli), close to the small emitters.
|
|
15
|
+
|
|
16
|
+
## Find the equivalent
|
|
17
|
+
|
|
18
|
+
| mitt / EventEmitter | nanoeffects |
|
|
19
|
+
| ------------------- | ----------- |
|
|
20
|
+
| `emitter.on("a", handler)` | `bus.on("a", handler)` |
|
|
21
|
+
| `emitter.off("a", handler)` | call the function that `bus.on` returned, or `bus.off("a")` for all handlers of `a` |
|
|
22
|
+
| `emitter.emit("a", payload)` | `bus.emit("a", payload)` |
|
|
23
|
+
| `emitter.once("a", handler)` | `bus.once("a", handler)` |
|
|
24
|
+
| `on("*", (type, payload) => …)` | `bus.on("*", (payload, ctx) => …)`: the event name is `ctx.type` (see the [Guide](guide.md)) |
|
|
25
|
+
| `emitter.all.clear()` | `bus.off()` |
|
|
26
|
+
|
|
27
|
+
Two differences to know:
|
|
28
|
+
|
|
29
|
+
- `bus.on` returns the unsubscribe function. You do not need to keep the handler to remove it.
|
|
30
|
+
- The handler gets a second argument, the task context: `(payload, ctx)`. You can ignore it until you need `ctx.signal` or `ctx.emit`.
|
|
31
|
+
|
|
32
|
+
## Move a handler
|
|
33
|
+
|
|
34
|
+
Before, with `mitt`:
|
|
35
|
+
|
|
36
|
+
<!-- docs-check: skip - needs mitt, which the project does not depend on -->
|
|
37
|
+
```js
|
|
38
|
+
import mitt from "mitt";
|
|
39
|
+
|
|
40
|
+
const emitter = mitt();
|
|
41
|
+
|
|
42
|
+
emitter.on("search", async (query) => {
|
|
43
|
+
const items = await api.search(query);
|
|
44
|
+
emitter.emit("results", items);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
emitter.emit("search", "abc");
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
After, with nanoeffects. The first step changes only the import and the constructor:
|
|
51
|
+
|
|
52
|
+
```js
|
|
53
|
+
import { createBus } from "nanoeffects";
|
|
54
|
+
|
|
55
|
+
const bus = createBus(); // event bus; each handler call is a task
|
|
56
|
+
|
|
57
|
+
bus.on("search", async (query) => {
|
|
58
|
+
const items = await api.search(query);
|
|
59
|
+
bus.emit("results", items);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
bus.emit("search", "abc");
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The second step fixes the race. Wrap the handler in `latest` and pass the signal to the request:
|
|
66
|
+
|
|
67
|
+
```js
|
|
68
|
+
import { createBus } from "nanoeffects";
|
|
69
|
+
import { latest } from "nanoeffects/policies";
|
|
70
|
+
|
|
71
|
+
const bus = createBus(); // event bus; each handler call is a task
|
|
72
|
+
|
|
73
|
+
// latest: a newer "search" aborts the older run
|
|
74
|
+
bus.on(
|
|
75
|
+
"search",
|
|
76
|
+
latest(async (query, { signal }) => {
|
|
77
|
+
const items = await api.search(query, { signal }); // signal cancels the request
|
|
78
|
+
bus.emit("results", items);
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
bus.emit("search", "abc");
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
More cases (pause, retry, timeout): [Cancel stale requests](cancel-stale-requests.md).
|
|
86
|
+
|
|
87
|
+
## Check the differences
|
|
88
|
+
|
|
89
|
+
| Topic | Emitter | nanoeffects |
|
|
90
|
+
| ----- | ------- | ----------- |
|
|
91
|
+
| Handler order | order of subscription | order of subscription |
|
|
92
|
+
| Error in a handler | depends on the library | the first error stops the rest and is thrown from `emit`; set `onError` to isolate handlers ([Errors](errors.md)) |
|
|
93
|
+
| Async handler rejects | usually an unhandled rejection | not handled without `onError`; set `onError` to catch it |
|
|
94
|
+
| Event without a listener | ignored | ignored; set `onUnhandled` to be told |
|
|
95
|
+
| Types | event map in a type parameter | event map in a type parameter ([TypeScript](typescript.md)) |
|
|
96
|
+
|
|
97
|
+
The exact rules of `emit`, abort and ordering: [Semantics](semantics.md).
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Why nanoeffects exists
|
|
2
|
+
|
|
3
|
+
Read this page if you build an SDK, a core library or a micro-frontend shell and wonder why you would pick a new event bus. It explains the constraints the library was made for. It does not teach the API: start with the [Guide](guide.md).
|
|
4
|
+
|
|
5
|
+
## The problem: a shared dependency is a shared risk
|
|
6
|
+
|
|
7
|
+
A shared library (an SDK, a core package, a platform shell) is loaded by many apps. Whatever it depends on, the apps depend on too. State and event libraries are the worst case, because they keep **internal state and identity** (a store, a context, a class). Two copies or two versions of them do not merge: they silently stop seeing each other.
|
|
8
|
+
|
|
9
|
+
Module Federation makes this visible. Each remote can bring its own copy unless every team agrees on a `singleton` and a version range. The [Nx guide on library versions](https://nx.dev/docs/kb/manage-library-versions-with-module-federation) lists the result: libraries with internal state conflict, and a missing method in one version breaks callers written for another.
|
|
10
|
+
|
|
11
|
+
Real cases from the libraries nanoeffects was compared with:
|
|
12
|
+
|
|
13
|
+
| Library | What goes wrong | Source |
|
|
14
|
+
| ------- | --------------- | ------ |
|
|
15
|
+
| Redux, React Redux | A shared component library uses `react-redux`. Every micro-frontend must use the same version of that library, or `react-redux` "breaks weirdly". | [react-redux#1949](https://github.com/reduxjs/react-redux/issues/1949) |
|
|
16
|
+
| Redux | A store is reachable only under a `Provider`. Each sub-app needs its own provider, and a second one must not be created when a module is injected. Reducers duplicated between the host and a module add to it. | [Redux on Module Federation](https://dev.to/manomano-tech-team/how-to-work-with-redux-on-module-federation-13gm) |
|
|
17
|
+
| Effector | A library that uses `effector-react/scope` and is bundled into another project gets two instances. Its `Provider` is not seen by `useStore` of the app: "no scope found". | [effector#727](https://github.com/effector/effector/issues/727) |
|
|
18
|
+
| RxJS | Two copies give two `Observable` classes. Types of one version do not fit the other, and operators added to one copy are missing on the other. The advice is "one version in `node_modules`". | [RxJS#2984](https://github.com/ReactiveX/rxjs/issues/2984), [neo4j-javascript-driver#978](https://github.com/neo4j/neo4j-javascript-driver/issues/978) |
|
|
19
|
+
|
|
20
|
+
A library that is **not** a shared singleton cannot break this way. nanoeffects has no dependencies and no module-level state: a bus is an object you create. Two copies, or two versions, of the package in one page are two independent buses. Nothing is registered globally, no `Provider` is needed, and the host app does not have to pick a version for you.
|
|
21
|
+
|
|
22
|
+
## Why not the alternatives
|
|
23
|
+
|
|
24
|
+
| Option | Why it did not fit |
|
|
25
|
+
| ------ | ------------------ |
|
|
26
|
+
| Redux (with sagas) | A store and a provider shared by every consumer, which is the risk above. `redux-saga` also has no cheap way to get an answer back from an event: effects talk through the store. |
|
|
27
|
+
| Effector | A graph with global identity. A version or scope mismatch between the SDK and the app reaches the whole infrastructure. |
|
|
28
|
+
| Zustand | Good state, but it is a store, not a way to run and cancel work. It is the natural place to hold state next to nanoeffects: see [Stores](stores.md). |
|
|
29
|
+
| RxJS | Flexible, but learning its ecosystem (operators, subjects, schedulers) is a high entry cost for every developer who touches the SDK. The package is also not small: [Bundlephobia](https://bundlephobia.com/package/rxjs) reports 17.3 kB minified and gzipped for 7.8.2. |
|
|
30
|
+
|
|
31
|
+
## What the library does instead
|
|
32
|
+
|
|
33
|
+
- **No dependencies, no global state.** It is safe to ship inside an SDK next to whatever the host app runs. It does not need a `singleton` entry in the Module Federation config.
|
|
34
|
+
- **Small enough for a strict budget.** SDKs often get a few kilobytes for all third-party code. `createBus` is about 1.4 kB gzip (1.3 kB brotli); policies, effects and commands are separate entries you import only when you need them. See [Package entries](package.md).
|
|
35
|
+
- **Simple first.** `on` and `emit` are the whole model. Cancellation, retry and the rest are plain functions on top. The [Guide](guide.md) and [Coming from redux-saga](migrating-from-saga.md) map the familiar names.
|
|
36
|
+
- **`emit` can return an answer.** A [command](actions.md) is an event whose handler returns a result, so one module can ask another and wait for the reply. Events alone cannot do that, and it was the main thing missing from the Redux-based options.
|
|
37
|
+
- **Easy to debug.** Event-driven code is hard to follow: you see an effect and not the event that caused it. Every task knows its parent, so [tracing](tracing.md) shows the chain of events, with loggers for the console, the Performance panel and Redux DevTools.
|
|
38
|
+
|
|
39
|
+
## When this is not the right tool
|
|
40
|
+
|
|
41
|
+
nanoeffects holds no state. If you need a store, keep Redux, Zustand or Effector and let the bus run the async work: [Stores](stores.md). If you already have streams everywhere, keep RxJS: [Use nanoeffects next to RxJS](migrating-from-rxjs.md).
|