nanoeffects 0.19.1 → 0.21.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/dist/{action-creator-HFA_LZOH.d.ts → action-creator-Bpa8BUS9.d.ts} +18 -7
  2. package/dist/actions.d.ts +3 -3
  3. package/dist/actions.js +1 -1
  4. package/dist/{context-types-BNcs43eY.d.ts → context-types-BKIH5a8s.d.ts} +1 -1
  5. package/dist/context.d.ts +2 -2
  6. package/dist/dev.d.ts +2 -2
  7. package/dist/dev.js +1 -1
  8. package/dist/effects.d.ts +4 -8
  9. package/dist/index.d.ts +2 -2
  10. package/dist/match.d.ts +1 -1
  11. package/dist/{module-DTf73Wdq.d.ts → module-B0KVDNjY.d.ts} +14 -9
  12. package/dist/mount-each-D54UqAhZ.js +1 -0
  13. package/dist/notrace/{action-creator-CbFf398i.d.ts → action-creator-D2CotugT.d.ts} +16 -7
  14. package/dist/notrace/actions.d.ts +14 -9
  15. package/dist/notrace/actions.js +1 -1
  16. package/dist/notrace/effects.d.ts +4 -8
  17. package/dist/notrace/index.d.ts +2 -2
  18. package/dist/notrace/{plugin-C5vIjxhR.d.ts → plugin-C6KA4dX-.d.ts} +1 -1
  19. package/dist/notrace/policies.d.ts +51 -6
  20. package/dist/notrace/policies.js +1 -1
  21. package/dist/notrace/redux.d.ts +1 -1
  22. package/dist/observable.d.ts +1 -1
  23. package/dist/oop.d.ts +1 -1
  24. package/dist/{plugin-Sy8z9IId.d.ts → plugin-D9UTzGRG.d.ts} +2 -2
  25. package/dist/policies.d.ts +52 -7
  26. package/dist/policies.js +1 -1
  27. package/dist/react.d.ts +9 -8
  28. package/dist/redux.d.ts +1 -1
  29. package/dist/scope.d.ts +4 -4
  30. package/dist/signal.d.ts +1 -1
  31. package/dist/state-of-C19H6BoV.d.ts +10 -0
  32. package/dist/trace.d.ts +1 -1
  33. package/dist/{types-DaZT158m.d.ts → types-CbdJKC2z.d.ts} +2 -2
  34. package/dist/vue.d.ts +72 -0
  35. package/dist/vue.js +1 -0
  36. package/dist/{with-policy-C_mX7S54.d.ts → with-policy-BeH8wCkX.d.ts} +1 -1
  37. package/docs/README.md +2 -0
  38. package/docs/actions.md +31 -1
  39. package/docs/chat-example.md +246 -0
  40. package/docs/migrating-from-rxjs.md +58 -1
  41. package/docs/package.md +1 -0
  42. package/docs/plugins.md +3 -3
  43. package/docs/policies.md +27 -0
  44. package/docs/query-libraries.md +2 -0
  45. package/docs/react.md +5 -3
  46. package/docs/starter.md +25 -1
  47. package/docs/typescript.md +14 -0
  48. package/docs/vue.md +96 -0
  49. package/llms-full.txt +517 -10
  50. package/llms.txt +4 -2
  51. package/package.json +25 -4
@@ -0,0 +1,246 @@
1
+ # Build a chat with domains, modules and a small server
2
+
3
+ Use this page when your app has several features that react to each other and you want to see where each piece of logic goes. The example is a chat with rooms, a history that loads, messages that can fail to send, a search across all rooms, and new messages that arrive on their own. A small Fastify server in the example answers every request, and it misbehaves on a known schedule, so each problem on this page really happens.
4
+
5
+ It is a level D example. Start with [Actions, commands, jobs and modules](actions.md) for the API, or with the `domains-ts` template in [Start a project from a template](starter.md#start-with-domains) for a smaller project.
6
+
7
+ The code is in [`examples/chat`](../examples/chat). Run it on [StackBlitz](https://stackblitz.com/github/nanoeffects/nanoeffects/tree/main/examples/chat), or on your machine:
8
+
9
+ ```bash
10
+ cd examples/chat
11
+ npm install
12
+ npm run dev
13
+ ```
14
+
15
+ `npm run dev` starts the server and Vite together. Start it with `CHAOS=off npm run dev` to turn the failures off.
16
+
17
+ ## Read how the server fails
18
+
19
+ The server keeps its data in memory. These rules are on purpose, and they are the same every run, so you can follow each section below by doing what the table says:
20
+
21
+ | What you do | What the server does | Section |
22
+ | ----------- | -------------------- | ------- |
23
+ | Select a room, then another before the first is shown | The room Random loads its history in 1.2 s, slower than the others. | [Load a history that must not race](#load-a-history-that-must-not-race) |
24
+ | Send messages | Every third send answers 503. | [Send a message that can fail](#send-a-message-that-can-fail) |
25
+ | Send `/fail hello` | Answers 400. | [Send a message that can fail](#send-a-message-that-can-fail) |
26
+ | Search a word that is in Random | Random answers after 2.5 s. | [Search where one room is slow](#search-where-one-room-is-slow) |
27
+ | Wait | A new message appears every 4 s. | [Poll for new messages](#poll-for-new-messages) |
28
+
29
+ Open the Network panel of the browser while you do this. A request that the page aborts shows as cancelled there, and the server prints `aborted: ...` for it.
30
+
31
+ ## See how the files are split
32
+
33
+ Each domain is a folder. The components only emit events and read values; they never call the server.
34
+
35
+ | Path | Job |
36
+ | ---- | --- |
37
+ | `src/bus.ts` | Creates the bus. It imports no events and no domain. |
38
+ | `src/app.ts` | Mounts the four domains. |
39
+ | `src/api.ts` | The client of the server: one function per request. |
40
+ | `server/` | The Fastify server and the script that starts it with Vite. |
41
+ | `src/domains/rooms/` | The list of rooms and the selected room. |
42
+ | `src/domains/messages/` | The history of the open room and sending a message. |
43
+ | `src/domains/search/` | Searching every room and opening a result. |
44
+ | `src/domains/incoming/` | New messages, asked for at an interval. |
45
+ | `src/ui/` | The components. |
46
+
47
+ A domain folder has its events (`rooms.ts`, which imports no bus), its work (`rooms-handlers.ts`), the state it keeps (`rooms-store.ts`) and a module (`rooms-module.ts`) that wires them. A domain reacts to another domain by listening to its events, never by importing its handlers or its store. Events and commands both live in the events file. An event (`createAction`) says that something happened, and any domain may listen: `rooms/selected` is heard by three. A command (`createCommand`) is an order with exactly one handler, as `search/openResult` and `messages/mark` are. The handler is given to `createCommand`, so the command works wherever it is called and can return a result, and the module binds it with `module.use` for as long as it is mounted. A job is a command that also sends its outcomes, but its handler is bound in the module (`module.handle`): a job usually runs under a policy, and the type of its outcomes is exact only when the handler is not part of the type.
48
+
49
+ ## Load a history that must not race
50
+
51
+ **Problem.** The reader clicks a slow room and then a fast one. If the slow answer arrives last, it shows the wrong room, and a hand-written loading flag stays `true` when a request is aborted.
52
+
53
+ **When.** A load starts from a user choice that can change before it ends.
54
+
55
+ A job sends `pending` when its handler starts and exactly one of `success`, `fail` and `cancel` when it ends. `latest` around the handler aborts the older load when a newer one starts, so only the newer result is delivered, and the older one sends `cancel`:
56
+
57
+ ```js
58
+ // messages.js
59
+ import { createJob } from "nanoeffects/actions";
60
+
61
+ // createJob — a command that also sends pending, success, fail and cancel
62
+ export const loadHistory = createJob("messages/loadHistory");
63
+ ```
64
+
65
+ ```js
66
+ // messages-handlers.js
67
+ import { latest } from "nanoeffects/policies";
68
+
69
+ // latest — a load for a newer room aborts the older one, and its result is never delivered
70
+ export const fetchHistory = latest((roomId, { signal }) => api.loadHistory(roomId, { signal }));
71
+ ```
72
+
73
+ The module gives the job its work and keeps the state from the outcomes, so an aborted load clears the flag too (see [Show a loading state with a job](actions.md#show-a-loading-state-with-a-job)):
74
+
75
+ ```js
76
+ // messages-module.js
77
+ import { createModule } from "nanoeffects/actions";
78
+ import { loadHistory } from "./messages";
79
+ import { fetchHistory } from "./messages-handlers";
80
+
81
+ export const messagesModule = createModule("messages", (module) => {
82
+ // handle — this module does the work of loadHistory while it is mounted
83
+ module.handle(loadHistory, fetchHistory);
84
+ module.on(loadHistory.pending, () => console.log("show the spinner"));
85
+ module.on(loadHistory.cancel, () => console.log("a newer load replaced it"));
86
+ });
87
+ ```
88
+
89
+ ## Send a message that can fail
90
+
91
+ **Problem.** A message must appear at once, go out in the order it was sent, be tried again after a failure, and show as failed if it never arrives. The outcome `fail` carries only the error, not the call, so the page cannot tell which message failed.
92
+
93
+ **When.** An action has a visible result that can fail and that must keep its order.
94
+
95
+ `queue` keeps the order, `retry` tries a failed call again, and the handler throws an error that carries the id of the message:
96
+
97
+ ```js
98
+ import { retry } from "nanoeffects/effects";
99
+ import { queue } from "nanoeffects/policies";
100
+
101
+ class SendFailed extends Error {
102
+ constructor(localId, cause) {
103
+ super(cause.message);
104
+ this.localId = localId;
105
+ }
106
+ }
107
+
108
+ // A 400 is an answer: asking again gives the same answer. A 503 or a lost connection may pass.
109
+ const isWorthRetrying = (error) => !(error.status < 500);
110
+
111
+ // retry — try a failed send again, up to 3 attempts, 200 ms apart, unless the answer was a "no"
112
+ const attempt = retry(
113
+ async (call, { signal }) => {
114
+ const message = await api.send(call.roomId, call.text, { signal });
115
+ return { localId: call.localId, message };
116
+ },
117
+ { times: 3, delay: 200, shouldRetry: isWorthRetrying },
118
+ );
119
+
120
+ // queue — messages go out one after another, in the order they were sent
121
+ export const deliver = queue(async (call, ctx) => {
122
+ try {
123
+ return await attempt(call, ctx);
124
+ } catch (error) {
125
+ throw new SendFailed(call.localId, error);
126
+ }
127
+ });
128
+ ```
129
+
130
+ The module adds the message on `pending`, marks it delivered on `success`, and marks the message named by the error as failed on `fail`. Send `/fail hello` to see a failure that is not tried again, and send a few messages to meet the 503 that is.
131
+
132
+ ## Search where one room is slow
133
+
134
+ **Problem.** A search asks every room. If it waits for all of them and one room is slow or broken, the reader sees nothing. If it fails on the first error, the rooms that answered are lost.
135
+
136
+ **When.** One action fans out to several sources and a partial answer is better than none.
137
+
138
+ `timeout` limits each request, and `allSettled` waits for every request to end, whether it succeeded or failed. The handler keeps the hits and the names of the rooms that did not answer:
139
+
140
+ ```js
141
+ import { allSettled, timeout } from "nanoeffects/effects";
142
+ import { debounce, latest } from "nanoeffects/policies";
143
+
144
+ const rooms = [{ id: "general", name: "General" }, { id: "random", name: "Random" }];
145
+
146
+ const searchRoom = (room, query) => ({ signal }) => api.searchRoom(room.id, query, { signal });
147
+
148
+ // timeout — a room that does not answer in 1.5 s fails with a timeout error
149
+ const askRoom = (room, query) => {
150
+ const request = searchRoom(room, query);
151
+ return (parent) => timeout(parent, 1500, request);
152
+ };
153
+
154
+ // debounce — wait for quiet; latest — a newer query aborts the older search
155
+ export const runSearch = debounce(
156
+ 300,
157
+ latest(async (query, ctx) => {
158
+ // allSettled — ask every room at once and wait for all of them, however each one ends
159
+ const outcomes = await allSettled(ctx, rooms.map((room) => askRoom(room, query)));
160
+ return outcomes.map((outcome, index) => ({ room: rooms[index], outcome }));
161
+ }),
162
+ );
163
+ ```
164
+
165
+ `all` would fail the whole search on the first slow room, which is the wrong behavior here. See [Effects](effects.md) for the others.
166
+
167
+ ## Wait for a room before marking a result
168
+
169
+ **Problem.** The reader opens a search result from another room. The message cannot be marked until that room's history has loaded, and the handler must not guess how long that takes.
170
+
171
+ **When.** A handler starts work in another domain and needs to continue after its outcome.
172
+
173
+ `take` waits for the next event of a name. Start it before the emit that causes the event, so nothing is missed:
174
+
175
+ ```js
176
+ import { createCommand } from "nanoeffects/actions";
177
+ import { take } from "nanoeffects/effects";
178
+
179
+ // createCommand — an order for the messages domain, with the handler that does it
180
+ const markMessage = createCommand("messages/mark", (messageId) => console.log("mark", messageId));
181
+
182
+ // the room that is open, kept from the rooms/selected event
183
+ let selectedRoom = "general";
184
+
185
+ export const goToResult = async (hit, ctx) => {
186
+ if (hit.roomId !== selectedRoom) {
187
+ // take — wait for the next event of that name; started before the emit so nothing is missed
188
+ const loaded = take(ctx, "messages/loadHistory/success");
189
+ ctx.emit("rooms/selected", hit.roomId);
190
+ await loaded;
191
+ }
192
+ // ctx.emit with a command — the messages domain marks the message
193
+ ctx.emit(markMessage(hit.id));
194
+ };
195
+ ```
196
+
197
+ ## Poll for new messages
198
+
199
+ **Problem.** New messages come from the server, and the page has to ask for them at an interval. The polling must stop when the feature is switched off, and one failed request must not stop it.
200
+
201
+ **When.** A feature has background work that lives as long as the feature does.
202
+
203
+ `poll` calls a function now and then at an interval, and ends when its context is aborted. A module starts the task with `spawn`, and unmounting the module aborts it:
204
+
205
+ ```js
206
+ import { createModule } from "nanoeffects/actions";
207
+ import { poll } from "nanoeffects/effects";
208
+
209
+ const watchUpdates = async (_, ctx) => {
210
+ // the number of the last update seen; the first answer only tells where the server is
211
+ let since;
212
+
213
+ const check = async () => {
214
+ try {
215
+ const updates = await api.loadUpdates(since, { signal: ctx.signal });
216
+ since = updates.seq;
217
+ for (const message of updates.messages) {
218
+ // ctx.emit — after an abort it delivers nothing
219
+ ctx.emit("incoming/received", message);
220
+ }
221
+ } catch {
222
+ // one failed request must not end the polling: the next tick asks again
223
+ }
224
+ };
225
+
226
+ // poll — call `check` now and then every 3 s; an abort of ctx (an unmounted module) ends it
227
+ await poll(ctx, check, 3000, { leading: true });
228
+ };
229
+
230
+ export const incomingModule = createModule("incoming", (module) => {
231
+ // spawn — a background task that lives as long as this module
232
+ module.spawn(watchUpdates);
233
+ });
234
+ ```
235
+
236
+ In the example a button calls `app.unmount(incomingModule)` and `app.mount(incomingModule)`, so you can watch the polling stop (the Network panel shows no more `updates` requests) and start.
237
+
238
+ ## Know where each value lives
239
+
240
+ Each domain keeps its state in a [Zustand](https://zustand.docs.pmnd.rs/) store that the module writes and a component reads with `useStore`. Each store lists its mutations, such as `startLoading(roomId)` and `markFailed(localId)`, and a module calls them with the data of an event: `module.on(loadHistory.pending, startLoading)`. A mutation that sets plain values gives `set` an object, as in `set({ searching: true })`. The store of the messages uses the `immer` middleware, so a mutation that changes a list can change it in place, as in `state.messages.push(message)`, and the store still gives React a new object. Nothing else writes the state. The list of rooms is the exception: it is read with one `useEventState` in a provider at the root. `useEventState` is not a store: an event that came before the provider mounted is lost, and a second component gets its own copy. See [Show the last value of an event as state](react.md#show-the-last-value-of-an-event-as-state).
241
+
242
+ ## Where to go next
243
+
244
+ - The API of commands, jobs and modules: [Actions, commands, jobs and modules](actions.md).
245
+ - Policies such as `latest` and `queue`: [Policies](policies.md). Effects such as `allSettled`, `take` and `poll`: [Effects](effects.md).
246
+ - A smaller project with two domains: [Start with domains](starter.md#start-with-domains).
@@ -1,6 +1,6 @@
1
1
  # Use nanoeffects next to RxJS
2
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.
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 a search box and a polling loop both ways, and lists what stays in RxJS.
4
4
 
5
5
  ## Split the work
6
6
 
@@ -82,6 +82,63 @@ input.addEventListener("input", (event) => bus.emit("typed", event.target.value)
82
82
 
83
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
84
 
85
+ ## Rewrite a polling loop
86
+
87
+ Check a job status every 5 seconds. Retry a failed check, and stop when the user leaves the screen.
88
+
89
+ The same job in RxJS: `timer` ticks, `exhaustMap` skips a tick while a check runs, `retry` repeats a failed check, `takeUntil` stops it.
90
+
91
+ <!-- docs-check: skip - needs rxjs, which the project does not depend on -->
92
+ ```js
93
+ // RxJS
94
+ import { timer } from "rxjs";
95
+ import { exhaustMap, retry, takeUntil } from "rxjs/operators";
96
+
97
+ timer(5000, 5000)
98
+ .pipe(
99
+ exhaustMap(() => api.statusStream().pipe(retry({ count: 3, delay: 500 }))),
100
+ takeUntil(leave$),
101
+ )
102
+ .subscribe((status) => render(status));
103
+ ```
104
+
105
+ The same job in nanoeffects. `poll` ticks, `retry` repeats one check, and `race` ends the loop on the first `"leave"` event:
106
+
107
+ ```js
108
+ import { createBus } from "nanoeffects";
109
+ import { poll, race, retry, take } from "nanoeffects/effects";
110
+
111
+ const bus = createBus(); // the bus: events and handlers
112
+
113
+ bus.on("watch", async (_, ctx) => {
114
+ // one check, up to 3 attempts, 500 ms between them
115
+ const checkStatus = async () => {
116
+ const status = await retry(ctx, () => api.status({ signal: ctx.signal }), {
117
+ times: 3,
118
+ delay: 500,
119
+ });
120
+ ctx.emit("status", status);
121
+ };
122
+
123
+ await race(ctx, {
124
+ // poll: a check every 5 s; waitForFn: the next pause starts after the check ends
125
+ polling: ({ signal }) => poll({ signal }, checkStatus, 5000, { waitForFn: true }),
126
+ // take: resolves at the next "leave", and race stops the polling
127
+ leave: ({ signal }) => take(ctx, "leave", { signal }),
128
+ });
129
+ });
130
+
131
+ bus.on("status", (status) => render(status));
132
+
133
+ bus.emit("watch"); // start
134
+ bus.emit("leave"); // stop
135
+ ```
136
+
137
+ Two things differ from the pipeline:
138
+
139
+ - `waitForFn: true` plays the role of `exhaustMap`: checks never overlap. Without it `poll` keeps a steady rate, and a slow check can overlap the next one.
140
+ - If all 3 attempts fail, the error stops `poll`, as an error ends an Observable. To keep polling after a failed check, catch it inside `checkStatus` and emit an `"error"` event.
141
+
85
142
  ## Use both in one app
86
143
 
87
144
  Keep RxJS where a stream is how you think, and let events cross to the bus in either direction.
package/docs/package.md CHANGED
@@ -16,6 +16,7 @@ Imports are split so you only load what you use. `createBus` stays small; the re
16
16
  | `nanoeffects/redux` | `createEffectsMiddleware` |
17
17
  | `nanoeffects/observable` | `createObserve`, `Subject` |
18
18
  | `nanoeffects/react` | `reactPlugin`: adds the `useEmitter` hook to a bus (needs React 18 or newer) |
19
+ | `nanoeffects/vue` | `vuePlugin`: adds `watchEmitter` and `useEventRef` to a bus (needs Vue 3.3 or newer) |
19
20
  | `nanoeffects/dev` | `devPlugin` (no-op in production) |
20
21
  | `nanoeffects/trace` | `createTracer`, the loggers and `tracePlugin` (names for the trace) |
21
22
 
package/docs/plugins.md CHANGED
@@ -82,7 +82,7 @@ import { modulePlugin } from "nanoeffects/actions";
82
82
 
83
83
  const bus = createBus();
84
84
 
85
- // modulePlugin — mounts userModule on the first use of the app or the bus, and returns the app
85
+ // modulePlugin — mounts userModule at the end of this block, or at the first use if that is earlier, and returns the app
86
86
  const app = bus.use(modulePlugin([userModule]));
87
87
 
88
88
  app.unmount(userModule); // its listeners, bound commands and tasks are gone
@@ -261,7 +261,7 @@ export const bus = createBus()
261
261
  // commandsPlugin — the registry of command handlers; keep it to bind handlers later
262
262
  export const commands = bus.use(commandsPlugin());
263
263
 
264
- // modulePlugin — mounts userModule on the first use; keep app to mount and unmount later
264
+ // modulePlugin — mounts userModule at the end of this block; keep app to mount and unmount later
265
265
  export const app = bus.use(modulePlugin([userModule]));
266
266
 
267
267
  // devPlugin — reports leaks and modules while you develop; keep disconnect to remove it
@@ -274,7 +274,7 @@ The plugins give the same result in any order. Use them as they read best, and p
274
274
 
275
275
  Two things happen because modules are mounted late:
276
276
 
277
- - **Modules start on the first use.** `modulePlugin([userModule])` mounts `userModule` when the app or the bus is first used: the first `bus.emit`, or the first call of `app.mount`, `app.unmount`, `app.isMounted`, `app.dispose` or the read of `app.commands`. By then every plugin is on the bus, so a module and the tasks it spawns have the fields, effects and scopes of the plugins you used after `modulePlugin`, and `m.on(fn, handler)` works with `matchPlugin`.
277
+ - **Modules start at the end of the block.** `modulePlugin([userModule])` mounts `userModule` when the synchronous block that made it has finished, or earlier if the app or the bus is used first: the first `bus.emit`, or the first call of `app.mount`, `app.unmount`, `app.isMounted`, `app.dispose` or the read of `app.commands`. By then every plugin used in the same block is on the bus, so a module and the tasks it spawns have the fields, effects and scopes of the plugins you used after `modulePlugin`, and `m.on(fn, handler)` works with `matchPlugin`. A plugin used after an `await` can come too late: use every plugin before the first `await`. With no module, `modulePlugin()` starts nothing until the first use.
278
278
  - **Listeners keep their place.** A `bus.on` after `bus.use(modulePlugin(...))` is held until the modules are mounted, then made, in the order you wrote them. The listeners of a module therefore stay ahead of the ones registered after the plugin, and behind the ones registered before it. The function `bus.on` returns works before and after.
279
279
 
280
280
  Other plugins do not need an order for another reason:
package/docs/policies.md CHANGED
@@ -36,6 +36,33 @@ Every policy accepts `key` in its options (see [Isolate state by key](#isolate-s
36
36
  | `throttle(ms, fn, options?)` | at most one run per `ms` |
37
37
  | `queue(fn, options?)` | one after another, in order |
38
38
 
39
+ ## Call a policy without its function
40
+
41
+ **Problem.** Some code builds the function for you and only asks which policy to use, as [`usePolicy`](react.md#keep-a-debounced-handler-between-renders) does. If you must pass your own function there, it is easy to pass the wrong one.
42
+
43
+ **When.** You pass a policy to `usePolicy`. With a bus, `withPolicy` or your own code, call the policy with its function as above.
44
+
45
+ Leave the function out and the policy returns a wrapper. The wrapper takes a function and gives back the same policy as the direct call:
46
+
47
+ ```js
48
+ import { debounce } from "nanoeffects/policies";
49
+
50
+ const runSearch = async (query) => {
51
+ await api.search(query);
52
+ };
53
+
54
+ // debounce(300) — no function yet: returns a wrapper
55
+ const wrap = debounce(300);
56
+
57
+ // the wrapper takes the function; same as debounce(300, runSearch)
58
+ const search = wrap(runSearch);
59
+ ```
60
+
61
+ - It works for `latest()`, `leading()`, `debounce(ms)`, `throttle(ms)` and `queue()`, with the options as the last argument: `queue({ concurrency: 2 })`, `debounce(300, { leading: true })`.
62
+ - Each time you apply a wrapper to a function, you get a new policy with its own state.
63
+ - To combine policies, nest them as before: `throttle(100, latest(fn))`.
64
+ - With `key`, annotate the payload of the key function in TypeScript: `latest({ key: (user: User) => user.id })`. Without the annotation the wrapper cannot know the payload type.
65
+
39
66
  ## Configure debounce and throttle
40
67
 
41
68
  `debounce` and `throttle` take `leading` and `trailing`. Behavior matches lodash (and patronum); tests compare random call sequences to lodash itself.
@@ -122,3 +122,5 @@ Send it from the handler with `ctx.emit(userLoaded(user))`. Do not replace the c
122
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
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
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.
125
+
126
+ A whole small app, with a search, a filter, pages and a detail panel, is in the [Rick and Morty example](../examples/rickandmorty).
package/docs/react.md CHANGED
@@ -248,8 +248,8 @@ export const Search = ({ wait }) => {
248
248
  const found = await api.search(query, { signal });
249
249
  setItems(found);
250
250
  },
251
- // the second argument builds the policy once; task runs the callback above
252
- (task) => debounce(wait, task),
251
+ // debounce(wait) — the second argument builds the policy once around the callback above
252
+ debounce(wait),
253
253
  // deps — list what the second argument reads; a new wait makes a new policy
254
254
  [wait],
255
255
  );
@@ -265,7 +265,9 @@ export const Search = ({ wait }) => {
265
265
 
266
266
  - `onChange` keeps its identity while `wait` stays the same, so it is safe in `useEffect` and in props.
267
267
  - A pending call is cancelled on unmount and when `deps` change.
268
- - Do not write `usePolicy(debounce(300, callback))`. The policy would be made on every render or keep a stale callback.
268
+ - Write the policy without its callback: `debounce(300)`, not `debounce(300, callback)`. `usePolicy` puts the callback in itself, always the one of the latest render.
269
+ - To combine policies, write a function of the task: `(task) => throttle(100, latest(task))`. Here `task` is the callback that `usePolicy` keeps current; pass it, not your own function.
270
+ - `deps` stay your job: `debounce(wait)` is made again on every render, but only the first one (and the one after `deps` change) is used.
269
271
  - Without a React component, use [`withPolicy`](policies.md#use-a-policy-without-a-bus).
270
272
 
271
273
  ## Where to go next
package/docs/starter.md CHANGED
@@ -47,6 +47,8 @@ Pass `--template` to skip the question. With npm, put `--` before the option.
47
47
  | `vanilla-ts` | The `vanilla` app in TypeScript | You want the compiler to check events and handlers |
48
48
  | `react-ts` | The `react` app in TypeScript | You build a React app in TypeScript |
49
49
  | `domains-ts` | Commands, a job and modules, with the logic split into domain folders and React as a thin layer | You build an app with more than one feature and want the logic off the UI |
50
+ | `vue-ts` | The `react-ts` app in Vue 3, with `watchEmitter` and `useEventRef` from `nanoeffects/vue` | You build a Vue 3 app |
51
+ | `svelte-ts` | The `react-ts` app in Svelte 5, with a Svelte store made from an event | You build a Svelte app |
50
52
 
51
53
  ```bash
52
54
  npm create nanoeffects@latest my-app -- --template react
@@ -60,6 +62,26 @@ The TypeScript templates are the same apps with the same files, written in `.ts`
60
62
  npm create nanoeffects@latest my-app -- --template react-ts
61
63
  ```
62
64
 
65
+ ### Start with Vue
66
+
67
+ `vue-ts` is the search app of `react-ts` in Vue 3 with `<script setup lang="ts">`. Its bus uses `vuePlugin` from `nanoeffects/vue`: `watchEmitter` sends the query to the bus when it changes, and `useEventRef` keeps the result of the search in a `ref`. Like `useEventState` in React, `useEventRef` is [not a store](vue.md#it-is-not-a-store): a value that several components share belongs in a store such as Pinia.
68
+
69
+ ```bash
70
+ npm create nanoeffects@latest my-app -- --template vue-ts
71
+ ```
72
+
73
+ `npm run build` type-checks the `.vue` files with `vue-tsc`, which needs TypeScript 5: the template depends on it.
74
+
75
+ ### Start with Svelte
76
+
77
+ `svelte-ts` is the search app of `react-ts` in Svelte 5. It needs no part of nanoeffects for Svelte: a Svelte store is any object with `subscribe`, so a small helper of the project, `src/event-store.ts`, makes a store from an event with `readable` and `bus.on`. In a component, `$found` reads it, and Svelte subscribes and unsubscribes for you. The query goes to the bus from the `oninput` handler, so nothing is emitted when the component mounts. The store listens only while something reads it, and is [not a store of the app](react.md#show-the-last-value-of-an-event-as-state): a value that several components share belongs in a store of your own.
78
+
79
+ ```bash
80
+ npm create nanoeffects@latest my-app -- --template svelte-ts
81
+ ```
82
+
83
+ `npm run build` type-checks the `.svelte` files with `svelte-check`, which needs TypeScript 5: the template depends on it.
84
+
63
85
  ### Start with domains
64
86
 
65
87
  `domains-ts` is not the search box of the other templates grown in TypeScript: it shows where logic goes when an app has several features. Each domain is a folder under `src/domains/`. It has the events of the domain, the work that handles them, and a module that mounts the work. The React components only emit events and read values.
@@ -76,11 +98,13 @@ npm create nanoeffects@latest my-app -- --template domains-ts
76
98
  | `src/domains/checkout/` | Placing an order: `checkout.ts` (a job), `checkout-handlers.ts` (`leading` and `retry`), `checkout-store.ts` (state written from the job's outcomes), `checkout-module.ts`. |
77
99
  | `src/ui/` | The components. None of them calls the fake server in `src/api.ts`. |
78
100
 
101
+ For a bigger example with the same layout, a chat with four domains and mocks, see [Build a chat with domains, modules and a small server](chat-example.md).
102
+
79
103
  The commands, jobs and modules themselves are explained in [Actions, commands, jobs and modules](actions.md). The template keeps found products in a Context provider with one `useEventState`, and the order state in a store of its own: `useEventState` is [not a store](react.md#show-the-last-value-of-an-event-as-state).
80
104
 
81
105
  ## Read the files
82
106
 
83
- Every template except `domains-ts` splits the code the way [the guide](guide.md) does: one job per file. In the TypeScript templates the files end in `.ts` (and `.tsx` for components).
107
+ Every template except `domains-ts` splits the code the way [the guide](guide.md) does: one job per file. In the TypeScript templates the files end in `.ts` (and `.tsx` for React components, `.vue` for Vue components, `.svelte` for Svelte components).
84
108
 
85
109
  | File | Job |
86
110
  | ---- | --- |
@@ -129,6 +129,20 @@ const user = bus.emit(getUser("1")); // Promise<User>
129
129
 
130
130
  `createModule` of the app only creates the module: it does not mount it, and it keeps no link to the bus. That is why the app in `bus.ts` starts without modules. `createModule` from `nanoeffects/actions` stays for a module made without a bus: there you write the event map yourself, `createModule<AppEvents>("user", …)`.
131
131
 
132
+ ## Type a function that takes the signal
133
+
134
+ A function that makes a request should take the `signal` of the task, so aborting the task cancels the request. `WithSignal` is the type for that parameter: an object with a `signal`. It is checker-only, like `WithContext`: it adds nothing to your bundle.
135
+
136
+ ```ts
137
+ import type { WithSignal } from "nanoeffects";
138
+
139
+ // WithSignal — { signal: AbortSignal }; a task context fits it, and so does { signal } on its own
140
+ const search = (query: string, { signal }: WithSignal) =>
141
+ fetch(`/search?q=${encodeURIComponent(query)}`, { signal });
142
+ ```
143
+
144
+ Pass `ctx` (or `{ signal }`) when you call it. The effects of `nanoeffects/effects` (`retry`, `timeout`, `all`) take an optional signal instead, because outside a task you may pass `{}`; that is a different type, and you do not write it.
145
+
132
146
  ## Keep the contract apart from the code
133
147
 
134
148
  When the file with the command must not import the code that implements it, make the command without a handler. Here you write the payload and the result once, as generics, and the handler goes to a module, where it needs no annotation:
package/docs/vue.md ADDED
@@ -0,0 +1,96 @@
1
+ # Use nanoeffects in a Vue app
2
+
3
+ Use this page when your app is in Vue 3 and a component has to talk to the bus: tell it that a value changed, and show the result of an event. `vuePlugin` gives you two functions for that. To see them in a running project, start from the `vue-ts` template in [Start a project from a template](starter.md#start-with-vue).
4
+
5
+ You need Vue 3.3 or newer and `nanoeffects` installed. If you do not know what the bus is, read the [Guide](guide.md) first.
6
+
7
+ ## Add the plugin to the bus
8
+
9
+ **Problem.** Without help, a component writes `watch(query, (value) => bus.emit(queryChanged(value)))` and its own subscription in `onMounted` and `onUnmounted`. The same few lines are copied into every component, and each copy forgets something: the cleanup, the equality check, or the name of the subscription in the trace.
10
+
11
+ **When.** A Vue component sends values to the bus, reads events from it, or both.
12
+
13
+ `vuePlugin()` adds two functions to the bus. Take them out of the bus once, in the file that makes it, and import them where you need them:
14
+
15
+ ```js
16
+ // bus.js
17
+ import { createBus } from "nanoeffects";
18
+ import { vuePlugin } from "nanoeffects/vue";
19
+
20
+ // createBus — the event bus, created once outside Vue
21
+ // vuePlugin — adds watchEmitter and useEventRef to the bus
22
+ export const bus = createBus().use(vuePlugin());
23
+ export const { watchEmitter, useEventRef } = bus; // free functions: they do not read `this`
24
+ ```
25
+
26
+ ## Tell the bus that a value changed
27
+
28
+ <!-- docs-check: continue -->
29
+
30
+ `watchEmitter(source, event)` watches a `ref` or a getter and emits `event` with the new value after it changes. It does not emit when it is called, and it stops when the component unmounts. It is `watch` that ends with an emit, so it follows the rules of `watch`: several changes in one tick emit once, with the last value, and a `ref` that holds an object that you change in place does not trigger it.
31
+
32
+ ```js
33
+ // search.js
34
+ import { createAction } from "nanoeffects/actions";
35
+
36
+ // createAction — an event with a name; calling it builds { type, payload }
37
+ export const queryChanged = createAction("search/queryChanged");
38
+ export const searchLoaded = createAction("search/loaded");
39
+ ```
40
+
41
+ ```js
42
+ // query-box.js
43
+ import { ref } from "vue";
44
+ import { watchEmitter } from "./bus";
45
+ import { queryChanged } from "./search";
46
+
47
+ export const setup = () => {
48
+ const query = ref("");
49
+
50
+ // watchEmitter — emits queryChanged(query) when query changes, never on mount
51
+ watchEmitter(query, queryChanged);
52
+
53
+ return { query };
54
+ };
55
+ ```
56
+
57
+ The third argument takes the options `equals`, `traceId` and `traceGroupId`:
58
+
59
+ | Option | Use it when |
60
+ | ------ | ----------- |
61
+ | `equals(previous, next)` | A new object with the same fields should not emit: `{ equals: (a, b) => a.id === b.id }`. |
62
+ | `traceId` | You want the subscription to have a name in the [trace](tracing.md#show-which-subscription-began-a-chain). |
63
+ | `traceGroupId` | You want to name the whole flow that the emit starts. |
64
+
65
+ `watchEmitter` returns the function that stops it early, for use outside a component.
66
+
67
+ ## Show the result of an event
68
+
69
+ <!-- docs-check: continue -->
70
+
71
+ `useEventRef(event, initial)` returns a `ref` that starts as `initial`, and that every `event` of the bus sets to its payload. It stops listening when the component unmounts. A payload that is a function is stored, not called.
72
+
73
+ ```js
74
+ // found-list.js
75
+ import { useEventRef } from "./bus";
76
+ import { searchLoaded } from "./search";
77
+
78
+ export const setup = () => {
79
+ // useEventRef — like ref([]), but searchLoaded sets the value
80
+ const found = useEventRef(searchLoaded, []);
81
+
82
+ return { found };
83
+ };
84
+ ```
85
+
86
+ In TypeScript the type of the `ref` comes from the event: with `createAction<string[]>("search/loaded")` it is `Ref<string[]>`, and for an event with no payload type it comes from `initial`.
87
+
88
+ ### It is not a store
89
+
90
+ `useEventRef` is state of one component. Each component that calls it keeps its own copy of the last payload, and the bus keeps no values, so an event that came before the call is lost. To share a value between components, or to keep it for components that mount later, keep it in a store such as Pinia and write to it from a handler of the event. Or call `useEventRef` once in a component at the root and pass the `ref` down with `provide` and `inject`.
91
+
92
+ ## Where to go next
93
+
94
+ - The same two jobs for React: [Build a sign-up form in React](react.md).
95
+ - A policy in a component (a debounced save): in `<script setup>` make it once with [`withPolicy`](policies.md#use-a-policy-without-a-bus); Vue runs the setup once, so it needs no hook.
96
+ - Keep values in Pinia: [Connect Redux, Zustand, MobX, or Pinia](stores.md).