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.
- package/dist/{action-creator-HFA_LZOH.d.ts → action-creator-Bpa8BUS9.d.ts} +18 -7
- package/dist/actions.d.ts +3 -3
- package/dist/actions.js +1 -1
- package/dist/{context-types-BNcs43eY.d.ts → context-types-BKIH5a8s.d.ts} +1 -1
- package/dist/context.d.ts +2 -2
- package/dist/dev.d.ts +2 -2
- package/dist/dev.js +1 -1
- package/dist/effects.d.ts +4 -8
- package/dist/index.d.ts +2 -2
- package/dist/match.d.ts +1 -1
- package/dist/{module-DTf73Wdq.d.ts → module-B0KVDNjY.d.ts} +14 -9
- package/dist/mount-each-D54UqAhZ.js +1 -0
- package/dist/notrace/{action-creator-CbFf398i.d.ts → action-creator-D2CotugT.d.ts} +16 -7
- package/dist/notrace/actions.d.ts +14 -9
- package/dist/notrace/actions.js +1 -1
- package/dist/notrace/effects.d.ts +4 -8
- package/dist/notrace/index.d.ts +2 -2
- package/dist/notrace/{plugin-C5vIjxhR.d.ts → plugin-C6KA4dX-.d.ts} +1 -1
- package/dist/notrace/policies.d.ts +51 -6
- package/dist/notrace/policies.js +1 -1
- package/dist/notrace/redux.d.ts +1 -1
- package/dist/observable.d.ts +1 -1
- package/dist/oop.d.ts +1 -1
- package/dist/{plugin-Sy8z9IId.d.ts → plugin-D9UTzGRG.d.ts} +2 -2
- package/dist/policies.d.ts +52 -7
- package/dist/policies.js +1 -1
- package/dist/react.d.ts +9 -8
- package/dist/redux.d.ts +1 -1
- package/dist/scope.d.ts +4 -4
- package/dist/signal.d.ts +1 -1
- package/dist/state-of-C19H6BoV.d.ts +10 -0
- package/dist/trace.d.ts +1 -1
- package/dist/{types-DaZT158m.d.ts → types-CbdJKC2z.d.ts} +2 -2
- package/dist/vue.d.ts +72 -0
- package/dist/vue.js +1 -0
- package/dist/{with-policy-C_mX7S54.d.ts → with-policy-BeH8wCkX.d.ts} +1 -1
- package/docs/README.md +2 -0
- package/docs/actions.md +31 -1
- package/docs/chat-example.md +246 -0
- package/docs/migrating-from-rxjs.md +58 -1
- package/docs/package.md +1 -0
- package/docs/plugins.md +3 -3
- package/docs/policies.md +27 -0
- package/docs/query-libraries.md +2 -0
- package/docs/react.md +5 -3
- package/docs/starter.md +25 -1
- package/docs/typescript.md +14 -0
- package/docs/vue.md +96 -0
- package/llms-full.txt +517 -10
- package/llms.txt +4 -2
- package/package.json +25 -4
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,8 @@ 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 |
|
|
556
|
+
| `svelte-ts` | The `react-ts` app in Svelte 5, with a Svelte store made from an event | You build a Svelte app |
|
|
555
557
|
|
|
556
558
|
```bash
|
|
557
559
|
npm create nanoeffects@latest my-app -- --template react
|
|
@@ -565,6 +567,26 @@ The TypeScript templates are the same apps with the same files, written in `.ts`
|
|
|
565
567
|
npm create nanoeffects@latest my-app -- --template react-ts
|
|
566
568
|
```
|
|
567
569
|
|
|
570
|
+
### Start with Vue
|
|
571
|
+
|
|
572
|
+
`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.
|
|
573
|
+
|
|
574
|
+
```bash
|
|
575
|
+
npm create nanoeffects@latest my-app -- --template vue-ts
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
`npm run build` type-checks the `.vue` files with `vue-tsc`, which needs TypeScript 5: the template depends on it.
|
|
579
|
+
|
|
580
|
+
### Start with Svelte
|
|
581
|
+
|
|
582
|
+
`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.
|
|
583
|
+
|
|
584
|
+
```bash
|
|
585
|
+
npm create nanoeffects@latest my-app -- --template svelte-ts
|
|
586
|
+
```
|
|
587
|
+
|
|
588
|
+
`npm run build` type-checks the `.svelte` files with `svelte-check`, which needs TypeScript 5: the template depends on it.
|
|
589
|
+
|
|
568
590
|
### Start with domains
|
|
569
591
|
|
|
570
592
|
`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 +603,13 @@ npm create nanoeffects@latest my-app -- --template domains-ts
|
|
|
581
603
|
| `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
604
|
| `src/ui/` | The components. None of them calls the fake server in `src/api.ts`. |
|
|
583
605
|
|
|
606
|
+
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).
|
|
607
|
+
|
|
584
608
|
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
609
|
|
|
586
610
|
## Read the files
|
|
587
611
|
|
|
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).
|
|
612
|
+
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).
|
|
589
613
|
|
|
590
614
|
| File | Job |
|
|
591
615
|
| ---- | --- |
|
|
@@ -668,6 +692,7 @@ Imports are split so you only load what you use. `createBus` stays small; the re
|
|
|
668
692
|
| `nanoeffects/redux` | `createEffectsMiddleware` |
|
|
669
693
|
| `nanoeffects/observable` | `createObserve`, `Subject` |
|
|
670
694
|
| `nanoeffects/react` | `reactPlugin`: adds the `useEmitter` hook to a bus (needs React 18 or newer) |
|
|
695
|
+
| `nanoeffects/vue` | `vuePlugin`: adds `watchEmitter` and `useEventRef` to a bus (needs Vue 3.3 or newer) |
|
|
671
696
|
| `nanoeffects/dev` | `devPlugin` (no-op in production) |
|
|
672
697
|
| `nanoeffects/trace` | `createTracer`, the loggers and `tracePlugin` (names for the trace) |
|
|
673
698
|
|
|
@@ -770,7 +795,7 @@ import { modulePlugin } from "nanoeffects/actions";
|
|
|
770
795
|
|
|
771
796
|
const bus = createBus();
|
|
772
797
|
|
|
773
|
-
// modulePlugin — mounts userModule
|
|
798
|
+
// modulePlugin — mounts userModule at the end of this block, or at the first use if that is earlier, and returns the app
|
|
774
799
|
const app = bus.use(modulePlugin([userModule]));
|
|
775
800
|
|
|
776
801
|
app.unmount(userModule); // its listeners, bound commands and tasks are gone
|
|
@@ -949,7 +974,7 @@ export const bus = createBus()
|
|
|
949
974
|
// commandsPlugin — the registry of command handlers; keep it to bind handlers later
|
|
950
975
|
export const commands = bus.use(commandsPlugin());
|
|
951
976
|
|
|
952
|
-
// modulePlugin — mounts userModule
|
|
977
|
+
// modulePlugin — mounts userModule at the end of this block; keep app to mount and unmount later
|
|
953
978
|
export const app = bus.use(modulePlugin([userModule]));
|
|
954
979
|
|
|
955
980
|
// devPlugin — reports leaks and modules while you develop; keep disconnect to remove it
|
|
@@ -962,7 +987,7 @@ The plugins give the same result in any order. Use them as they read best, and p
|
|
|
962
987
|
|
|
963
988
|
Two things happen because modules are mounted late:
|
|
964
989
|
|
|
965
|
-
- **Modules start
|
|
990
|
+
- **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
991
|
- **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
992
|
|
|
968
993
|
Other plugins do not need an order for another reason:
|
|
@@ -1055,6 +1080,33 @@ Every policy accepts `key` in its options (see [Isolate state by key](#isolate-s
|
|
|
1055
1080
|
| `throttle(ms, fn, options?)` | at most one run per `ms` |
|
|
1056
1081
|
| `queue(fn, options?)` | one after another, in order |
|
|
1057
1082
|
|
|
1083
|
+
## Call a policy without its function
|
|
1084
|
+
|
|
1085
|
+
**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.
|
|
1086
|
+
|
|
1087
|
+
**When.** You pass a policy to `usePolicy`. With a bus, `withPolicy` or your own code, call the policy with its function as above.
|
|
1088
|
+
|
|
1089
|
+
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:
|
|
1090
|
+
|
|
1091
|
+
```js
|
|
1092
|
+
import { debounce } from "nanoeffects/policies";
|
|
1093
|
+
|
|
1094
|
+
const runSearch = async (query) => {
|
|
1095
|
+
await api.search(query);
|
|
1096
|
+
};
|
|
1097
|
+
|
|
1098
|
+
// debounce(300) — no function yet: returns a wrapper
|
|
1099
|
+
const wrap = debounce(300);
|
|
1100
|
+
|
|
1101
|
+
// the wrapper takes the function; same as debounce(300, runSearch)
|
|
1102
|
+
const search = wrap(runSearch);
|
|
1103
|
+
```
|
|
1104
|
+
|
|
1105
|
+
- 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 })`.
|
|
1106
|
+
- Each time you apply a wrapper to a function, you get a new policy with its own state.
|
|
1107
|
+
- To combine policies, nest them as before: `throttle(100, latest(fn))`.
|
|
1108
|
+
- 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.
|
|
1109
|
+
|
|
1058
1110
|
## Configure debounce and throttle
|
|
1059
1111
|
|
|
1060
1112
|
`debounce` and `throttle` take `leading` and `trailing`. Behavior matches lodash (and patronum); tests compare random call sequences to lodash itself.
|
|
@@ -1882,6 +1934,8 @@ If you only need `on` / `emit` or policies, stay in the [Guide](guide.md) until
|
|
|
1882
1934
|
|
|
1883
1935
|
Recipe without a UI framework: [`examples/modules.ts`](../examples/modules.ts).
|
|
1884
1936
|
|
|
1937
|
+
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).
|
|
1938
|
+
|
|
1885
1939
|
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
1940
|
|
|
1887
1941
|
## Return a value from `emit` with a command
|
|
@@ -2128,11 +2182,39 @@ app.commands.handle(getUser, fakeUser); // in a test
|
|
|
2128
2182
|
app.dispose(); // unmounts every module
|
|
2129
2183
|
```
|
|
2130
2184
|
|
|
2185
|
+
`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`:
|
|
2186
|
+
|
|
2187
|
+
```js
|
|
2188
|
+
import { createModule } from "nanoeffects/actions";
|
|
2189
|
+
import { app } from "./bus";
|
|
2190
|
+
|
|
2191
|
+
const userModule = createModule("user", (m) => {
|
|
2192
|
+
m.on("user/loaded", (user) => console.log(user));
|
|
2193
|
+
});
|
|
2194
|
+
const orderModule = createModule("order", (m) => {
|
|
2195
|
+
m.on("order/placed", (order) => console.log(order));
|
|
2196
|
+
});
|
|
2197
|
+
|
|
2198
|
+
// mount — start both modules, left to right
|
|
2199
|
+
const stop = app.mount(userModule, orderModule);
|
|
2200
|
+
|
|
2201
|
+
// the returned function stops them again, last mounted first (orderModule, then userModule)
|
|
2202
|
+
stop();
|
|
2203
|
+
|
|
2204
|
+
// the same for modules that you already have in an array: spread it
|
|
2205
|
+
const modules = [userModule, orderModule];
|
|
2206
|
+
app.mount(...modules);
|
|
2207
|
+
app.unmount(...modules); // also in reverse order
|
|
2208
|
+
```
|
|
2209
|
+
|
|
2210
|
+
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.
|
|
2211
|
+
|
|
2131
2212
|
- **Unmounting undoes everything** the module did: subscriptions, bound commands, sub-modules, and background tasks (aborted).
|
|
2132
2213
|
- **A module is known by its name.** Mounting a module whose name is taken replaces the mounted one (see [Hot reload](hmr.md)).
|
|
2133
2214
|
- **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
2215
|
- A disposed app cannot mount modules.
|
|
2135
|
-
- **The given modules are mounted
|
|
2216
|
+
- **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).
|
|
2217
|
+
- **To mount by hand instead**, pass no modules: `modulePlugin()` starts nothing, and `app.mount(...)` starts the modules when you want.
|
|
2136
2218
|
- Module context = task context plus `handle`, `use`, and `spawn`:
|
|
2137
2219
|
- `handle` — give a command a handler (contract, or override).
|
|
2138
2220
|
- `use` — bring sub-modules and commands that carry a handler; the returned function undoes that call.
|
|
@@ -2978,6 +3060,8 @@ Send it from the handler with `ctx.emit(userLoaded(user))`. Do not replace the c
|
|
|
2978
3060
|
- **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.
|
|
2979
3061
|
- **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.
|
|
2980
3062
|
|
|
3063
|
+
A whole small app, with a search, a filter, pages and a detail panel, is in the [Rick and Morty example](../examples/rickandmorty).
|
|
3064
|
+
|
|
2981
3065
|
---
|
|
2982
3066
|
Source: docs/react.md
|
|
2983
3067
|
|
|
@@ -3231,8 +3315,8 @@ export const Search = ({ wait }) => {
|
|
|
3231
3315
|
const found = await api.search(query, { signal });
|
|
3232
3316
|
setItems(found);
|
|
3233
3317
|
},
|
|
3234
|
-
// the second argument builds the policy once
|
|
3235
|
-
|
|
3318
|
+
// debounce(wait) — the second argument builds the policy once around the callback above
|
|
3319
|
+
debounce(wait),
|
|
3236
3320
|
// deps — list what the second argument reads; a new wait makes a new policy
|
|
3237
3321
|
[wait],
|
|
3238
3322
|
);
|
|
@@ -3248,7 +3332,9 @@ export const Search = ({ wait }) => {
|
|
|
3248
3332
|
|
|
3249
3333
|
- `onChange` keeps its identity while `wait` stays the same, so it is safe in `useEffect` and in props.
|
|
3250
3334
|
- A pending call is cancelled on unmount and when `deps` change.
|
|
3251
|
-
-
|
|
3335
|
+
- 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.
|
|
3336
|
+
- 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.
|
|
3337
|
+
- `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.
|
|
3252
3338
|
- Without a React component, use [`withPolicy`](policies.md#use-a-policy-without-a-bus).
|
|
3253
3339
|
|
|
3254
3340
|
## Where to go next
|
|
@@ -3259,6 +3345,356 @@ export const Search = ({ wait }) => {
|
|
|
3259
3345
|
- A state library instead of `useState`: [Stores](stores.md).
|
|
3260
3346
|
- React Query or SWR instead of your own state: [Use nanoeffects with React Query and SWR](query-libraries.md).
|
|
3261
3347
|
|
|
3348
|
+
---
|
|
3349
|
+
Source: docs/chat-example.md
|
|
3350
|
+
|
|
3351
|
+
# Build a chat with domains, modules and a small server
|
|
3352
|
+
|
|
3353
|
+
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.
|
|
3354
|
+
|
|
3355
|
+
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.
|
|
3356
|
+
|
|
3357
|
+
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:
|
|
3358
|
+
|
|
3359
|
+
```bash
|
|
3360
|
+
cd examples/chat
|
|
3361
|
+
npm install
|
|
3362
|
+
npm run dev
|
|
3363
|
+
```
|
|
3364
|
+
|
|
3365
|
+
`npm run dev` starts the server and Vite together. Start it with `CHAOS=off npm run dev` to turn the failures off.
|
|
3366
|
+
|
|
3367
|
+
## Read how the server fails
|
|
3368
|
+
|
|
3369
|
+
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:
|
|
3370
|
+
|
|
3371
|
+
| What you do | What the server does | Section |
|
|
3372
|
+
| ----------- | -------------------- | ------- |
|
|
3373
|
+
| 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) |
|
|
3374
|
+
| Send messages | Every third send answers 503. | [Send a message that can fail](#send-a-message-that-can-fail) |
|
|
3375
|
+
| Send `/fail hello` | Answers 400. | [Send a message that can fail](#send-a-message-that-can-fail) |
|
|
3376
|
+
| 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) |
|
|
3377
|
+
| Wait | A new message appears every 4 s. | [Poll for new messages](#poll-for-new-messages) |
|
|
3378
|
+
|
|
3379
|
+
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.
|
|
3380
|
+
|
|
3381
|
+
## See how the files are split
|
|
3382
|
+
|
|
3383
|
+
Each domain is a folder. The components only emit events and read values; they never call the server.
|
|
3384
|
+
|
|
3385
|
+
| Path | Job |
|
|
3386
|
+
| ---- | --- |
|
|
3387
|
+
| `src/bus.ts` | Creates the bus. It imports no events and no domain. |
|
|
3388
|
+
| `src/app.ts` | Mounts the four domains. |
|
|
3389
|
+
| `src/api.ts` | The client of the server: one function per request. |
|
|
3390
|
+
| `server/` | The Fastify server and the script that starts it with Vite. |
|
|
3391
|
+
| `src/domains/rooms/` | The list of rooms and the selected room. |
|
|
3392
|
+
| `src/domains/messages/` | The history of the open room and sending a message. |
|
|
3393
|
+
| `src/domains/search/` | Searching every room and opening a result. |
|
|
3394
|
+
| `src/domains/incoming/` | New messages, asked for at an interval. |
|
|
3395
|
+
| `src/ui/` | The components. |
|
|
3396
|
+
|
|
3397
|
+
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.
|
|
3398
|
+
|
|
3399
|
+
## Load a history that must not race
|
|
3400
|
+
|
|
3401
|
+
**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.
|
|
3402
|
+
|
|
3403
|
+
**When.** A load starts from a user choice that can change before it ends.
|
|
3404
|
+
|
|
3405
|
+
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`:
|
|
3406
|
+
|
|
3407
|
+
```js
|
|
3408
|
+
// messages.js
|
|
3409
|
+
import { createJob } from "nanoeffects/actions";
|
|
3410
|
+
|
|
3411
|
+
// createJob — a command that also sends pending, success, fail and cancel
|
|
3412
|
+
export const loadHistory = createJob("messages/loadHistory");
|
|
3413
|
+
```
|
|
3414
|
+
|
|
3415
|
+
```js
|
|
3416
|
+
// messages-handlers.js
|
|
3417
|
+
import { latest } from "nanoeffects/policies";
|
|
3418
|
+
|
|
3419
|
+
// latest — a load for a newer room aborts the older one, and its result is never delivered
|
|
3420
|
+
export const fetchHistory = latest((roomId, { signal }) => api.loadHistory(roomId, { signal }));
|
|
3421
|
+
```
|
|
3422
|
+
|
|
3423
|
+
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)):
|
|
3424
|
+
|
|
3425
|
+
```js
|
|
3426
|
+
// messages-module.js
|
|
3427
|
+
import { createModule } from "nanoeffects/actions";
|
|
3428
|
+
import { loadHistory } from "./messages";
|
|
3429
|
+
import { fetchHistory } from "./messages-handlers";
|
|
3430
|
+
|
|
3431
|
+
export const messagesModule = createModule("messages", (module) => {
|
|
3432
|
+
// handle — this module does the work of loadHistory while it is mounted
|
|
3433
|
+
module.handle(loadHistory, fetchHistory);
|
|
3434
|
+
module.on(loadHistory.pending, () => console.log("show the spinner"));
|
|
3435
|
+
module.on(loadHistory.cancel, () => console.log("a newer load replaced it"));
|
|
3436
|
+
});
|
|
3437
|
+
```
|
|
3438
|
+
|
|
3439
|
+
## Send a message that can fail
|
|
3440
|
+
|
|
3441
|
+
**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.
|
|
3442
|
+
|
|
3443
|
+
**When.** An action has a visible result that can fail and that must keep its order.
|
|
3444
|
+
|
|
3445
|
+
`queue` keeps the order, `retry` tries a failed call again, and the handler throws an error that carries the id of the message:
|
|
3446
|
+
|
|
3447
|
+
```js
|
|
3448
|
+
import { retry } from "nanoeffects/effects";
|
|
3449
|
+
import { queue } from "nanoeffects/policies";
|
|
3450
|
+
|
|
3451
|
+
class SendFailed extends Error {
|
|
3452
|
+
constructor(localId, cause) {
|
|
3453
|
+
super(cause.message);
|
|
3454
|
+
this.localId = localId;
|
|
3455
|
+
}
|
|
3456
|
+
}
|
|
3457
|
+
|
|
3458
|
+
// A 400 is an answer: asking again gives the same answer. A 503 or a lost connection may pass.
|
|
3459
|
+
const isWorthRetrying = (error) => !(error.status < 500);
|
|
3460
|
+
|
|
3461
|
+
// retry — try a failed send again, up to 3 attempts, 200 ms apart, unless the answer was a "no"
|
|
3462
|
+
const attempt = retry(
|
|
3463
|
+
async (call, { signal }) => {
|
|
3464
|
+
const message = await api.send(call.roomId, call.text, { signal });
|
|
3465
|
+
return { localId: call.localId, message };
|
|
3466
|
+
},
|
|
3467
|
+
{ times: 3, delay: 200, shouldRetry: isWorthRetrying },
|
|
3468
|
+
);
|
|
3469
|
+
|
|
3470
|
+
// queue — messages go out one after another, in the order they were sent
|
|
3471
|
+
export const deliver = queue(async (call, ctx) => {
|
|
3472
|
+
try {
|
|
3473
|
+
return await attempt(call, ctx);
|
|
3474
|
+
} catch (error) {
|
|
3475
|
+
throw new SendFailed(call.localId, error);
|
|
3476
|
+
}
|
|
3477
|
+
});
|
|
3478
|
+
```
|
|
3479
|
+
|
|
3480
|
+
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.
|
|
3481
|
+
|
|
3482
|
+
## Search where one room is slow
|
|
3483
|
+
|
|
3484
|
+
**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.
|
|
3485
|
+
|
|
3486
|
+
**When.** One action fans out to several sources and a partial answer is better than none.
|
|
3487
|
+
|
|
3488
|
+
`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:
|
|
3489
|
+
|
|
3490
|
+
```js
|
|
3491
|
+
import { allSettled, timeout } from "nanoeffects/effects";
|
|
3492
|
+
import { debounce, latest } from "nanoeffects/policies";
|
|
3493
|
+
|
|
3494
|
+
const rooms = [{ id: "general", name: "General" }, { id: "random", name: "Random" }];
|
|
3495
|
+
|
|
3496
|
+
const searchRoom = (room, query) => ({ signal }) => api.searchRoom(room.id, query, { signal });
|
|
3497
|
+
|
|
3498
|
+
// timeout — a room that does not answer in 1.5 s fails with a timeout error
|
|
3499
|
+
const askRoom = (room, query) => {
|
|
3500
|
+
const request = searchRoom(room, query);
|
|
3501
|
+
return (parent) => timeout(parent, 1500, request);
|
|
3502
|
+
};
|
|
3503
|
+
|
|
3504
|
+
// debounce — wait for quiet; latest — a newer query aborts the older search
|
|
3505
|
+
export const runSearch = debounce(
|
|
3506
|
+
300,
|
|
3507
|
+
latest(async (query, ctx) => {
|
|
3508
|
+
// allSettled — ask every room at once and wait for all of them, however each one ends
|
|
3509
|
+
const outcomes = await allSettled(ctx, rooms.map((room) => askRoom(room, query)));
|
|
3510
|
+
return outcomes.map((outcome, index) => ({ room: rooms[index], outcome }));
|
|
3511
|
+
}),
|
|
3512
|
+
);
|
|
3513
|
+
```
|
|
3514
|
+
|
|
3515
|
+
`all` would fail the whole search on the first slow room, which is the wrong behavior here. See [Effects](effects.md) for the others.
|
|
3516
|
+
|
|
3517
|
+
## Wait for a room before marking a result
|
|
3518
|
+
|
|
3519
|
+
**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.
|
|
3520
|
+
|
|
3521
|
+
**When.** A handler starts work in another domain and needs to continue after its outcome.
|
|
3522
|
+
|
|
3523
|
+
`take` waits for the next event of a name. Start it before the emit that causes the event, so nothing is missed:
|
|
3524
|
+
|
|
3525
|
+
```js
|
|
3526
|
+
import { createCommand } from "nanoeffects/actions";
|
|
3527
|
+
import { take } from "nanoeffects/effects";
|
|
3528
|
+
|
|
3529
|
+
// createCommand — an order for the messages domain, with the handler that does it
|
|
3530
|
+
const markMessage = createCommand("messages/mark", (messageId) => console.log("mark", messageId));
|
|
3531
|
+
|
|
3532
|
+
// the room that is open, kept from the rooms/selected event
|
|
3533
|
+
let selectedRoom = "general";
|
|
3534
|
+
|
|
3535
|
+
export const goToResult = async (hit, ctx) => {
|
|
3536
|
+
if (hit.roomId !== selectedRoom) {
|
|
3537
|
+
// take — wait for the next event of that name; started before the emit so nothing is missed
|
|
3538
|
+
const loaded = take(ctx, "messages/loadHistory/success");
|
|
3539
|
+
ctx.emit("rooms/selected", hit.roomId);
|
|
3540
|
+
await loaded;
|
|
3541
|
+
}
|
|
3542
|
+
// ctx.emit with a command — the messages domain marks the message
|
|
3543
|
+
ctx.emit(markMessage(hit.id));
|
|
3544
|
+
};
|
|
3545
|
+
```
|
|
3546
|
+
|
|
3547
|
+
## Poll for new messages
|
|
3548
|
+
|
|
3549
|
+
**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.
|
|
3550
|
+
|
|
3551
|
+
**When.** A feature has background work that lives as long as the feature does.
|
|
3552
|
+
|
|
3553
|
+
`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:
|
|
3554
|
+
|
|
3555
|
+
```js
|
|
3556
|
+
import { createModule } from "nanoeffects/actions";
|
|
3557
|
+
import { poll } from "nanoeffects/effects";
|
|
3558
|
+
|
|
3559
|
+
const watchUpdates = async (_, ctx) => {
|
|
3560
|
+
// the number of the last update seen; the first answer only tells where the server is
|
|
3561
|
+
let since;
|
|
3562
|
+
|
|
3563
|
+
const check = async () => {
|
|
3564
|
+
try {
|
|
3565
|
+
const updates = await api.loadUpdates(since, { signal: ctx.signal });
|
|
3566
|
+
since = updates.seq;
|
|
3567
|
+
for (const message of updates.messages) {
|
|
3568
|
+
// ctx.emit — after an abort it delivers nothing
|
|
3569
|
+
ctx.emit("incoming/received", message);
|
|
3570
|
+
}
|
|
3571
|
+
} catch {
|
|
3572
|
+
// one failed request must not end the polling: the next tick asks again
|
|
3573
|
+
}
|
|
3574
|
+
};
|
|
3575
|
+
|
|
3576
|
+
// poll — call `check` now and then every 3 s; an abort of ctx (an unmounted module) ends it
|
|
3577
|
+
await poll(ctx, check, 3000, { leading: true });
|
|
3578
|
+
};
|
|
3579
|
+
|
|
3580
|
+
export const incomingModule = createModule("incoming", (module) => {
|
|
3581
|
+
// spawn — a background task that lives as long as this module
|
|
3582
|
+
module.spawn(watchUpdates);
|
|
3583
|
+
});
|
|
3584
|
+
```
|
|
3585
|
+
|
|
3586
|
+
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.
|
|
3587
|
+
|
|
3588
|
+
## Know where each value lives
|
|
3589
|
+
|
|
3590
|
+
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).
|
|
3591
|
+
|
|
3592
|
+
## Where to go next
|
|
3593
|
+
|
|
3594
|
+
- The API of commands, jobs and modules: [Actions, commands, jobs and modules](actions.md).
|
|
3595
|
+
- Policies such as `latest` and `queue`: [Policies](policies.md). Effects such as `allSettled`, `take` and `poll`: [Effects](effects.md).
|
|
3596
|
+
- A smaller project with two domains: [Start with domains](starter.md#start-with-domains).
|
|
3597
|
+
|
|
3598
|
+
---
|
|
3599
|
+
Source: docs/vue.md
|
|
3600
|
+
|
|
3601
|
+
# Use nanoeffects in a Vue app
|
|
3602
|
+
|
|
3603
|
+
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).
|
|
3604
|
+
|
|
3605
|
+
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.
|
|
3606
|
+
|
|
3607
|
+
## Add the plugin to the bus
|
|
3608
|
+
|
|
3609
|
+
**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.
|
|
3610
|
+
|
|
3611
|
+
**When.** A Vue component sends values to the bus, reads events from it, or both.
|
|
3612
|
+
|
|
3613
|
+
`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:
|
|
3614
|
+
|
|
3615
|
+
```js
|
|
3616
|
+
// bus.js
|
|
3617
|
+
import { createBus } from "nanoeffects";
|
|
3618
|
+
import { vuePlugin } from "nanoeffects/vue";
|
|
3619
|
+
|
|
3620
|
+
// createBus — the event bus, created once outside Vue
|
|
3621
|
+
// vuePlugin — adds watchEmitter and useEventRef to the bus
|
|
3622
|
+
export const bus = createBus().use(vuePlugin());
|
|
3623
|
+
export const { watchEmitter, useEventRef } = bus; // free functions: they do not read `this`
|
|
3624
|
+
```
|
|
3625
|
+
|
|
3626
|
+
## Tell the bus that a value changed
|
|
3627
|
+
|
|
3628
|
+
<!-- docs-check: continue -->
|
|
3629
|
+
|
|
3630
|
+
`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.
|
|
3631
|
+
|
|
3632
|
+
```js
|
|
3633
|
+
// search.js
|
|
3634
|
+
import { createAction } from "nanoeffects/actions";
|
|
3635
|
+
|
|
3636
|
+
// createAction — an event with a name; calling it builds { type, payload }
|
|
3637
|
+
export const queryChanged = createAction("search/queryChanged");
|
|
3638
|
+
export const searchLoaded = createAction("search/loaded");
|
|
3639
|
+
```
|
|
3640
|
+
|
|
3641
|
+
```js
|
|
3642
|
+
// query-box.js
|
|
3643
|
+
import { ref } from "vue";
|
|
3644
|
+
import { watchEmitter } from "./bus";
|
|
3645
|
+
import { queryChanged } from "./search";
|
|
3646
|
+
|
|
3647
|
+
export const setup = () => {
|
|
3648
|
+
const query = ref("");
|
|
3649
|
+
|
|
3650
|
+
// watchEmitter — emits queryChanged(query) when query changes, never on mount
|
|
3651
|
+
watchEmitter(query, queryChanged);
|
|
3652
|
+
|
|
3653
|
+
return { query };
|
|
3654
|
+
};
|
|
3655
|
+
```
|
|
3656
|
+
|
|
3657
|
+
The third argument takes the options `equals`, `traceId` and `traceGroupId`:
|
|
3658
|
+
|
|
3659
|
+
| Option | Use it when |
|
|
3660
|
+
| ------ | ----------- |
|
|
3661
|
+
| `equals(previous, next)` | A new object with the same fields should not emit: `{ equals: (a, b) => a.id === b.id }`. |
|
|
3662
|
+
| `traceId` | You want the subscription to have a name in the [trace](tracing.md#show-which-subscription-began-a-chain). |
|
|
3663
|
+
| `traceGroupId` | You want to name the whole flow that the emit starts. |
|
|
3664
|
+
|
|
3665
|
+
`watchEmitter` returns the function that stops it early, for use outside a component.
|
|
3666
|
+
|
|
3667
|
+
## Show the result of an event
|
|
3668
|
+
|
|
3669
|
+
<!-- docs-check: continue -->
|
|
3670
|
+
|
|
3671
|
+
`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.
|
|
3672
|
+
|
|
3673
|
+
```js
|
|
3674
|
+
// found-list.js
|
|
3675
|
+
import { useEventRef } from "./bus";
|
|
3676
|
+
import { searchLoaded } from "./search";
|
|
3677
|
+
|
|
3678
|
+
export const setup = () => {
|
|
3679
|
+
// useEventRef — like ref([]), but searchLoaded sets the value
|
|
3680
|
+
const found = useEventRef(searchLoaded, []);
|
|
3681
|
+
|
|
3682
|
+
return { found };
|
|
3683
|
+
};
|
|
3684
|
+
```
|
|
3685
|
+
|
|
3686
|
+
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`.
|
|
3687
|
+
|
|
3688
|
+
### It is not a store
|
|
3689
|
+
|
|
3690
|
+
`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`.
|
|
3691
|
+
|
|
3692
|
+
## Where to go next
|
|
3693
|
+
|
|
3694
|
+
- The same two jobs for React: [Build a sign-up form in React](react.md).
|
|
3695
|
+
- 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.
|
|
3696
|
+
- Keep values in Pinia: [Connect Redux, Zustand, MobX, or Pinia](stores.md).
|
|
3697
|
+
|
|
3262
3698
|
---
|
|
3263
3699
|
Source: docs/context.md
|
|
3264
3700
|
|
|
@@ -4140,6 +4576,20 @@ const user = bus.emit(getUser("1")); // Promise<User>
|
|
|
4140
4576
|
|
|
4141
4577
|
`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
4578
|
|
|
4579
|
+
## Type a function that takes the signal
|
|
4580
|
+
|
|
4581
|
+
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.
|
|
4582
|
+
|
|
4583
|
+
```ts
|
|
4584
|
+
import type { WithSignal } from "nanoeffects";
|
|
4585
|
+
|
|
4586
|
+
// WithSignal — { signal: AbortSignal }; a task context fits it, and so does { signal } on its own
|
|
4587
|
+
const search = (query: string, { signal }: WithSignal) =>
|
|
4588
|
+
fetch(`/search?q=${encodeURIComponent(query)}`, { signal });
|
|
4589
|
+
```
|
|
4590
|
+
|
|
4591
|
+
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.
|
|
4592
|
+
|
|
4143
4593
|
## Keep the contract apart from the code
|
|
4144
4594
|
|
|
4145
4595
|
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 +5147,7 @@ Source: docs/migrating-from-rxjs.md
|
|
|
4697
5147
|
|
|
4698
5148
|
# Use nanoeffects next to RxJS
|
|
4699
5149
|
|
|
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
|
|
5150
|
+
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
5151
|
|
|
4702
5152
|
## Split the work
|
|
4703
5153
|
|
|
@@ -4779,6 +5229,63 @@ input.addEventListener("input", (event) => bus.emit("typed", event.target.value)
|
|
|
4779
5229
|
|
|
4780
5230
|
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
5231
|
|
|
5232
|
+
## Rewrite a polling loop
|
|
5233
|
+
|
|
5234
|
+
Check a job status every 5 seconds. Retry a failed check, and stop when the user leaves the screen.
|
|
5235
|
+
|
|
5236
|
+
The same job in RxJS: `timer` ticks, `exhaustMap` skips a tick while a check runs, `retry` repeats a failed check, `takeUntil` stops it.
|
|
5237
|
+
|
|
5238
|
+
<!-- docs-check: skip - needs rxjs, which the project does not depend on -->
|
|
5239
|
+
```js
|
|
5240
|
+
// RxJS
|
|
5241
|
+
import { timer } from "rxjs";
|
|
5242
|
+
import { exhaustMap, retry, takeUntil } from "rxjs/operators";
|
|
5243
|
+
|
|
5244
|
+
timer(5000, 5000)
|
|
5245
|
+
.pipe(
|
|
5246
|
+
exhaustMap(() => api.statusStream().pipe(retry({ count: 3, delay: 500 }))),
|
|
5247
|
+
takeUntil(leave$),
|
|
5248
|
+
)
|
|
5249
|
+
.subscribe((status) => render(status));
|
|
5250
|
+
```
|
|
5251
|
+
|
|
5252
|
+
The same job in nanoeffects. `poll` ticks, `retry` repeats one check, and `race` ends the loop on the first `"leave"` event:
|
|
5253
|
+
|
|
5254
|
+
```js
|
|
5255
|
+
import { createBus } from "nanoeffects";
|
|
5256
|
+
import { poll, race, retry, take } from "nanoeffects/effects";
|
|
5257
|
+
|
|
5258
|
+
const bus = createBus(); // the bus: events and handlers
|
|
5259
|
+
|
|
5260
|
+
bus.on("watch", async (_, ctx) => {
|
|
5261
|
+
// one check, up to 3 attempts, 500 ms between them
|
|
5262
|
+
const checkStatus = async () => {
|
|
5263
|
+
const status = await retry(ctx, () => api.status({ signal: ctx.signal }), {
|
|
5264
|
+
times: 3,
|
|
5265
|
+
delay: 500,
|
|
5266
|
+
});
|
|
5267
|
+
ctx.emit("status", status);
|
|
5268
|
+
};
|
|
5269
|
+
|
|
5270
|
+
await race(ctx, {
|
|
5271
|
+
// poll: a check every 5 s; waitForFn: the next pause starts after the check ends
|
|
5272
|
+
polling: ({ signal }) => poll({ signal }, checkStatus, 5000, { waitForFn: true }),
|
|
5273
|
+
// take: resolves at the next "leave", and race stops the polling
|
|
5274
|
+
leave: ({ signal }) => take(ctx, "leave", { signal }),
|
|
5275
|
+
});
|
|
5276
|
+
});
|
|
5277
|
+
|
|
5278
|
+
bus.on("status", (status) => render(status));
|
|
5279
|
+
|
|
5280
|
+
bus.emit("watch"); // start
|
|
5281
|
+
bus.emit("leave"); // stop
|
|
5282
|
+
```
|
|
5283
|
+
|
|
5284
|
+
Two things differ from the pipeline:
|
|
5285
|
+
|
|
5286
|
+
- `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.
|
|
5287
|
+
- 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.
|
|
5288
|
+
|
|
4782
5289
|
## Use both in one app
|
|
4783
5290
|
|
|
4784
5291
|
Keep RxJS where a stream is how you think, and let events cross to the bus in either direction.
|