nanoeffects 0.8.0 → 0.10.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 (51) hide show
  1. package/README.md +2 -2
  2. package/dist/actions.d.ts +38 -22
  3. package/dist/actions.js +1 -1
  4. package/dist/bus-CkTHUAvq.js +1 -0
  5. package/dist/context-COgyNlrC.js +1 -0
  6. package/dist/effects.js +1 -1
  7. package/dist/index.js +1 -1
  8. package/dist/lifecycle-key-8iX8RUVD.js +1 -0
  9. package/dist/notrace/actions.d.ts +38 -22
  10. package/dist/notrace/actions.js +1 -1
  11. package/dist/notrace/bus-BmCTo8BG.js +1 -0
  12. package/dist/notrace/context-yYU3_9lq.js +1 -0
  13. package/dist/notrace/{delay-B_W1tnkS.js → delay-BosJKFjg.js} +1 -1
  14. package/dist/notrace/effects.js +1 -1
  15. package/dist/notrace/index.js +1 -1
  16. package/dist/notrace/lifecycle-key-8iX8RUVD.js +1 -0
  17. package/dist/notrace/policies.d.ts +30 -2
  18. package/dist/notrace/policies.js +1 -1
  19. package/dist/oop.js +1 -1
  20. package/dist/policies.d.ts +2 -1
  21. package/dist/policies.js +1 -1
  22. package/dist/react.d.ts +33 -1
  23. package/dist/react.js +1 -1
  24. package/dist/scope.js +1 -1
  25. package/dist/signal.d.ts +51 -0
  26. package/dist/signal.js +1 -0
  27. package/dist/with-policy-CqUS-VWW.d.ts +30 -0
  28. package/dist/with-policy-DBV9dh2F.js +1 -0
  29. package/docs/README.md +6 -0
  30. package/docs/actions.md +80 -4
  31. package/docs/cancel-stale-requests.md +111 -0
  32. package/docs/debounce-and-abort.md +106 -0
  33. package/docs/guide.md +1 -1
  34. package/docs/migrating-from-effector.md +2 -2
  35. package/docs/migrating-from-mitt.md +97 -0
  36. package/docs/motivation.md +41 -0
  37. package/docs/observable.md +124 -10
  38. package/docs/package.md +4 -2
  39. package/docs/plugins.md +20 -0
  40. package/docs/policies.md +33 -0
  41. package/docs/prevent-double-submit.md +128 -0
  42. package/docs/query-libraries.md +124 -0
  43. package/docs/react.md +44 -0
  44. package/llms-full.txt +944 -24
  45. package/llms.txt +9 -3
  46. package/package.json +25 -4
  47. package/skills/nanoeffects/SKILL.md +52 -0
  48. package/dist/bus-BAs9x7IQ.js +0 -1
  49. package/dist/context-CK0ZomWt.js +0 -1
  50. package/dist/notrace/bus-Bdk0YyFQ.js +0 -1
  51. package/dist/notrace/context-CctVmHLC.js +0 -1
@@ -1,24 +1,138 @@
1
1
  # Turn events into Observables
2
2
 
3
- Use this page only when you already work in Observables / RxJS. Prefer `bus.on` with policies for cancellation and tracing.
3
+ Use this page when your app already works with Observables or RxJS and you want a bus event as a stream. If you start from scratch, prefer `bus.on` with policies: they cancel work and show up in traces.
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
+ ## Turn an event into a stream
8
+
9
+ Problem: a search box emits `typed` on every key. You want to wait for a pause and skip repeats with operators you already know.
10
+
7
11
  <!-- docs-check: skip - needs rxjs, which the project does not depend on -->
8
12
  ```js
9
- import { from } from "rxjs";
10
- import { debounceTime, distinctUntilChanged, switchMap } from "rxjs/operators";
13
+ import { createBus } from "nanoeffects";
11
14
  import { createObserve } from "nanoeffects/observable";
15
+ import { from } from "rxjs";
16
+ import { debounceTime, distinctUntilChanged } from "rxjs/operators";
12
17
 
13
- const observe = createObserve(bus); // Observable per event name
18
+ const bus = createBus(); // the bus: events and handlers
19
+ const observe = createObserve(bus); // observe(event) gives an Observable per event name
14
20
 
15
- from(observe("typed"))
16
- .pipe(debounceTime(300), distinctUntilChanged(), switchMap(searchRequest))
17
- .subscribe((items) => bus.emit("results", items));
21
+ const typed$ = from(observe("typed")); // RxJS wraps it with from()
22
+
23
+ const subscription = typed$
24
+ .pipe(debounceTime(300), distinctUntilChanged())
25
+ .subscribe((query) => bus.emit("search", query)); // back into the bus
18
26
  ```
19
27
 
20
28
  - `observe(event)` returns an Observable of the payloads of that event. Each `subscribe` is a `bus.on` of its own; `unsubscribe()` removes it.
21
29
  - `subscribe` takes a function or an object with `next`.
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.
30
+ - Types follow the event map: `observe("typed")` is an `Observable<string>` when `typed` carries a string.
31
+
32
+ ## Send a stream into the bus
33
+
34
+ Problem: `.subscribe((value) => bus.emit(action(value)))` repeats the bus and the action in every subscription. `bus.emitter(action)` from `emitterPlugin` returns that callback, so it is the argument of `subscribe`. It works with any source that takes a `next` function: an RxJS stream, a store callback, a DOM listener.
35
+
36
+ ```js
37
+ // bus.js
38
+ import { createBus } from "nanoeffects";
39
+ import { emitterPlugin } from "nanoeffects/actions";
40
+
41
+ export const bus = createBus().use(emitterPlugin()); // adds bus.emitter
42
+ ```
43
+
44
+ ```js
45
+ // search.js
46
+ import { createAction } from "nanoeffects/actions";
47
+
48
+ export const queryChanged = createAction("search/queryChanged"); // a plain action creator
49
+ ```
50
+
51
+ <!-- docs-check: skip - needs rxjs, which the project does not depend on -->
52
+ ```js
53
+ import { fromEvent } from "rxjs";
54
+ import { debounceTime, distinctUntilChanged, map } from "rxjs/operators";
55
+ import { bus } from "./bus";
56
+ import { queryChanged } from "./search";
57
+
58
+ const input = document.querySelector("#search"); // the search box
59
+
60
+ const subscription = fromEvent(input, "input")
61
+ .pipe(
62
+ map((event) => event.target.value),
63
+ debounceTime(300),
64
+ distinctUntilChanged(),
65
+ )
66
+ .subscribe(bus.emitter(queryChanged)); // calls bus.emit(queryChanged(value))
67
+
68
+ bus.on(queryChanged, (query) => console.log("search for", query));
69
+ ```
70
+
71
+ The Observable can come from anywhere, not only from `createObserve`. `subscription.unsubscribe()` stops it; the bus does not know about it. Pass the callback to `subscribe` as it is, and do not call `bus.emitter(...)` inside the operators on every value.
72
+
73
+ ## Do the work in a handler, not in `switchMap`
74
+
75
+ An Observable is good for shaping values. A request is better in a handler with a policy, because the handler has a `signal` and the trace shows it. Split it: RxJS ends at the value, the bus starts the work.
76
+
77
+ <!-- docs-check: skip - needs rxjs, which the project does not depend on -->
78
+ ```js
79
+ import { latest } from "nanoeffects/policies";
80
+
81
+ // RxJS: shape the values (debounce, skip repeats), then hand them to the bus
82
+ from(observe("typed"))
83
+ .pipe(debounceTime(300), distinctUntilChanged())
84
+ .subscribe((query) => bus.emit("search", query));
85
+
86
+ // nanoeffects: one cancellable request; a newer "search" aborts the old one
87
+ bus.on(
88
+ "search",
89
+ latest(async (query, { signal }) => {
90
+ const items = await api.search(query, { signal });
91
+ bus.emit("results", items);
92
+ }),
93
+ );
94
+ ```
95
+
96
+ `switchMap` drops the old inner stream. `latest` aborts the old task through `signal`, so a `fetch` that got the signal really stops.
97
+
98
+ ## Convert back to a stream or a promise
99
+
100
+ | You need | Write |
101
+ | -------- | ----- |
102
+ | A stream of events, with operators | `from(observe("event"))` |
103
+ | The next event as a promise | `firstValueFrom(from(observe("event")))`; inside a task `take(ctx, "event")` does the same and respects abort |
104
+ | The events of a bus as an RxJS `Observable` class (for `instanceof` or `pipe` as a method) | `new Observable((subscriber) => observe("event").subscribe(subscriber))` |
105
+ | A task's abort as a stream | `fromEvent(ctx.signal, "abort")`, for `takeUntil` |
106
+ | The latest value to new subscribers | `from(observe("event")).pipe(shareReplay(1))`; the Observable replays nothing by itself |
107
+
108
+ Events that fired before `subscribe` are lost: the Observable is hot, like `bus.on`. Add `startWith(initial)` or `shareReplay` when a late subscriber needs a value.
109
+
110
+ ## End the subscription
111
+
112
+ A subscription from `observe` is not tied to a task. Nothing ends it for you. Pick one:
113
+
114
+ <!-- docs-check: skip - needs rxjs, which the project does not depend on -->
115
+ ```js
116
+ import { fromEvent, from } from "rxjs";
117
+ import { takeUntil } from "rxjs/operators";
118
+ import { onAbort } from "nanoeffects/effects";
119
+
120
+ bus.on("screen-opened", (_, ctx) => {
121
+ // takeUntil: stop the stream when the task aborts
122
+ from(observe("typed"))
123
+ .pipe(takeUntil(fromEvent(ctx.signal, "abort")))
124
+ .subscribe((query) => bus.emit("search", query));
125
+
126
+ // or: onAbort calls unsubscribe for you
127
+ const subscription = observe("scrolled").subscribe(updatePosition);
128
+ onAbort(ctx, subscription.unsubscribe);
129
+ });
130
+ ```
131
+
132
+ Outside a task, call `subscription.unsubscribe()` yourself when the screen goes away, or use the signal of a module.
133
+
134
+ ## Know the limits
135
+
136
+ - The stream never completes and never fails: events are the only thing that arrives. An error thrown by your subscriber is yours; the bus handles it only if `onError` is set ([Errors](errors.md)).
137
+ - `observe` reads the bus when you call `subscribe`, not when you create it. Create it once, call it as often as needed.
138
+ - Operators that keep state (`scan`, `buffer`, `window`) live in RxJS. The bus keeps no values. For the full split of work see [Use nanoeffects next to RxJS](migrating-from-rxjs.md).
package/docs/package.md CHANGED
@@ -9,6 +9,7 @@ Imports are split so you only load what you use. `createBus` stays small; the re
9
9
  | `nanoeffects/effects` | `retry`, `timeout`, `poll`, `delay`, `take`, `all`, `allSettled`, `race`, `effectsPlugin` |
10
10
  | `nanoeffects/actions` | `createAction`, `createCommand`, `createJob`, `createModule`, `modulePlugin`, `commandsPlugin` |
11
11
  | `nanoeffects/match` | `matchPlugin`: `on`, `once` and `off` that take a function which picks the events |
12
+ | `nanoeffects/signal` | `signalPlugin`: `emit(call, { signal })` ties the tasks of an emit to an outside `AbortSignal` |
12
13
  | `nanoeffects/oop` | `extendBus`, `WithBus`, `On`, `Once`, `disposeBus`: the methods of a bus on a class, with the subscriptions of each instance ended by `dispose()` |
13
14
  | `nanoeffects/scope` | `scopePlugin`, `effect`: fields for one command, action, job, module or `emit` (the types `Scope`, `Effect`) |
14
15
  | `nanoeffects/redux` | `createEffectsMiddleware` |
@@ -21,9 +22,10 @@ The `...Plugin` names above are used with `bus.use`. [Plugins](plugins.md) lists
21
22
 
22
23
  ## Give the docs to an AI assistant
23
24
 
24
- The package ships two plain-text files for assistants that write code with it:
25
+ The package ships plain-text files for assistants that write code with it:
25
26
 
26
27
  - `llms.txt`: a short index. It states the rules that generated code often gets wrong and links to every page of these docs.
27
28
  - `llms-full.txt`: the same summary followed by the text of every linked page, in one file.
29
+ - `skills/nanoeffects/SKILL.md`: a skill for assistants that support skills (for example Claude Code). It says when to suggest nanoeffects, when not to, and the same rules. Copy the folder to `.claude/skills/` in your project or to `~/.claude/skills/`.
28
30
 
29
- 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.
31
+ Find them in `node_modules/nanoeffects/`, or load `llms-full.txt` from a CDN such as `https://unpkg.com/nanoeffects/llms-full.txt`. All match the installed version.
package/docs/plugins.md CHANGED
@@ -92,6 +92,7 @@ Add one of these when you meet its need. None of them is required.
92
92
  | Plugin | What it gives you | Import | `bus.use` returns |
93
93
  | ------ | ----------------- | ------ | ----------------- |
94
94
  | [`scopePlugin`](#give-one-command-its-own-fields-scopeplugin) | Its own fields for one command, action, job or module, and a way to replace them in one `emit`. | `nanoeffects/scope` | The same bus |
95
+ | [`signalPlugin`](#tie-an-emit-to-an-outside-signal-signalplugin) | `emit(call, { signal })` that aborts its tasks when an outside signal aborts. | `nanoeffects/signal` | The same bus, typed with the new `emit` |
95
96
  | [`matchPlugin`](#listen-to-a-group-of-events-matchplugin) | `on` that listens to a group of events which a function picks. | `nanoeffects/match` | The same bus, typed with the new overloads |
96
97
  | [`emitterPlugin`](#hand-the-bus-to-a-state-manager-emitterplugin) | A callback that emits an action, to give to a state manager. | `nanoeffects/actions` | The same bus, with `emitter` |
97
98
  | [`reactPlugin`](#tell-the-bus-when-a-react-value-changes-reactplugin) | A React hook that emits when a value of a component changes. | `nanoeffects/react` | The same bus, with `useEmitter` |
@@ -118,6 +119,25 @@ const fakeApi = { getUser: async (id) => ({ id, name: "test" }) };
118
119
  bus.emit(getUser("1"), { api: fakeApi }); // a fake api for this call only
119
120
  ```
120
121
 
122
+ ### Tie an emit to an outside signal: `signalPlugin`
123
+
124
+ A library such as React Query owns the request and gives you an `AbortSignal`. `emit` takes it as `{ signal }` and aborts the tasks it starts when the signal aborts, so `timeout`, `retry` and the request stop too. More in [Use nanoeffects with React Query and SWR](query-libraries.md).
125
+
126
+ ```js
127
+ import { createBus } from "nanoeffects";
128
+ import { createCommand } from "nanoeffects/actions";
129
+ import { signalPlugin } from "nanoeffects/signal";
130
+
131
+ // signalPlugin — emit takes { signal } for a command, a job or an action
132
+ const bus = createBus().use(signalPlugin());
133
+
134
+ const getUser = createCommand("user/get", (id, { signal }) => api.getUser(id, { signal }));
135
+
136
+ const controller = new AbortController();
137
+ bus.emit(getUser("1"), { signal: controller.signal }); // runs the command
138
+ controller.abort(); // ctx.signal of the handler aborts with it
139
+ ```
140
+
121
141
  ### Listen to a group of events: `matchPlugin`
122
142
 
123
143
  `on`, `once` and `off` take a function that picks the events, such as every `user/*` event. A type guard narrows the payload. The subscription listens to every event, so for a few known events `on(creator, handler)` is cheaper. More in the [Guide](guide.md#pick-events-with-a-function).
package/docs/policies.md CHANGED
@@ -114,3 +114,36 @@ Every call returns a promise with the result of its own run. An error rejects on
114
114
  - A waiting call whose task aborts is skipped and resolves with `undefined`.
115
115
  - When a running call aborts, its slot is freed at once and its result is dropped.
116
116
  - Pass `signal` into what `fn` does. A `fn` that ignores it can overlap with the next run.
117
+
118
+ ## Use a policy without a bus
119
+
120
+ **Problem.** You want a debounced input handler or a "latest request wins" fetch, but your code has no bus. A policy needs a context on every call, so `debounce(300, search)("re")` does not work on its own.
121
+
122
+ **When.** One component or module needs a policy and nothing else from the library. If you already have a bus, `bus.on(event, policy)` is simpler.
123
+
124
+ `withPolicy` takes the result of any policy and returns a plain function. It needs no bus and no context argument, and it keeps the state of the policy between calls.
125
+
126
+ ```js
127
+ import { debounce, withPolicy } from "nanoeffects/policies";
128
+
129
+ // debounce — waits for 300 ms of quiet, then runs the callback
130
+ const policy = debounce(300, async (query, { signal }) => {
131
+ await api.search(query, { signal });
132
+ });
133
+
134
+ // withPolicy — a plain function: no bus, no context argument
135
+ const onChange = withPolicy(policy);
136
+
137
+ onChange("re");
138
+ onChange("redux"); // only this call runs, 300 ms after it
139
+
140
+ // cancel — drops a pending call and aborts a running one
141
+ onChange.cancel();
142
+ ```
143
+
144
+ - The callback has the same `(payload, ctx)` shape as for a bus. `ctx.signal`, `ctx.abort` and `ctx.run` work. `ctx.emit` and `ctx.on` do nothing, because there is no bus.
145
+ - Every call returns a promise with the result. A call the policy drops resolves with `undefined`.
146
+ - `cancel()` aborts every call that has not finished. Calls made after it work again.
147
+ - Where `Symbol.dispose` exists, `using onChange = withPolicy(policy)` calls `cancel()` at the end of the scope.
148
+
149
+ In a React component, use [`usePolicy`](react.md#keep-a-debounced-handler-between-renders) instead: it keeps the function between renders.
@@ -0,0 +1,128 @@
1
+ # Prevent a double submit
2
+
3
+ Read this page if a second click on "Pay", "Sign up" or "Save" sends the request twice. It shows how to ignore the second click while the first request runs, how to do it per item, and when to queue the calls instead.
4
+
5
+ ## See the problem
6
+
7
+ A user clicks twice. Both clicks run the handler, and the server gets two requests.
8
+
9
+ ```js
10
+ import { createBus } from "nanoeffects";
11
+
12
+ const bus = createBus(); // the bus: events and handlers
13
+
14
+ // Without a policy every "submitted" starts its own request.
15
+ bus.on("submitted", async (form) => {
16
+ const order = await api.pay(form);
17
+ bus.emit("paid", order);
18
+ });
19
+ ```
20
+
21
+ A `disabled` button helps only until the next render. A flag in a variable (`if (busy) return`) must be reset on every error path and after an abort.
22
+
23
+ ## Ignore clicks while a request runs
24
+
25
+ Wrap the handler in `leading`. While one run is going, new calls are dropped. When the run ends, the next click works.
26
+
27
+ ```js
28
+ import { createBus } from "nanoeffects";
29
+ import { leading } from "nanoeffects/policies";
30
+
31
+ const bus = createBus(); // the bus: events and handlers
32
+
33
+ // leading: a call is dropped while a previous run is still going
34
+ bus.on(
35
+ "submitted",
36
+ leading(async (form, ctx) => {
37
+ const order = await api.pay(form, { signal: ctx.signal });
38
+ ctx.emit("paid", order);
39
+ }),
40
+ );
41
+ ```
42
+
43
+ What to replace: `api.pay` is your own request function.
44
+
45
+ Result: `emit("submitted", form)` twice in a row sends one request. The second call returns at once and does nothing.
46
+
47
+ `leading` frees its lock when the task ends or aborts. If the handler throws, the next click works again, with no flag to reset.
48
+
49
+ ## Allow one run per item
50
+
51
+ A list of rows, each with its own "Delete" button, should not block the whole list. Give each item its own state with `key`.
52
+
53
+ ```js
54
+ import { createBus } from "nanoeffects";
55
+ import { leading } from "nanoeffects/policies";
56
+
57
+ const bus = createBus(); // the bus: events and handlers
58
+
59
+ // leading with key: one running call per row id, other rows are not blocked
60
+ bus.on(
61
+ "row/deleted",
62
+ leading(
63
+ async (row, ctx) => {
64
+ await api.deleteRow(row.id, { signal: ctx.signal });
65
+ ctx.emit("row/removed", row.id);
66
+ },
67
+ { key: (row) => row.id },
68
+ ),
69
+ );
70
+ ```
71
+
72
+ ## Retry only when the network failed
73
+
74
+ A retry after a failed payment can charge twice if the server did run the first request. Retry only on errors where the request did not reach the server, and make the server accept an idempotency key.
75
+
76
+ ```js
77
+ import { createBus } from "nanoeffects";
78
+ import { leading } from "nanoeffects/policies";
79
+ import { retry } from "nanoeffects/effects";
80
+
81
+ const bus = createBus(); // the bus: events and handlers
82
+
83
+ // fetch rejects with a TypeError when the network fails; an HTTP error is a plain Error
84
+ const isNetworkError = (error) => error instanceof TypeError;
85
+
86
+ bus.on(
87
+ "submitted",
88
+ leading(async (form, ctx) => {
89
+ const pay = () => api.pay(form, { signal: ctx.signal });
90
+ // retry: up to 3 tries, 500 ms apart, only when the network failed
91
+ const order = await retry(ctx, pay, { times: 3, delay: 500, shouldRetry: isNetworkError });
92
+ ctx.emit("paid", order);
93
+ }),
94
+ );
95
+ ```
96
+
97
+ Backoff between tries: [Back off between retries](effects.md#back-off-between-retries).
98
+
99
+ ## Guard a handler when you have no bus
100
+
101
+ `withPolicy` turns a policy into a plain function, so a click handler needs no bus.
102
+
103
+ ```js
104
+ import { leading, withPolicy } from "nanoeffects/policies";
105
+
106
+ // leading: ignore new calls while a run is going
107
+ const policy = leading(async (form, { signal }) => {
108
+ await api.save(form, { signal });
109
+ });
110
+
111
+ // withPolicy: a plain function to call from onSubmit
112
+ const submit = withPolicy(policy);
113
+
114
+ submit({ name: "Ada" }); // runs
115
+ submit({ name: "Ada" }); // ignored while the first runs; resolves with undefined
116
+ ```
117
+
118
+ In a React component use [`usePolicy`](react.md#keep-a-debounced-handler-between-renders).
119
+
120
+ ## Pick another policy when clicks must not be lost
121
+
122
+ | You want | Policy |
123
+ | -------- | ------ |
124
+ | Ignore a second click while the first runs (pay, sign up) | `leading` |
125
+ | Run every click, one after another (save a draft, upload files) | `queue` |
126
+ | Keep only the newest click and cancel the older (filters, tabs) | `latest` |
127
+
128
+ Related: [Policies](policies.md), [Build a sign-up form in React](react.md).
@@ -0,0 +1,124 @@
1
+ # Use nanoeffects with React Query and SWR
2
+
3
+ Read this page if you fetch data with [React Query](https://tanstack.com/query) (TanStack Query) or [SWR](https://swr.vercel.app) and want `retry`, `timeout` and the abort of nanoeffects inside the request. It shows one command that both libraries call.
4
+
5
+ You need a React project, `nanoeffects`, and one of the two libraries. `api` below is your own client: replace it with your requests.
6
+
7
+ ## Decide who owns what
8
+
9
+ The libraries and nanoeffects do different jobs, so each keeps its own.
10
+
11
+ | React Query or SWR | nanoeffects |
12
+ | ------------------ | ----------- |
13
+ | `isLoading`, `data`, `error` | `retry`, `timeout`, `latest` and other policies |
14
+ | the cache, one request for the same key | the abort that reaches the request |
15
+ | refetch on focus, on reconnect, on interval | the handler: the same code for a test, a worker and the UI |
16
+
17
+ `isLoading` and `data` come from the library, not from `emit`. `await bus.emit(...)` resolves once, with the result, so it cannot report that the request is still running.
18
+
19
+ ## Make the command
20
+
21
+ A command is the request itself. It lives in files that know nothing about React: the bus, the command with its handler, and the component.
22
+
23
+ ```js
24
+ // bus.js
25
+ import { createBus } from "nanoeffects";
26
+ import { signalPlugin } from "nanoeffects/signal";
27
+
28
+ // createBus — the bus the app shares
29
+ // signalPlugin — lets emit take { signal }, the signal of the library
30
+ export const bus = createBus().use(signalPlugin());
31
+ ```
32
+
33
+ The handler tries the request up to three times and gives up after five seconds for each try. `ctx.signal` is the signal of the task: it aborts when the library aborts the request.
34
+
35
+ ```js
36
+ // user.js
37
+ import { createCommand } from "nanoeffects/actions";
38
+ import { retry, timeout } from "nanoeffects/effects";
39
+
40
+ // createCommand — a command with a handler: emit returns what the handler returns
41
+ export const getUser = createCommand("user/get", async (id, ctx) => {
42
+ // one try: the request, with the signal of timeout so it can stop it
43
+ const load = ({ signal }) => api.getUser(id, { signal });
44
+ // timeout — fail the try after 5 s
45
+ const attempt = () => timeout(ctx, 5000, load);
46
+ // retry — up to 3 tries, 500 ms apart
47
+ return retry(ctx, attempt, { times: 3, delay: 500 });
48
+ });
49
+ ```
50
+
51
+ ## Call it from React Query
52
+
53
+ <!-- docs-check: continue -->
54
+
55
+ React Query gives `queryFn` a `signal` and aborts it when the query becomes inactive or out of date. Pass it to `emit`. The task is aborted with it, so `timeout` and `retry` stop, and so does the request.
56
+
57
+ ```jsx
58
+ // Profile.jsx
59
+ import { useQuery } from "@tanstack/react-query";
60
+ import { bus } from "./bus";
61
+ import { getUser } from "./user";
62
+
63
+ export function Profile({ id }) {
64
+ const { isLoading, data, error } = useQuery({
65
+ queryKey: ["user", id],
66
+ // emit — runs the command; { signal } ties its task to the signal of React Query
67
+ queryFn: ({ signal }) => bus.emit(getUser(id), { signal }),
68
+ });
69
+
70
+ if (isLoading) return <p>Loading…</p>;
71
+ if (error) return <p>{error.message}</p>;
72
+ return <p>{data.name}</p>;
73
+ }
74
+ ```
75
+
76
+ The command rejects when all tries fail, so React Query shows the `error`. React Query counts its own `retry` too: set `retry: false` in `useQuery` when the handler already retries, or the tries multiply.
77
+
78
+ ## Call it from SWR
79
+
80
+ <!-- docs-check: continue -->
81
+
82
+ SWR does not pass a signal to the fetcher, so there is nothing to give to `emit`. The command still brings `retry` and `timeout`.
83
+
84
+ ```jsx
85
+ // Profile.jsx
86
+ import useSWR from "swr";
87
+ import { bus } from "./bus";
88
+ import { getUser } from "./user";
89
+
90
+ // the fetcher gets the key (here the id) and returns a promise
91
+ const fetcher = (id) => bus.emit(getUser(id));
92
+
93
+ export function Profile({ id }) {
94
+ const { isLoading, data, error } = useSWR(id, fetcher);
95
+
96
+ if (isLoading) return <p>Loading…</p>;
97
+ if (error) return <p>{error.message}</p>;
98
+ return <p>{data.name}</p>;
99
+ }
100
+ ```
101
+
102
+ ## Let the bus know the result
103
+
104
+ <!-- docs-check: continue -->
105
+
106
+ A command gives its result only to the caller. If a store or another module must react to a loaded user, the handler can say so:
107
+
108
+ ```js
109
+ // user-events.js
110
+ import { createAction } from "nanoeffects/actions";
111
+
112
+ // createAction — an event with a payload
113
+ export const userLoaded = createAction("user/loaded");
114
+ ```
115
+
116
+ Send it from the handler with `ctx.emit(userLoaded(user))`. Do not replace the command with `createJob` here: a job handles the error itself and resolves with `undefined`, so React Query would never see the failure. Use a job when nothing waits for the result and the app shows a loading state from the bus, see [Show a loading state with a job](actions.md#show-a-loading-state-with-a-job).
117
+
118
+ ## Know the limits
119
+
120
+ - **Without the plugin, `{ signal }` is ignored.** The command runs, but the abort does not reach it. Call `bus.use(signalPlugin())` once, where the bus is made.
121
+ - **Only the call forms take it.** `bus.emit(getUser(id), { signal })` works; `bus.emit("user/get", id, { signal })` does not.
122
+ - **A handler that returns without a promise is finished with the call**, so a signal that aborts later does not reach it. Commands that fetch are async, so this only matters for a sync handler that sets up a callback.
123
+ - **Give each request its own signal for an event.** For a command or a job the link ends when it settles. For `bus.emit(action, { signal })` it stays until the signal aborts.
124
+ - **The signal reaches the tasks that this `emit` starts**, with the listeners of the command's type and the `ctx.run` children. A separate `bus.emit` that the handler makes is not linked: pass `{ signal: ctx.signal }` to it.
package/docs/react.md CHANGED
@@ -162,8 +162,52 @@ const checkName = latest(async (name, ctx) => {
162
162
 
163
163
  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.
164
164
 
165
+ ## Keep a debounced handler between renders
166
+
167
+ **Problem.** A component runs again on every render. A policy made in its body, such as `debounce(300, search)`, is made again each time and loses its timer. A policy made once keeps the callback of the first render, which reads old props and state.
168
+
169
+ **When.** A handler in a component needs a policy, and you do not need a bus: a search box, an autosave, a submit that must not fire twice.
170
+
171
+ `usePolicy(callback, make, deps)` keeps one function between renders. It calls the `callback` of the latest render, so an inline callback sees fresh state. It cancels pending calls when the component unmounts.
172
+
173
+ ```jsx
174
+ import { useState } from "react";
175
+ import { debounce } from "nanoeffects/policies";
176
+ import { usePolicy } from "nanoeffects/react";
177
+
178
+ export const Search = ({ wait }) => {
179
+ const [items, setItems] = useState([]);
180
+
181
+ // usePolicy — a stable function; the first argument is the latest render's callback
182
+ const onChange = usePolicy(
183
+ async (query, { signal }) => {
184
+ const found = await api.search(query, { signal });
185
+ setItems(found);
186
+ },
187
+ // the second argument builds the policy once; task runs the callback above
188
+ (task) => debounce(wait, task),
189
+ // deps — list what the second argument reads; a new wait makes a new policy
190
+ [wait],
191
+ );
192
+
193
+ return (
194
+ <div>
195
+ <input onChange={(event) => onChange(event.target.value)} />
196
+ <p>{items.length} found</p>
197
+ </div>
198
+ );
199
+ };
200
+ ```
201
+
202
+ - `onChange` keeps its identity while `wait` stays the same, so it is safe in `useEffect` and in props.
203
+ - A pending call is cancelled on unmount and when `deps` change.
204
+ - Do not write `usePolicy(debounce(300, callback))`. The policy would be made on every render or keep a stale callback.
205
+ - Without a React component, use [`withPolicy`](policies.md#use-a-policy-without-a-bus).
206
+
165
207
  ## Where to go next
166
208
 
167
209
  - The policies behind this form: [Policies](policies.md).
210
+ - A policy as a plain function without a bus: [Use a policy without a bus](policies.md#use-a-policy-without-a-bus).
168
211
  - `retry`, `timeout` and the other effects: [Effects](effects.md).
169
212
  - A state library instead of `useState`: [Stores](stores.md).
213
+ - React Query or SWR instead of your own state: [Use nanoeffects with React Query and SWR](query-libraries.md).