nanoeffects 0.19.0 → 0.20.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 (47) 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 +1 -1
  20. package/dist/notrace/redux.d.ts +1 -1
  21. package/dist/observable.d.ts +1 -1
  22. package/dist/oop.d.ts +1 -1
  23. package/dist/{plugin-Sy8z9IId.d.ts → plugin-D9UTzGRG.d.ts} +2 -2
  24. package/dist/policies.d.ts +2 -2
  25. package/dist/react.d.ts +3 -4
  26. package/dist/redux.d.ts +1 -1
  27. package/dist/scope.d.ts +4 -4
  28. package/dist/signal.d.ts +1 -1
  29. package/dist/state-of-C19H6BoV.d.ts +10 -0
  30. package/dist/trace.d.ts +1 -1
  31. package/dist/{types-DaZT158m.d.ts → types-CbdJKC2z.d.ts} +2 -2
  32. package/dist/vue.d.ts +72 -0
  33. package/dist/vue.js +1 -0
  34. package/dist/{with-policy-C_mX7S54.d.ts → with-policy-BeH8wCkX.d.ts} +1 -1
  35. package/docs/README.md +2 -0
  36. package/docs/actions.md +31 -1
  37. package/docs/chat-example.md +241 -0
  38. package/docs/migrating-from-rxjs.md +58 -1
  39. package/docs/package.md +1 -0
  40. package/docs/plugins.md +3 -3
  41. package/docs/starter.md +14 -1
  42. package/docs/typescript.md +14 -0
  43. package/docs/vue.md +96 -0
  44. package/llms-full.txt +467 -7
  45. package/llms.txt +4 -2
  46. package/package.json +23 -3
  47. package/skills/nanoeffects/SKILL.md +12 -18
package/llms-full.txt CHANGED
@@ -9,7 +9,7 @@ Rules that generated code often gets wrong:
9
9
  - A handler gets the payload and a task context: `(payload, ctx)`. The context has `signal`, `emit`, `on`, `run`. Pass `ctx.signal` to `fetch`.
10
10
  - Effects take `ctx` as the first argument: `retry(ctx, fn, options)`, `timeout(ctx, ms, fn)`, `take(ctx, "event")`.
11
11
  - Policies wrap a handler: `bus.on("search", latest(async (query, ctx) => { ... }))`.
12
- - Each part is imported from its own path: `nanoeffects` (`createBus`), `nanoeffects/policies`, `nanoeffects/effects`, `nanoeffects/actions`, `nanoeffects/redux`, `nanoeffects/observable`, `nanoeffects/react` (`reactPlugin`, adds `bus.useEmitter` and `bus.useEventState`; `usePolicy`; needs React), `nanoeffects/trace` (`createTracer`, loggers, `tracePlugin`), `nanoeffects/dev`.
12
+ - Each part is imported from its own path: `nanoeffects` (`createBus`), `nanoeffects/policies`, `nanoeffects/effects`, `nanoeffects/actions`, `nanoeffects/redux`, `nanoeffects/observable`, `nanoeffects/react` (`reactPlugin`, adds `bus.useEmitter` and `bus.useEventState`; `usePolicy`; needs React), `nanoeffects/vue` (`vuePlugin`, adds `bus.watchEmitter` and `bus.useEventRef`; needs Vue), `nanoeffects/trace` (`createTracer`, loggers, `tracePlugin`), `nanoeffects/dev`.
13
13
  - `bus.emit(command(payload))` runs the command's handler and returns its result. Plain events return nothing.
14
14
 
15
15
  ---
@@ -552,6 +552,7 @@ Pass `--template` to skip the question. With npm, put `--` before the option.
552
552
  | `vanilla-ts` | The `vanilla` app in TypeScript | You want the compiler to check events and handlers |
553
553
  | `react-ts` | The `react` app in TypeScript | You build a React app in TypeScript |
554
554
  | `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 |
555
+ | `vue-ts` | The `react-ts` app in Vue 3, with `watchEmitter` and `useEventRef` from `nanoeffects/vue` | You build a Vue 3 app |
555
556
 
556
557
  ```bash
557
558
  npm create nanoeffects@latest my-app -- --template react
@@ -565,6 +566,16 @@ The TypeScript templates are the same apps with the same files, written in `.ts`
565
566
  npm create nanoeffects@latest my-app -- --template react-ts
566
567
  ```
567
568
 
569
+ ### Start with Vue
570
+
571
+ `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.
572
+
573
+ ```bash
574
+ npm create nanoeffects@latest my-app -- --template vue-ts
575
+ ```
576
+
577
+ `npm run build` type-checks the `.vue` files with `vue-tsc`, which needs TypeScript 5: the template depends on it.
578
+
568
579
  ### Start with domains
569
580
 
570
581
  `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.
@@ -581,11 +592,13 @@ npm create nanoeffects@latest my-app -- --template domains-ts
581
592
  | `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`. |
582
593
  | `src/ui/` | The components. None of them calls the fake server in `src/api.ts`. |
583
594
 
595
+ 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).
596
+
584
597
  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).
585
598
 
586
599
  ## Read the files
587
600
 
588
- 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).
601
+ 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).
589
602
 
590
603
  | File | Job |
591
604
  | ---- | --- |
@@ -668,6 +681,7 @@ Imports are split so you only load what you use. `createBus` stays small; the re
668
681
  | `nanoeffects/redux` | `createEffectsMiddleware` |
669
682
  | `nanoeffects/observable` | `createObserve`, `Subject` |
670
683
  | `nanoeffects/react` | `reactPlugin`: adds the `useEmitter` hook to a bus (needs React 18 or newer) |
684
+ | `nanoeffects/vue` | `vuePlugin`: adds `watchEmitter` and `useEventRef` to a bus (needs Vue 3.3 or newer) |
671
685
  | `nanoeffects/dev` | `devPlugin` (no-op in production) |
672
686
  | `nanoeffects/trace` | `createTracer`, the loggers and `tracePlugin` (names for the trace) |
673
687
 
@@ -770,7 +784,7 @@ import { modulePlugin } from "nanoeffects/actions";
770
784
 
771
785
  const bus = createBus();
772
786
 
773
- // modulePlugin — mounts userModule on the first use of the app or the bus, and returns the app
787
+ // modulePlugin — mounts userModule at the end of this block, or at the first use if that is earlier, and returns the app
774
788
  const app = bus.use(modulePlugin([userModule]));
775
789
 
776
790
  app.unmount(userModule); // its listeners, bound commands and tasks are gone
@@ -949,7 +963,7 @@ export const bus = createBus()
949
963
  // commandsPlugin — the registry of command handlers; keep it to bind handlers later
950
964
  export const commands = bus.use(commandsPlugin());
951
965
 
952
- // modulePlugin — mounts userModule on the first use; keep app to mount and unmount later
966
+ // modulePlugin — mounts userModule at the end of this block; keep app to mount and unmount later
953
967
  export const app = bus.use(modulePlugin([userModule]));
954
968
 
955
969
  // devPlugin — reports leaks and modules while you develop; keep disconnect to remove it
@@ -962,7 +976,7 @@ The plugins give the same result in any order. Use them as they read best, and p
962
976
 
963
977
  Two things happen because modules are mounted late:
964
978
 
965
- - **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`.
979
+ - **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.
966
980
  - **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.
967
981
 
968
982
  Other plugins do not need an order for another reason:
@@ -1882,6 +1896,8 @@ If you only need `on` / `emit` or policies, stay in the [Guide](guide.md) until
1882
1896
 
1883
1897
  Recipe without a UI framework: [`examples/modules.ts`](../examples/modules.ts).
1884
1898
 
1899
+ A chat with four domains, jobs under policies and effects, and a stream tied to a module: [Build a chat with domains, modules and a small server](chat-example.md).
1900
+
1885
1901
  A whole project with two domains, a job and React as a thin layer: `npm create nanoeffects@latest my-app -- --template domains-ts` (see [Start a project from a template](starter.md#start-with-domains)).
1886
1902
 
1887
1903
  ## Return a value from `emit` with a command
@@ -2128,11 +2144,39 @@ app.commands.handle(getUser, fakeUser); // in a test
2128
2144
  app.dispose(); // unmounts every module
2129
2145
  ```
2130
2146
 
2147
+ `mount` and `unmount` take any number of modules. Use it to start the domains of an app yourself, at the moment you choose and in the order you give, when you do not give them to `modulePlugin`:
2148
+
2149
+ ```js
2150
+ import { createModule } from "nanoeffects/actions";
2151
+ import { app } from "./bus";
2152
+
2153
+ const userModule = createModule("user", (m) => {
2154
+ m.on("user/loaded", (user) => console.log(user));
2155
+ });
2156
+ const orderModule = createModule("order", (m) => {
2157
+ m.on("order/placed", (order) => console.log(order));
2158
+ });
2159
+
2160
+ // mount — start both modules, left to right
2161
+ const stop = app.mount(userModule, orderModule);
2162
+
2163
+ // the returned function stops them again, last mounted first (orderModule, then userModule)
2164
+ stop();
2165
+
2166
+ // the same for modules that you already have in an array: spread it
2167
+ const modules = [userModule, orderModule];
2168
+ app.mount(...modules);
2169
+ app.unmount(...modules); // also in reverse order
2170
+ ```
2171
+
2172
+ If the `setup` of one module throws, the modules of that call that were already mounted are unmounted again and the error is thrown, so a call mounts all of its modules or none. A module that is mounted already is skipped.
2173
+
2131
2174
  - **Unmounting undoes everything** the module did: subscriptions, bound commands, sub-modules, and background tasks (aborted).
2132
2175
  - **A module is known by its name.** Mounting a module whose name is taken replaces the mounted one (see [Hot reload](hmr.md)).
2133
2176
  - **Registry.** If `bus.use(commandsPlugin())` ran first, that registry is used; otherwise the module plugin attaches its own. Either way it is `bus.commands` and `app.commands`.
2134
2177
  - A disposed app cannot mount modules.
2135
- - **The given modules are mounted on the first use** of the app or the bus (the first `bus.emit`, or a call of `mount`, `unmount`, `isMounted`, `dispose`, or the read of `commands`), so the plugins you use after `modulePlugin` still reach them. The `bus.on` calls made in between are held and made right after, so the listeners of a module stay ahead of them. See [using plugins in any order](plugins.md#use-them-in-any-order).
2178
+ - **The given modules are mounted at the end of the block that made the app**, or at the first use of the app or the bus if that comes first (the first `bus.emit`, or a call of `mount`, `unmount`, `isMounted`, `dispose`, or the read of `commands`). Nothing needs to use the app: `bus.use(modulePlugin([a, b]))` is enough to start them. The plugins that you use after `modulePlugin` in the same block still reach them, because the modules wait until that block has finished. A plugin used after an `await` can come too late: use every plugin before the first `await`. The `bus.on` calls made in between are held and made right after, so the listeners of a module stay ahead of them. See [using plugins in any order](plugins.md#use-them-in-any-order).
2179
+ - **To mount by hand instead**, pass no modules: `modulePlugin()` starts nothing, and `app.mount(...)` starts the modules when you want.
2136
2180
  - Module context = task context plus `handle`, `use`, and `spawn`:
2137
2181
  - `handle` — give a command a handler (contract, or override).
2138
2182
  - `use` — bring sub-modules and commands that carry a handler; the returned function undoes that call.
@@ -3259,6 +3303,351 @@ export const Search = ({ wait }) => {
3259
3303
  - A state library instead of `useState`: [Stores](stores.md).
3260
3304
  - React Query or SWR instead of your own state: [Use nanoeffects with React Query and SWR](query-libraries.md).
3261
3305
 
3306
+ ---
3307
+ Source: docs/chat-example.md
3308
+
3309
+ # Build a chat with domains, modules and a small server
3310
+
3311
+ 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.
3312
+
3313
+ 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.
3314
+
3315
+ 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:
3316
+
3317
+ ```bash
3318
+ cd examples/chat
3319
+ npm install
3320
+ npm run dev
3321
+ ```
3322
+
3323
+ `npm run dev` starts the server and Vite together. Start it with `CHAOS=off npm run dev` to turn the failures off.
3324
+
3325
+ ## Read how the server fails
3326
+
3327
+ 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:
3328
+
3329
+ | What you do | What the server does | Section |
3330
+ | ----------- | -------------------- | ------- |
3331
+ | 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) |
3332
+ | Send messages | Every third send answers 503. | [Send a message that can fail](#send-a-message-that-can-fail) |
3333
+ | Send `/fail hello` | Answers 400. | [Send a message that can fail](#send-a-message-that-can-fail) |
3334
+ | 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) |
3335
+ | Wait | A new message appears every 4 s. | [Poll for new messages](#poll-for-new-messages) |
3336
+
3337
+ 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.
3338
+
3339
+ ## See how the files are split
3340
+
3341
+ Each domain is a folder. The components only emit events and read values; they never call the server.
3342
+
3343
+ | Path | Job |
3344
+ | ---- | --- |
3345
+ | `src/bus.ts` | Creates the bus. It imports no events and no domain. |
3346
+ | `src/app.ts` | Mounts the four domains. |
3347
+ | `src/api.ts` | The client of the server: one function per request. |
3348
+ | `server/` | The Fastify server and the script that starts it with Vite. |
3349
+ | `src/domains/rooms/` | The list of rooms and the selected room. |
3350
+ | `src/domains/messages/` | The history of the open room and sending a message. |
3351
+ | `src/domains/search/` | Searching every room and opening a result. |
3352
+ | `src/domains/incoming/` | New messages, asked for at an interval. |
3353
+ | `src/ui/` | The components. |
3354
+
3355
+ 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.
3356
+
3357
+ ## Load a history that must not race
3358
+
3359
+ **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.
3360
+
3361
+ **When.** A load starts from a user choice that can change before it ends.
3362
+
3363
+ 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`:
3364
+
3365
+ ```js
3366
+ // messages.js
3367
+ import { createJob } from "nanoeffects/actions";
3368
+
3369
+ // createJob — a command that also sends pending, success, fail and cancel
3370
+ export const loadHistory = createJob("messages/loadHistory");
3371
+ ```
3372
+
3373
+ ```js
3374
+ // messages-handlers.js
3375
+ import { latest } from "nanoeffects/policies";
3376
+
3377
+ // latest — a load for a newer room aborts the older one, and its result is never delivered
3378
+ export const fetchHistory = latest((roomId, { signal }) => api.loadHistory(roomId, { signal }));
3379
+ ```
3380
+
3381
+ 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)):
3382
+
3383
+ ```js
3384
+ // messages-module.js
3385
+ import { createModule } from "nanoeffects/actions";
3386
+ import { loadHistory } from "./messages";
3387
+ import { fetchHistory } from "./messages-handlers";
3388
+
3389
+ export const messagesModule = createModule("messages", (module) => {
3390
+ // handle — this module does the work of loadHistory while it is mounted
3391
+ module.handle(loadHistory, fetchHistory);
3392
+ module.on(loadHistory.pending, () => console.log("show the spinner"));
3393
+ module.on(loadHistory.cancel, () => console.log("a newer load replaced it"));
3394
+ });
3395
+ ```
3396
+
3397
+ ## Send a message that can fail
3398
+
3399
+ **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.
3400
+
3401
+ **When.** An action has a visible result that can fail and that must keep its order.
3402
+
3403
+ `queue` keeps the order, `retry` tries a failed call again, and the handler throws an error that carries the id of the message:
3404
+
3405
+ ```js
3406
+ import { retry } from "nanoeffects/effects";
3407
+ import { queue } from "nanoeffects/policies";
3408
+
3409
+ class SendFailed extends Error {
3410
+ constructor(localId, cause) {
3411
+ super(cause.message);
3412
+ this.localId = localId;
3413
+ }
3414
+ }
3415
+
3416
+ // A 400 is an answer: asking again gives the same answer. A 503 or a lost connection may pass.
3417
+ const isWorthRetrying = (error) => !(error.status < 500);
3418
+
3419
+ // retry — try a failed send again, up to 3 attempts, 200 ms apart, unless the answer was a "no"
3420
+ const attempt = retry(
3421
+ async (call, { signal }) => {
3422
+ const message = await api.send(call.roomId, call.text, { signal });
3423
+ return { localId: call.localId, message };
3424
+ },
3425
+ { times: 3, delay: 200, shouldRetry: isWorthRetrying },
3426
+ );
3427
+
3428
+ // queue — messages go out one after another, in the order they were sent
3429
+ export const deliver = queue(async (call, ctx) => {
3430
+ try {
3431
+ return await attempt(call, ctx);
3432
+ } catch (error) {
3433
+ throw new SendFailed(call.localId, error);
3434
+ }
3435
+ });
3436
+ ```
3437
+
3438
+ 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.
3439
+
3440
+ ## Search where one room is slow
3441
+
3442
+ **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.
3443
+
3444
+ **When.** One action fans out to several sources and a partial answer is better than none.
3445
+
3446
+ `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:
3447
+
3448
+ ```js
3449
+ import { allSettled, timeout } from "nanoeffects/effects";
3450
+ import { debounce, latest } from "nanoeffects/policies";
3451
+
3452
+ const rooms = [{ id: "general", name: "General" }, { id: "random", name: "Random" }];
3453
+
3454
+ const searchRoom = (room, query) => ({ signal }) => api.searchRoom(room.id, query, { signal });
3455
+
3456
+ // timeout — a room that does not answer in 1.5 s fails with a timeout error
3457
+ const askRoom = (room, query) => {
3458
+ const request = searchRoom(room, query);
3459
+ return (parent) => timeout(parent, 1500, request);
3460
+ };
3461
+
3462
+ // debounce — wait for quiet; latest — a newer query aborts the older search
3463
+ export const runSearch = debounce(
3464
+ 300,
3465
+ latest(async (query, ctx) => {
3466
+ // allSettled — ask every room at once and wait for all of them, however each one ends
3467
+ const outcomes = await allSettled(ctx, rooms.map((room) => askRoom(room, query)));
3468
+ return outcomes.map((outcome, index) => ({ room: rooms[index], outcome }));
3469
+ }),
3470
+ );
3471
+ ```
3472
+
3473
+ `all` would fail the whole search on the first slow room, which is the wrong behavior here. See [Effects](effects.md) for the others.
3474
+
3475
+ ## Wait for a room before marking a result
3476
+
3477
+ **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.
3478
+
3479
+ **When.** A handler starts work in another domain and needs to continue after its outcome.
3480
+
3481
+ `take` waits for the next event of a name. Start it before the emit that causes the event, so nothing is missed:
3482
+
3483
+ ```js
3484
+ import { take } from "nanoeffects/effects";
3485
+
3486
+ // the room that is open, kept from the rooms/selected event
3487
+ let selectedRoom = "general";
3488
+
3489
+ export const openResult = async (hit, ctx) => {
3490
+ if (hit.roomId !== selectedRoom) {
3491
+ // take — wait for the next event of that name; started before the emit so nothing is missed
3492
+ const loaded = take(ctx, "messages/loadHistory/success");
3493
+ ctx.emit("rooms/selected", hit.roomId);
3494
+ await loaded;
3495
+ }
3496
+ ctx.emit("messages/marked", hit.id);
3497
+ };
3498
+ ```
3499
+
3500
+ ## Poll for new messages
3501
+
3502
+ **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.
3503
+
3504
+ **When.** A feature has background work that lives as long as the feature does.
3505
+
3506
+ `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:
3507
+
3508
+ ```js
3509
+ import { createModule } from "nanoeffects/actions";
3510
+ import { poll } from "nanoeffects/effects";
3511
+
3512
+ const watchUpdates = async (_, ctx) => {
3513
+ // the number of the last update seen; the first answer only tells where the server is
3514
+ let since;
3515
+
3516
+ const check = async () => {
3517
+ try {
3518
+ const updates = await api.loadUpdates(since, { signal: ctx.signal });
3519
+ since = updates.seq;
3520
+ for (const message of updates.messages) {
3521
+ // ctx.emit — after an abort it delivers nothing
3522
+ ctx.emit("incoming/received", message);
3523
+ }
3524
+ } catch {
3525
+ // one failed request must not end the polling: the next tick asks again
3526
+ }
3527
+ };
3528
+
3529
+ // poll — call `check` now and then every 3 s; an abort of ctx (an unmounted module) ends it
3530
+ await poll(ctx, check, 3000, { leading: true });
3531
+ };
3532
+
3533
+ export const incomingModule = createModule("incoming", (module) => {
3534
+ // spawn — a background task that lives as long as this module
3535
+ module.spawn(watchUpdates);
3536
+ });
3537
+ ```
3538
+
3539
+ 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.
3540
+
3541
+ ## Know where each value lives
3542
+
3543
+ 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).
3544
+
3545
+ ## Where to go next
3546
+
3547
+ - The API of commands, jobs and modules: [Actions, commands, jobs and modules](actions.md).
3548
+ - Policies such as `latest` and `queue`: [Policies](policies.md). Effects such as `allSettled`, `take` and `poll`: [Effects](effects.md).
3549
+ - A smaller project with two domains: [Start with domains](starter.md#start-with-domains).
3550
+
3551
+ ---
3552
+ Source: docs/vue.md
3553
+
3554
+ # Use nanoeffects in a Vue app
3555
+
3556
+ 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).
3557
+
3558
+ 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.
3559
+
3560
+ ## Add the plugin to the bus
3561
+
3562
+ **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.
3563
+
3564
+ **When.** A Vue component sends values to the bus, reads events from it, or both.
3565
+
3566
+ `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:
3567
+
3568
+ ```js
3569
+ // bus.js
3570
+ import { createBus } from "nanoeffects";
3571
+ import { vuePlugin } from "nanoeffects/vue";
3572
+
3573
+ // createBus — the event bus, created once outside Vue
3574
+ // vuePlugin — adds watchEmitter and useEventRef to the bus
3575
+ export const bus = createBus().use(vuePlugin());
3576
+ export const { watchEmitter, useEventRef } = bus; // free functions: they do not read `this`
3577
+ ```
3578
+
3579
+ ## Tell the bus that a value changed
3580
+
3581
+ <!-- docs-check: continue -->
3582
+
3583
+ `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.
3584
+
3585
+ ```js
3586
+ // search.js
3587
+ import { createAction } from "nanoeffects/actions";
3588
+
3589
+ // createAction — an event with a name; calling it builds { type, payload }
3590
+ export const queryChanged = createAction("search/queryChanged");
3591
+ export const searchLoaded = createAction("search/loaded");
3592
+ ```
3593
+
3594
+ ```js
3595
+ // query-box.js
3596
+ import { ref } from "vue";
3597
+ import { watchEmitter } from "./bus";
3598
+ import { queryChanged } from "./search";
3599
+
3600
+ export const setup = () => {
3601
+ const query = ref("");
3602
+
3603
+ // watchEmitter — emits queryChanged(query) when query changes, never on mount
3604
+ watchEmitter(query, queryChanged);
3605
+
3606
+ return { query };
3607
+ };
3608
+ ```
3609
+
3610
+ The third argument takes the options `equals`, `traceId` and `traceGroupId`:
3611
+
3612
+ | Option | Use it when |
3613
+ | ------ | ----------- |
3614
+ | `equals(previous, next)` | A new object with the same fields should not emit: `{ equals: (a, b) => a.id === b.id }`. |
3615
+ | `traceId` | You want the subscription to have a name in the [trace](tracing.md#show-which-subscription-began-a-chain). |
3616
+ | `traceGroupId` | You want to name the whole flow that the emit starts. |
3617
+
3618
+ `watchEmitter` returns the function that stops it early, for use outside a component.
3619
+
3620
+ ## Show the result of an event
3621
+
3622
+ <!-- docs-check: continue -->
3623
+
3624
+ `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.
3625
+
3626
+ ```js
3627
+ // found-list.js
3628
+ import { useEventRef } from "./bus";
3629
+ import { searchLoaded } from "./search";
3630
+
3631
+ export const setup = () => {
3632
+ // useEventRef — like ref([]), but searchLoaded sets the value
3633
+ const found = useEventRef(searchLoaded, []);
3634
+
3635
+ return { found };
3636
+ };
3637
+ ```
3638
+
3639
+ 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`.
3640
+
3641
+ ### It is not a store
3642
+
3643
+ `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`.
3644
+
3645
+ ## Where to go next
3646
+
3647
+ - The same two jobs for React: [Build a sign-up form in React](react.md).
3648
+ - 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.
3649
+ - Keep values in Pinia: [Connect Redux, Zustand, MobX, or Pinia](stores.md).
3650
+
3262
3651
  ---
3263
3652
  Source: docs/context.md
3264
3653
 
@@ -4140,6 +4529,20 @@ const user = bus.emit(getUser("1")); // Promise<User>
4140
4529
 
4141
4530
  `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", …)`.
4142
4531
 
4532
+ ## Type a function that takes the signal
4533
+
4534
+ 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.
4535
+
4536
+ ```ts
4537
+ import type { WithSignal } from "nanoeffects";
4538
+
4539
+ // WithSignal — { signal: AbortSignal }; a task context fits it, and so does { signal } on its own
4540
+ const search = (query: string, { signal }: WithSignal) =>
4541
+ fetch(`/search?q=${encodeURIComponent(query)}`, { signal });
4542
+ ```
4543
+
4544
+ 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.
4545
+
4143
4546
  ## Keep the contract apart from the code
4144
4547
 
4145
4548
  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:
@@ -4697,7 +5100,7 @@ Source: docs/migrating-from-rxjs.md
4697
5100
 
4698
5101
  # Use nanoeffects next to RxJS
4699
5102
 
4700
- 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.
5103
+ 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.
4701
5104
 
4702
5105
  ## Split the work
4703
5106
 
@@ -4779,6 +5182,63 @@ input.addEventListener("input", (event) => bus.emit("typed", event.target.value)
4779
5182
 
4780
5183
  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).
4781
5184
 
5185
+ ## Rewrite a polling loop
5186
+
5187
+ Check a job status every 5 seconds. Retry a failed check, and stop when the user leaves the screen.
5188
+
5189
+ The same job in RxJS: `timer` ticks, `exhaustMap` skips a tick while a check runs, `retry` repeats a failed check, `takeUntil` stops it.
5190
+
5191
+ <!-- docs-check: skip - needs rxjs, which the project does not depend on -->
5192
+ ```js
5193
+ // RxJS
5194
+ import { timer } from "rxjs";
5195
+ import { exhaustMap, retry, takeUntil } from "rxjs/operators";
5196
+
5197
+ timer(5000, 5000)
5198
+ .pipe(
5199
+ exhaustMap(() => api.statusStream().pipe(retry({ count: 3, delay: 500 }))),
5200
+ takeUntil(leave$),
5201
+ )
5202
+ .subscribe((status) => render(status));
5203
+ ```
5204
+
5205
+ The same job in nanoeffects. `poll` ticks, `retry` repeats one check, and `race` ends the loop on the first `"leave"` event:
5206
+
5207
+ ```js
5208
+ import { createBus } from "nanoeffects";
5209
+ import { poll, race, retry, take } from "nanoeffects/effects";
5210
+
5211
+ const bus = createBus(); // the bus: events and handlers
5212
+
5213
+ bus.on("watch", async (_, ctx) => {
5214
+ // one check, up to 3 attempts, 500 ms between them
5215
+ const checkStatus = async () => {
5216
+ const status = await retry(ctx, () => api.status({ signal: ctx.signal }), {
5217
+ times: 3,
5218
+ delay: 500,
5219
+ });
5220
+ ctx.emit("status", status);
5221
+ };
5222
+
5223
+ await race(ctx, {
5224
+ // poll: a check every 5 s; waitForFn: the next pause starts after the check ends
5225
+ polling: ({ signal }) => poll({ signal }, checkStatus, 5000, { waitForFn: true }),
5226
+ // take: resolves at the next "leave", and race stops the polling
5227
+ leave: ({ signal }) => take(ctx, "leave", { signal }),
5228
+ });
5229
+ });
5230
+
5231
+ bus.on("status", (status) => render(status));
5232
+
5233
+ bus.emit("watch"); // start
5234
+ bus.emit("leave"); // stop
5235
+ ```
5236
+
5237
+ Two things differ from the pipeline:
5238
+
5239
+ - `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.
5240
+ - 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.
5241
+
4782
5242
  ## Use both in one app
4783
5243
 
4784
5244
  Keep RxJS where a stream is how you think, and let events cross to the bus in either direction.
package/llms.txt CHANGED
@@ -9,13 +9,13 @@ Rules that generated code often gets wrong:
9
9
  - A handler gets the payload and a task context: `(payload, ctx)`. The context has `signal`, `emit`, `on`, `run`. Pass `ctx.signal` to `fetch`.
10
10
  - Effects take `ctx` as the first argument: `retry(ctx, fn, options)`, `timeout(ctx, ms, fn)`, `take(ctx, "event")`.
11
11
  - Policies wrap a handler: `bus.on("search", latest(async (query, ctx) => { ... }))`.
12
- - Each part is imported from its own path: `nanoeffects` (`createBus`), `nanoeffects/policies`, `nanoeffects/effects`, `nanoeffects/actions`, `nanoeffects/redux`, `nanoeffects/observable`, `nanoeffects/react` (`reactPlugin`, adds `bus.useEmitter` and `bus.useEventState`; `usePolicy`; needs React), `nanoeffects/trace` (`createTracer`, loggers, `tracePlugin`), `nanoeffects/dev`.
12
+ - Each part is imported from its own path: `nanoeffects` (`createBus`), `nanoeffects/policies`, `nanoeffects/effects`, `nanoeffects/actions`, `nanoeffects/redux`, `nanoeffects/observable`, `nanoeffects/react` (`reactPlugin`, adds `bus.useEmitter` and `bus.useEventState`; `usePolicy`; needs React), `nanoeffects/vue` (`vuePlugin`, adds `bus.watchEmitter` and `bus.useEventRef`; needs Vue), `nanoeffects/trace` (`createTracer`, loggers, `tracePlugin`), `nanoeffects/dev`.
13
13
  - `bus.emit(command(payload))` runs the command's handler and returns its result. Plain events return nothing.
14
14
 
15
15
  ## Start here
16
16
 
17
17
  - [Guide](docs/guide.md): bus, handler context, policies, effects, commands and modules, step by step
18
- - [Start a project from a template](docs/starter.md): `npm create nanoeffects` makes a Vite project (`vanilla` or `react`) with a bus, `debounce` and `latest`
18
+ - [Start a project from a template](docs/starter.md): `npm create nanoeffects` makes a Vite project (`vanilla`, `react`, `vue-ts` and others, in JavaScript or TypeScript) with a bus, `debounce` and `latest`
19
19
  - [Why nanoeffects exists](docs/motivation.md): no dependencies and no global state for SDKs and Module Federation, and why not Redux, Effector or RxJS
20
20
  - [Package entries](docs/package.md): which import holds what
21
21
  - [Plugins](docs/plugins.md): every plugin of a bus, what `bus.use` returns, and the order to use them in
@@ -35,6 +35,8 @@ Rules that generated code often gets wrong:
35
35
  - [Connect Redux, Zustand, MobX, or Pinia](docs/stores.md): the Redux middleware and callbacks of other stores
36
36
  - [Use nanoeffects with React Query and SWR](docs/query-libraries.md): call a command from `queryFn` or the SWR fetcher; `signalPlugin` passes the abort of the library to the task
37
37
  - [Build a sign-up form in React](docs/react.md): a form with a username check, a submit that cannot fire twice, and retry; `useEventState` shows an event as state, `usePolicy` keeps a debounced handler between renders
38
+ - [Build a chat with domains, modules and a small server](docs/chat-example.md): rooms, a history under `latest`, a send under `queue` and `retry`, a search with `allSettled` and `timeout`, new messages polled with `poll`
39
+ - [Use nanoeffects in a Vue app](docs/vue.md): `vuePlugin` adds `watchEmitter` (a value of a component to the bus) and `useEventRef` (an event as a ref)
38
40
  - [Context fields](docs/context.md): put a store or client on every task
39
41
  - [Scopes](docs/scope.md): fields for one command, action, job, module or `emit`, and a way to replace them in a test
40
42
  - [Use the bus from a class](docs/oop.md): `Emitter`, `extendBus`, `@WithBus`, `@On`, `@Once`, and `dispose()` for the subscriptions of an instance