nanoeffects 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/dist/{action-creator-D48fp13T.d.ts → action-creator-DOWcbXpy.d.ts} +16 -2
- package/dist/actions.d.ts +38 -3
- package/dist/actions.js +1 -1
- package/dist/{bus-Cho463Sq.js → bus-BAs9x7IQ.js} +1 -1
- package/dist/{context-DlA80GOA.js → context-CK0ZomWt.js} +1 -1
- package/dist/context-plugin-B0sLWOxR.d.ts +34 -0
- package/dist/dev.d.ts +2 -2
- package/dist/effects.d.ts +153 -56
- package/dist/effects.js +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1 -1
- package/dist/{module-DVX8aYlq.d.ts → module-DCJl-qfi.d.ts} +16 -2
- package/dist/notrace/{action-creator-D48fp13T.d.ts → action-creator-DOWcbXpy.d.ts} +16 -2
- package/dist/notrace/actions.d.ts +52 -3
- package/dist/notrace/actions.js +1 -1
- package/dist/notrace/{bus-B12SvxLv.js → bus-Bdk0YyFQ.js} +1 -1
- package/dist/notrace/{context-yAWj0BFZ.js → context-CctVmHLC.js} +1 -1
- package/dist/notrace/context-plugin-B0sLWOxR.d.ts +34 -0
- package/dist/notrace/{delay-Cgvn16oY.js → delay-B_W1tnkS.js} +1 -1
- package/dist/notrace/effects.d.ts +153 -56
- package/dist/notrace/effects.js +1 -1
- package/dist/notrace/index.d.ts +3 -2
- package/dist/notrace/index.js +1 -1
- package/dist/notrace/policies.d.ts +1 -1
- package/dist/notrace/policies.js +1 -1
- package/dist/notrace/redux.d.ts +18 -20
- package/dist/notrace/redux.js +1 -1
- package/dist/observable.d.ts +1 -1
- package/dist/policies.d.ts +1 -1
- package/dist/policies.js +1 -1
- package/dist/react.d.ts +50 -0
- package/dist/react.js +1 -0
- package/dist/redux.d.ts +18 -20
- package/dist/redux.js +1 -1
- package/dist/trace.d.ts +1 -1
- package/docs/README.md +7 -1
- package/docs/actions.md +1 -1
- package/docs/context.md +24 -2
- package/docs/effects.md +82 -5
- package/docs/guide.md +2 -0
- package/docs/migrating-from-effector.md +293 -0
- package/docs/migrating-from-rxjs.md +127 -0
- package/docs/migrating-from-saga.md +93 -0
- package/docs/observable.md +1 -0
- package/docs/package.md +12 -2
- package/docs/policies.md +1 -0
- package/docs/react.md +157 -0
- package/docs/stores.md +429 -40
- package/docs/typescript.md +153 -0
- package/llms-full.txt +2645 -0
- package/llms.txt +48 -0
- package/package.json +35 -6
package/dist/react.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { D as EventKey, _ as Bus, o as EventMap, r as FreeActionCreator, t as ActionCreator } from "./action-creator-DOWcbXpy.js";
|
|
2
|
+
//#region src/react/plugin.d.ts
|
|
3
|
+
/** What `reactPlugin` adds to the bus. */
|
|
4
|
+
/** Tells whether two values are the same: `Object.is` unless the caller gives another. */
|
|
5
|
+
type Equals<V> = (previous: V, next: V) => boolean;
|
|
6
|
+
type ReactApi<E extends EventMap> = {
|
|
7
|
+
/**
|
|
8
|
+
* A React hook: after a render commits with a `value` that differs from the
|
|
9
|
+
* previous one (`Object.is`, or the `equals` you pass), emits `event` with it. It does not emit on mount,
|
|
10
|
+
* not twice under StrictMode, and not for an equal value. Changes before one
|
|
11
|
+
* commit emit once, with the last value. The method does not read `this`, so
|
|
12
|
+
* `export const { useEmitter } = bus` gives a free hook.
|
|
13
|
+
*/
|
|
14
|
+
useEmitter: {
|
|
15
|
+
<K extends EventKey<E>>(event: K, value: E[K], equals?: Equals<E[K]>): void;
|
|
16
|
+
/** A creator without a payload type: the payload comes from the event map. */
|
|
17
|
+
<K extends EventKey<E>>(creator: FreeActionCreator<K>, value: E[K], equals?: Equals<E[K]>): void;
|
|
18
|
+
<P, T extends string>(creator: ActionCreator<P, T>, value: P, equals?: Equals<P>): void;
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Adds `bus.useEmitter(event, value)`: a hook that tells the bus when a value of
|
|
23
|
+
* a component changes, without the traps of `useEffect(() => emit(value), [value])`
|
|
24
|
+
* (an emit on mount, two on mount under StrictMode, a new `emit` re-running it).
|
|
25
|
+
* Pass `equals` as the third argument for values that `Object.is` tells apart
|
|
26
|
+
* although they are the same, such as a new object with the same fields.
|
|
27
|
+
* Needs `react` 18 or newer. Returns the same bus.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```tsx
|
|
31
|
+
* import { createBus } from "nanoeffects";
|
|
32
|
+
* import { createAction } from "nanoeffects/actions";
|
|
33
|
+
* import { reactPlugin } from "nanoeffects/react";
|
|
34
|
+
*
|
|
35
|
+
* export const bus = createBus().use(reactPlugin());
|
|
36
|
+
* export const { useEmitter } = bus; // a free hook
|
|
37
|
+
*
|
|
38
|
+
* const nameChanged = createAction<string>("name/changed");
|
|
39
|
+
* bus.on(nameChanged, (name) => console.log(name));
|
|
40
|
+
*
|
|
41
|
+
* const Profile = () => {
|
|
42
|
+
* const [name, setName] = useState("Adam");
|
|
43
|
+
* useEmitter(nameChanged, name); // nameChanged("…") on every change of name
|
|
44
|
+
* return <input value={name} onChange={(event) => setName(event.target.value)} />;
|
|
45
|
+
* };
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export declare const reactPlugin: () => <E extends EventMap>(bus: Bus<E>) => Bus<E> & ReactApi<E>;
|
|
49
|
+
//#endregion
|
|
50
|
+
export type { ReactApi };
|
package/dist/react.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{useEffect as e,useRef as t}from"react";const n=()=>n=>{function r(r,i,a){let o=t(i),s=t({event:r,equals:a});e(()=>{s.current={event:r,equals:a}}),e(()=>{let{event:e,equals:t}=s.current,r=t??Object.is,a=o.current;if(r(a,i))return;o.current=i;let c=typeof e==`function`?e(i):{type:e,payload:i};n.emit(c)},[i])}return Object.assign(n,{useEmitter:r})};export{n as reactPlugin};
|
package/dist/redux.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { _ as Bus, o as EventMap
|
|
1
|
+
import { _ as Bus, o as EventMap } from "./action-creator-DOWcbXpy.js";
|
|
2
2
|
//#region src/store/redux/middleware.d.ts
|
|
3
3
|
/** What Redux passes through a middleware: an action is a plain object with a `type`. */
|
|
4
4
|
type ReduxAction = {
|
|
@@ -8,6 +8,8 @@ type ReduxAction = {
|
|
|
8
8
|
type NanoeffectsMiddlewareOptions = {
|
|
9
9
|
/** Return `false` for the actions that the bus should not get. Default: all of them. */
|
|
10
10
|
filter?: (action: ReduxAction) => boolean;
|
|
11
|
+
/** Receives an error that `emit` throws, so `dispatch` does not. Default: the error is thrown. */
|
|
12
|
+
onError?: (error: unknown, action: ReduxAction) => void;
|
|
11
13
|
};
|
|
12
14
|
//#endregion
|
|
13
15
|
//#region src/store/redux/effects.d.ts
|
|
@@ -19,48 +21,44 @@ type ReduxStoreApi<State = unknown> = {
|
|
|
19
21
|
}) => unknown;
|
|
20
22
|
getState: () => State;
|
|
21
23
|
};
|
|
22
|
-
type EffectsMiddlewareOptions
|
|
23
|
-
/** More fields for the context of every task, next to `store`. */
|
|
24
|
-
context?: object;
|
|
25
|
-
};
|
|
24
|
+
type EffectsMiddlewareOptions = Pick<NanoeffectsMiddlewareOptions, "filter">;
|
|
26
25
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* `getState` of the store, as soon as
|
|
30
|
-
* in the event map:
|
|
26
|
+
* Makes the Redux middleware that feeds a bus you already have: the actions of
|
|
27
|
+
* the store reach its handlers, and every task gets `ctx.store`, the `dispatch`
|
|
28
|
+
* and `getState` of the store, as soon as Redux has installed the middleware.
|
|
29
|
+
* Type it in the event map of the bus:
|
|
31
30
|
*
|
|
32
31
|
* ```ts
|
|
33
32
|
* type Events = { userRequested: string } & WithContext<{ store: ReduxStoreApi<RootState> }>;
|
|
34
33
|
* ```
|
|
35
34
|
*
|
|
36
|
-
*
|
|
35
|
+
* Use one bus for one store: a second store would replace the first in the
|
|
36
|
+
* contexts. Two things are looked after for you:
|
|
37
37
|
*
|
|
38
38
|
* - **A handler does not loop on its own action.** An action that a handler
|
|
39
39
|
* dispatches through `ctx.store` and that has the type the handler works on
|
|
40
40
|
* does not go back to the bus (a handler of `a` that dispatches `a`). Other
|
|
41
41
|
* handlers of that type do not get it either. Longer cycles, `a` to `b` to
|
|
42
42
|
* `a`, are yours to avoid.
|
|
43
|
-
* - **A failing handler does not break `dispatch`.**
|
|
44
|
-
*
|
|
45
|
-
* still throw to
|
|
43
|
+
* - **A failing handler does not break `dispatch`.** With `onError` on the bus
|
|
44
|
+
* the bus isolates the handlers. Without it the error is written to
|
|
45
|
+
* `console.error`, as in Redux Toolkit listeners. Reducers still throw to
|
|
46
|
+
* the caller.
|
|
46
47
|
*
|
|
47
48
|
* @example
|
|
48
49
|
* ```ts
|
|
49
|
-
* const
|
|
50
|
+
* const bus = createBus<Events>();
|
|
50
51
|
*
|
|
51
52
|
* const store = configureStore({
|
|
52
53
|
* reducer,
|
|
53
|
-
* middleware: (getDefault) => getDefault().concat(
|
|
54
|
+
* middleware: (getDefault) => getDefault().concat(createEffectsMiddleware(bus)),
|
|
54
55
|
* });
|
|
55
56
|
*
|
|
56
|
-
*
|
|
57
|
+
* bus.on(userRequested, latest(async (id, { store, signal }) => {
|
|
57
58
|
* store.dispatch(userLoaded(await fetchUser(id, { signal })));
|
|
58
59
|
* }));
|
|
59
60
|
* ```
|
|
60
61
|
*/
|
|
61
|
-
export declare const createEffectsMiddleware: <E extends EventMap
|
|
62
|
-
bus: Bus<E>;
|
|
63
|
-
middleware: (storeApi: ReduxStoreApi) => (next: (action: unknown) => unknown) => (action: unknown) => unknown;
|
|
64
|
-
};
|
|
62
|
+
export declare const createEffectsMiddleware: <E extends EventMap>(bus: Bus<E>, options?: EffectsMiddlewareOptions) => (storeApi: ReduxStoreApi) => (next: (action: unknown) => unknown) => (action: unknown) => unknown;
|
|
65
63
|
//#endregion
|
|
66
64
|
export type { EffectsMiddlewareOptions, ReduxStoreApi };
|
package/dist/redux.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
const e=e=>typeof e==`object`&&!!e&&`type`in e,t=(t,n={})=>{let r=t.emit,i=e=>{try{r(e)}catch(t){if(!n.onError)throw t;n.onError(t,e)}};return()=>t=>r=>{let a=t(r);return e(r)&&(n.filter?.(r)??!0)&&i(r),a}},n=(e,n={})=>{let r,i=/* @__PURE__ */ new WeakMap,a=e.addContext;e.addContext=e=>{a?.(e);let t=r;if(!t)return;let{type:n}=e;Object.assign(e,{store:{getState:t.getState,dispatch:e=>{typeof e==`object`&&e&&i.set(e,n);let r=e;return t.dispatch(r)}}})};let o=t(e,{filter:e=>i.get(e)!==e.type&&(n.filter?.(e)??!0),onError:(e,{type:t})=>{globalThis.console?.error(`[nanoeffects] handler of "${t}" failed`,e)}});return e=>(r=e,o())};export{n as createEffectsMiddleware};
|
package/dist/trace.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { d as Trace, f as TraceEvent, p as TraceLogger, z as Tracer } from "./action-creator-DOWcbXpy.js";
|
|
2
2
|
//#region src/trace/console.d.ts
|
|
3
3
|
type ConsoleLoggerOptions = {
|
|
4
4
|
/** Defaults to `globalThis.console`. */
|
package/docs/README.md
CHANGED
|
@@ -14,14 +14,20 @@ Code for every guide step: [`examples/guide.ts`](../examples/guide.ts) (type-che
|
|
|
14
14
|
| SPA with async UI | [Policies](policies.md), [Guide § latest](guide.md#3-abort-the-previous-run-with-latest) |
|
|
15
15
|
| Unreliable network / long flows | [Effects](effects.md) — one helper per job, then the rest as reference |
|
|
16
16
|
| Domain logic off the UI | [Actions, commands, modules](actions.md) |
|
|
17
|
-
| Already on Redux / Zustand / MobX | [Stores](stores.md) |
|
|
17
|
+
| Already on Redux / Zustand / MobX / Pinia | [Stores](stores.md) |
|
|
18
|
+
| A React form with async checks | [Build a sign-up form in React](react.md) |
|
|
19
|
+
| Moving from redux-saga | [Coming from redux-saga](migrating-from-saga.md) |
|
|
20
|
+
| Using RxJS | [Use nanoeffects next to RxJS](migrating-from-rxjs.md) |
|
|
21
|
+
| Moving from Effector | [Coming from Effector](migrating-from-effector.md) |
|
|
18
22
|
|
|
19
23
|
## Reference
|
|
20
24
|
|
|
21
25
|
- [Policies](policies.md) — `latest`, `leading`, `debounce`, `throttle`, `queue`, `key`
|
|
22
26
|
- [Effects and cancellation](effects.md) — `retry`, `timeout`, `poll`, `take`, `all`, …
|
|
23
27
|
- [Actions, commands, jobs and modules](actions.md)
|
|
28
|
+
- [Sign-up form in React](react.md) — one form: races, double submit, retry
|
|
24
29
|
- [Context fields](context.md) — store or client on every task
|
|
30
|
+
- [TypeScript in many files](typescript.md) — keep the types of the bus in commands and modules
|
|
25
31
|
- [Errors and unheard events](errors.md)
|
|
26
32
|
- [Package entries](package.md) — which import holds what
|
|
27
33
|
- [Tracing](tracing.md) — loggers, Redux DevTools, `notrace`
|
package/docs/actions.md
CHANGED
|
@@ -109,7 +109,7 @@ The registry lives in `nanoeffects/actions`, so a bus without one carries none o
|
|
|
109
109
|
|
|
110
110
|
**When.** You want one mount / one unmount for that domain. Skip modules while a few top-level `bus.on` calls are enough.
|
|
111
111
|
|
|
112
|
-
`bus.use(modulePlugin(modules))` assembles an app from modules and returns `mount`, `unmount`, `isMounted`, `dispose`, and `
|
|
112
|
+
`bus.use(modulePlugin(modules))` assembles an app from modules and returns `mount`, `unmount`, `isMounted`, `dispose`, `commands`, and `createModule`: the same as `createModule` below, with the context fields of the bus typed (see [TypeScript](typescript.md)).
|
|
113
113
|
|
|
114
114
|
```js
|
|
115
115
|
import { createBus } from "nanoeffects";
|
package/docs/context.md
CHANGED
|
@@ -19,14 +19,36 @@ That is enough in JavaScript. The fields appear on handlers, `ctx.run`, `ctx.on`
|
|
|
19
19
|
|
|
20
20
|
Library fields (`signal`, `emit`, `run`, …) win on name clashes. `context` may be a function `(task) => fields` when the value depends on the task.
|
|
21
21
|
|
|
22
|
+
To get `race`, `all`, `take` and the like on every task without passing `ctx`, see [`effectsPlugin`](effects.md#put-selected-effects-on-ctx).
|
|
23
|
+
|
|
22
24
|
## Type the fields (optional)
|
|
23
25
|
|
|
24
|
-
`
|
|
26
|
+
In TypeScript, `contextPlugin` puts the fields on every task of **this** bus and takes their types from the value you pass. The event map holds only events. It does not wire anything across the app — another bus can have different fields or none.
|
|
25
27
|
|
|
26
28
|
```ts
|
|
27
|
-
import {
|
|
29
|
+
import { contextPlugin, createBus } from "nanoeffects";
|
|
28
30
|
|
|
29
31
|
// Events — event name → payload
|
|
32
|
+
type Events = { save: Draft };
|
|
33
|
+
|
|
34
|
+
// contextPlugin — every task gets ctx.store; the type is the type of `store`
|
|
35
|
+
const bus = createBus<Events>().use(contextPlugin({ store }));
|
|
36
|
+
|
|
37
|
+
bus.on("save", (draft, { store }) => store.put(draft));
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
- A function `(task) => fields` makes the fields per task. It gets `{ type, payload }`.
|
|
41
|
+
- Several `contextPlugin` calls, `effectsPlugin` and the `context` option of `createBus` all add their fields to the same context.
|
|
42
|
+
- A name that the bus already has is a type error. The fields of the library (`signal`, `emit`, `run`, …) win at runtime, and a field that the context already has is not replaced.
|
|
43
|
+
- The plugin returns the same bus, so keep the result of the chain: `const bus = createBus().use(contextPlugin({ store }))`.
|
|
44
|
+
|
|
45
|
+
### Name the fields in the event map instead
|
|
46
|
+
|
|
47
|
+
`createBus<Events>({ context: { store } })` also types the fields, but through the event map: `WithContext` is checker-only and says which fields the handlers of this bus have. Use it when you cannot call `use`, for example for the bus that `createEffectsMiddleware` makes.
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import { createBus, type WithContext } from "nanoeffects";
|
|
51
|
+
|
|
30
52
|
// WithContext — checker-only: handlers also have ctx.store
|
|
31
53
|
type Events = { save: Draft } & WithContext<{ store: Store }>;
|
|
32
54
|
|
package/docs/effects.md
CHANGED
|
@@ -84,16 +84,93 @@ Each attempt or call of a wrapper gets its own child context. Abort of the calle
|
|
|
84
84
|
- `fn` gets the call number from `0`.
|
|
85
85
|
- An error from `fn` stops polling and rejects. An abort ends it normally and clears the timer.
|
|
86
86
|
|
|
87
|
-
A pause can be a function wherever a time is given
|
|
87
|
+
A pause can be a function wherever a time is given. For retries with a cap and jitter, see [Back off between retries](#back-off-between-retries):
|
|
88
88
|
|
|
89
89
|
```js
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
90
|
+
import { createBus } from "nanoeffects";
|
|
91
|
+
import { delay, poll, retry } from "nanoeffects/effects";
|
|
92
|
+
|
|
93
|
+
const bus = createBus();
|
|
94
|
+
|
|
95
|
+
bus.on("sync", async (_, ctx) => {
|
|
96
|
+
retry(ctx, load, { times: 5, delay: (attempt) => 2 ** attempt * 100 }); // 200, 400, 800, …
|
|
97
|
+
poll(ctx, refresh, (call) => Math.min(1000 * 2 ** call, 60_000)); // slows to once a minute
|
|
98
|
+
await delay(ctx, () => 200 + Math.random() * 200); // 200–400 ms
|
|
99
|
+
});
|
|
93
100
|
```
|
|
94
101
|
|
|
95
102
|
All three are traced: the effect is a group (`retry`, `timeout`, `poll`) and each attempt or call is a `run` child.
|
|
96
103
|
|
|
104
|
+
## Back off between retries
|
|
105
|
+
|
|
106
|
+
Use this when a server or network fails under load. Retrying at once, or at the same fixed pause, sends every client back at the same moment and can keep the server down. Make the pause grow with each attempt, and add jitter so clients spread out.
|
|
107
|
+
|
|
108
|
+
The shortest form is a `delay` function. It needs no import:
|
|
109
|
+
|
|
110
|
+
```js
|
|
111
|
+
import { retry } from "nanoeffects/effects";
|
|
112
|
+
|
|
113
|
+
const loadWithRetry = retry(loadUser, {
|
|
114
|
+
times: 5,
|
|
115
|
+
delay: (attempt) => 2 ** attempt * 100, // 200, 400, 800, 1600 ms
|
|
116
|
+
});
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
When you also want a cap and jitter, build the function with `backoff`:
|
|
120
|
+
|
|
121
|
+
```js
|
|
122
|
+
import { backoff, retry } from "nanoeffects/effects";
|
|
123
|
+
|
|
124
|
+
const loadWithRetry = retry(loadUser, {
|
|
125
|
+
times: 5,
|
|
126
|
+
// backoff — pause 200, 400, 800, … ms, never over 5 s, a random part of it
|
|
127
|
+
delay: backoff({ base: 200, max: 5000, jitter: true }),
|
|
128
|
+
});
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
| Option | Meaning |
|
|
132
|
+
| ------ | ------- |
|
|
133
|
+
| `base` | pause before the second attempt, in ms (default `100`) |
|
|
134
|
+
| `factor` | each pause is this many times longer (default `2`) |
|
|
135
|
+
| `max` | longest pause, in ms (default none) |
|
|
136
|
+
| `jitter` | `true` picks a random pause from 0 up to the computed one (default `false`) |
|
|
137
|
+
|
|
138
|
+
- With `jitter: true` a pause can be close to 0, so treat `base` as a scale, not a minimum.
|
|
139
|
+
- `retry` counts attempts from 1. `poll` counts calls from 0, so with `poll` the first pause is `base / factor`.
|
|
140
|
+
- The result is a plain function `(attempt) => ms`, so it also fits `delay(ctx, …)`.
|
|
141
|
+
|
|
142
|
+
## Put selected effects on `ctx`
|
|
143
|
+
|
|
144
|
+
If handlers call the same helpers often, put them on the task once. A handler then destructures them like `signal`, with no `ctx` argument. Only the effects you pass are imported, so the rest stay out of the bundle ([package entries](package.md)).
|
|
145
|
+
|
|
146
|
+
```js
|
|
147
|
+
import { createBus } from "nanoeffects";
|
|
148
|
+
import { all, delay, effectsPlugin, race } from "nanoeffects/effects";
|
|
149
|
+
|
|
150
|
+
// effectsPlugin — add race, all and delay to the context of every task; returns the same bus
|
|
151
|
+
const bus = createBus().use(effectsPlugin({ race, all, delay }));
|
|
152
|
+
|
|
153
|
+
bus.on("load", async (id, { race, all, delay }) => {
|
|
154
|
+
// race — same as race(ctx, …)
|
|
155
|
+
const result = await race({
|
|
156
|
+
data: ({ signal }) => fetchData(id, { signal }),
|
|
157
|
+
timeout: () => delay(5000), // delay — same as delay(ctx, 5000)
|
|
158
|
+
});
|
|
159
|
+
if ("timeout" in result) throw new Error("timed out");
|
|
160
|
+
|
|
161
|
+
// all — same as all(ctx, …)
|
|
162
|
+
const { user, posts } = await all({
|
|
163
|
+
user: ({ signal }) => fetchUser(id, { signal }),
|
|
164
|
+
posts: ({ signal }) => fetchPosts(id, { signal }),
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
- Supported: `race`, `all`, `allSettled`, `take`, `delay`. The key is the effect name.
|
|
170
|
+
- `retry`, `timeout` and `poll` are not supported: bound to the task they would look like their handler wrappers.
|
|
171
|
+
- Fields from `createBus({ context })` stay. TypeScript rejects an effect whose name is already a context field of the bus, including one added by an earlier `effectsPlugin`.
|
|
172
|
+
- Free forms keep working: `race(ctx, …)`.
|
|
173
|
+
|
|
97
174
|
## Tie a promise to the task signal
|
|
98
175
|
|
|
99
176
|
`delay(ctx, ms)` waits and rejects on abort (`ms` can be a function).
|
|
@@ -179,7 +256,7 @@ bus.on("login", async (user, ctx) => {
|
|
|
179
256
|
```
|
|
180
257
|
|
|
181
258
|
- Sees only events emitted after the call, takes one, and unsubscribes.
|
|
182
|
-
- `options.predicate` skips payloads it rejects.
|
|
259
|
+
- `options.predicate` skips payloads it rejects. In TypeScript, a type guard (`(payload): payload is Admin`) narrows the type of the result.
|
|
183
260
|
- `options.signal` stops the wait (how a `race` or `timeout` starter cancels it).
|
|
184
261
|
- On abort of the task or of `options.signal`, unsubscribes and rejects with the abort reason.
|
|
185
262
|
|
package/docs/guide.md
CHANGED
|
@@ -294,6 +294,8 @@ Modules also make [hot reload](hmr.md) work. Full API in [Actions](actions.md);
|
|
|
294
294
|
Describe events once when you want `on` / `emit` checked. An event without data is `void`. Skip this while you are learning the bus.
|
|
295
295
|
|
|
296
296
|
```ts
|
|
297
|
+
import { createBus } from "nanoeffects";
|
|
298
|
+
|
|
297
299
|
// Events — event name → payload for this bus (checker only)
|
|
298
300
|
type Events = {
|
|
299
301
|
greet: string;
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# Coming from Effector
|
|
2
|
+
|
|
3
|
+
Read this page if your project uses Effector and you want to know where nanoeffects fits. They overlap in one place: events that start effects. Effector also holds state; nanoeffects does not. It maps the names, shows one effect both ways, and lists what has no equivalent.
|
|
4
|
+
|
|
5
|
+
## Split the work
|
|
6
|
+
|
|
7
|
+
| Job | Use |
|
|
8
|
+
| --- | --- |
|
|
9
|
+
| Hold state (`createStore`, `combine`, `restore`) | Effector, or any store: [Stores](stores.md) |
|
|
10
|
+
| Derive state from events (`.on`, `sample` into a store) | Effector, or any store |
|
|
11
|
+
| Run a request when an event happens, cancel, retry, time out, trace | nanoeffects |
|
|
12
|
+
|
|
13
|
+
nanoeffects has no store. `createStore` can be replaced by any store you like, for example Zustand: a store and its `set` do the job of `createStore` and `.on`. [Stores](stores.md) shows how each of Zustand, Redux, MobX, Jotai and others calls the bus. If you keep Effector, use nanoeffects only for the effects that `createEffect` runs with little control over overlapping calls.
|
|
14
|
+
|
|
15
|
+
## Find the equivalent
|
|
16
|
+
|
|
17
|
+
| Effector | nanoeffects |
|
|
18
|
+
| -------- | ----------- |
|
|
19
|
+
| `createEvent()` | an event name on the bus, or [`createAction`](actions.md) |
|
|
20
|
+
| event call `loaded(user)` | `bus.emit("loaded", user)` |
|
|
21
|
+
| `createEffect(handler)` | a handler: `bus.on(event, handler)` |
|
|
22
|
+
| effect with `done` and `fail` | [`createJob`](actions.md): emits the success and fail actions |
|
|
23
|
+
| `effect.pending` | not provided: keep a flag in your store, or read it from [Tracing](tracing.md) |
|
|
24
|
+
| `sample({ clock, fn, target })` | `bus.on(clock, handler)` and `ctx.emit(target, …)` |
|
|
25
|
+
| `sample({ source: $store, clock })` | read the store from a [context field](context.md) in the handler |
|
|
26
|
+
| `sample({ filter })` | an `if` in the handler |
|
|
27
|
+
| `debounce`, `throttle` from patronum | [`debounce`, `throttle`](policies.md) (same rules as lodash, checked in tests) |
|
|
28
|
+
| take only the last effect call | [`latest`](policies.md) |
|
|
29
|
+
| `attach` | a [context field](context.md): the handler reads it from `ctx` |
|
|
30
|
+
| `fork()` and scope | one `createBus()` per scope |
|
|
31
|
+
| `createStore`, `combine`, `restore` | any store, such as Zustand, Redux, MobX or Jotai: see [Stores](stores.md) |
|
|
32
|
+
| `watch`, `createWatch` | `bus.on(event, handler)` |
|
|
33
|
+
| `event.prepend(fn)` | a function that you write, `(event) => ({ key, value })`; the bus needs nothing |
|
|
34
|
+
| `useStoreMap({ store, keys, fn })` | a selector of your store; with `useState` the component keeps the value |
|
|
35
|
+
| `event.watch((e) => e.preventDefault())` | call `event.preventDefault()` in the `onSubmit` handler |
|
|
36
|
+
|
|
37
|
+
## Rewrite an effect
|
|
38
|
+
|
|
39
|
+
A search that runs on every query, and reacts to the result:
|
|
40
|
+
|
|
41
|
+
<!-- docs-check: skip - needs effector, which the project does not depend on -->
|
|
42
|
+
```js
|
|
43
|
+
// Effector
|
|
44
|
+
import { createEffect, createEvent, createStore, sample } from "effector";
|
|
45
|
+
|
|
46
|
+
const queryChanged = createEvent();
|
|
47
|
+
const searchFx = createEffect((query) => api.search(query));
|
|
48
|
+
const $items = createStore([]).on(searchFx.doneData, (_, items) => items);
|
|
49
|
+
|
|
50
|
+
sample({ clock: queryChanged, target: searchFx });
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
If the user types fast, several `searchFx` calls overlap, and the old ones are not cancelled. The same job in nanoeffects, with the old request cancelled:
|
|
54
|
+
|
|
55
|
+
```js
|
|
56
|
+
import { createBus } from "nanoeffects";
|
|
57
|
+
import { debounce, latest } from "nanoeffects/policies";
|
|
58
|
+
|
|
59
|
+
const bus = createBus(); // the bus: events and handlers
|
|
60
|
+
|
|
61
|
+
// latest: a newer query aborts the request in flight
|
|
62
|
+
const search = latest(async (query, { signal }) => {
|
|
63
|
+
const items = await api.search(query, { signal });
|
|
64
|
+
bus.emit("found", items);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
bus.on("query-changed", debounce(300, search)); // debounce: run after 300 ms of quiet
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The store (`$items`) is not here. Keep it in Effector and update it from the `found` event, or replace it with a store of your choice. The next example does that with Zustand.
|
|
71
|
+
|
|
72
|
+
### Keep the state in Zustand
|
|
73
|
+
|
|
74
|
+
Without Effector, something must hold `$items`. A Zustand store does it: `create` replaces `createStore`, and `setState` replaces `.on(searchFx.doneData, …)`. The handler writes the result to the store directly, so the `found` event is not needed. A component reads the store with a selector, as `useUnit($items)` did.
|
|
75
|
+
|
|
76
|
+
```jsx
|
|
77
|
+
// search-zustand.jsx
|
|
78
|
+
import { create } from "zustand";
|
|
79
|
+
import { createBus } from "nanoeffects";
|
|
80
|
+
import { debounce, latest } from "nanoeffects/policies";
|
|
81
|
+
|
|
82
|
+
// create — Zustand: the store replaces $items
|
|
83
|
+
const useSearchStore = create(() => ({ items: [] }));
|
|
84
|
+
|
|
85
|
+
const bus = createBus(); // the bus: events and handlers
|
|
86
|
+
|
|
87
|
+
// latest — a newer query aborts the request in flight; debounce — wait 300 ms of quiet
|
|
88
|
+
const search = latest(async (query, { signal }) => {
|
|
89
|
+
const items = await api.search(query, { signal });
|
|
90
|
+
useSearchStore.setState({ items }); // Zustand: replaces .on(searchFx.doneData, …)
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
bus.on("query-changed", debounce(300, search));
|
|
94
|
+
|
|
95
|
+
export function Search() {
|
|
96
|
+
const items = useSearchStore((state) => state.items); // Zustand: a selector reads items
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div>
|
|
100
|
+
<input onChange={(event) => bus.emit("query-changed", event.target.value)} />
|
|
101
|
+
<ul>
|
|
102
|
+
{items.map((item) => (
|
|
103
|
+
<li key={item.id}>{item.title}</li>
|
|
104
|
+
))}
|
|
105
|
+
</ul>
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`bus.emit("query-changed", …)` in `onChange` does the work of calling the `queryChanged` event. To also start a task when the store changes, see [Zustand in Stores](stores.md#zustand).
|
|
112
|
+
|
|
113
|
+
## Rewrite a form
|
|
114
|
+
|
|
115
|
+
The form from the [Forms recipe of the Effector docs](https://effector.dev/en/recipes/react/forms/) has five parts: an event for a change of a field (`fieldUpdate`), a store for the values (`$form`), an event for the submit (`formSubmitted`), an effect that sends them (`sendFormFx`), and a `sample` that reads the store when the form is submitted. Four of them keep state or wire units together. Only the effect has a counterpart in nanoeffects.
|
|
116
|
+
|
|
117
|
+
<!-- docs-check: skip - needs effector, which the project does not depend on -->
|
|
118
|
+
```jsx
|
|
119
|
+
// Effector (adapted from the Effector docs)
|
|
120
|
+
import { createEffect, createEvent, createStore, sample } from "effector";
|
|
121
|
+
import { useStoreMap } from "effector-react";
|
|
122
|
+
|
|
123
|
+
const formSubmitted = createEvent();
|
|
124
|
+
const fieldUpdate = createEvent();
|
|
125
|
+
|
|
126
|
+
const sendFormFx = createEffect((params) => {
|
|
127
|
+
console.log(params);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const $form = createStore({});
|
|
131
|
+
$form.on(fieldUpdate, (form, { key, value }) => ({ ...form, [key]: value }));
|
|
132
|
+
|
|
133
|
+
// sample: when the form is submitted, read $form and run the effect with it
|
|
134
|
+
sample({ clock: formSubmitted, source: $form, target: sendFormFx });
|
|
135
|
+
|
|
136
|
+
// prepend: turn a DOM event into the { key, value } that fieldUpdate takes
|
|
137
|
+
const handleChange = fieldUpdate.prepend((event) => ({
|
|
138
|
+
key: event.target.name,
|
|
139
|
+
value: event.target.value,
|
|
140
|
+
}));
|
|
141
|
+
|
|
142
|
+
const Field = ({ name, type, label }) => {
|
|
143
|
+
const value = useStoreMap({
|
|
144
|
+
store: $form,
|
|
145
|
+
keys: [name],
|
|
146
|
+
fn: (values) => values[name] ?? "",
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<div>
|
|
151
|
+
{label} <input name={name} type={type} value={value} onChange={handleChange} />
|
|
152
|
+
</div>
|
|
153
|
+
);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const App = () => (
|
|
157
|
+
<form onSubmit={formSubmitted}>
|
|
158
|
+
<Field name="login" label="Login" />
|
|
159
|
+
<Field name="password" type="password" label="Password" />
|
|
160
|
+
<button type="submit">Submit!</button>
|
|
161
|
+
</form>
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
formSubmitted.watch((event) => {
|
|
165
|
+
event.preventDefault();
|
|
166
|
+
});
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
The same form with nanoeffects. The values live in React state: nanoeffects holds none, and any store of [Stores](stores.md) works the same way. The effect becomes a handler of the submit event. The values travel in the event, so no `sample` and no `source` are needed. First the bus, in its own file:
|
|
170
|
+
|
|
171
|
+
```js
|
|
172
|
+
// form-bus.js
|
|
173
|
+
import { createBus } from "nanoeffects";
|
|
174
|
+
|
|
175
|
+
export const bus = createBus(); // the bus: events and their handlers
|
|
176
|
+
|
|
177
|
+
// bus.on — the job of sendFormFx: runs when the form is submitted
|
|
178
|
+
bus.on("form/submitted", (values) => {
|
|
179
|
+
console.log(values);
|
|
180
|
+
});
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Then the form. `useState` does the work of `$form`, `fieldUpdate` and `prepend`; the event handler does the work of the `watch` that called `preventDefault`:
|
|
184
|
+
|
|
185
|
+
```jsx
|
|
186
|
+
// App.jsx
|
|
187
|
+
import { useState } from "react";
|
|
188
|
+
import { bus } from "./form-bus"; // the bus of the previous snippet
|
|
189
|
+
|
|
190
|
+
function Field({ name, type, label, value, onChange }) {
|
|
191
|
+
return (
|
|
192
|
+
<div>
|
|
193
|
+
{label} <input name={name} type={type} value={value} onChange={onChange} />
|
|
194
|
+
</div>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function App() {
|
|
199
|
+
const [form, setForm] = useState({}); // the values of the form, as $form held them
|
|
200
|
+
|
|
201
|
+
// one handler for every field, as handleChange was
|
|
202
|
+
function handleChange(event) {
|
|
203
|
+
const { name, value } = event.target;
|
|
204
|
+
setForm((current) => ({ ...current, [name]: value }));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function handleSubmit(event) {
|
|
208
|
+
event.preventDefault(); // the page must not reload; no separate watcher is needed
|
|
209
|
+
bus.emit("form/submitted", form); // bus.emit — the values go in the event
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return (
|
|
213
|
+
<form onSubmit={handleSubmit}>
|
|
214
|
+
<Field name="login" label="Login" value={form.login ?? ""} onChange={handleChange} />
|
|
215
|
+
<Field
|
|
216
|
+
name="password"
|
|
217
|
+
type="password"
|
|
218
|
+
label="Password"
|
|
219
|
+
value={form.password ?? ""}
|
|
220
|
+
onChange={handleChange}
|
|
221
|
+
/>
|
|
222
|
+
<button type="submit">Submit!</button>
|
|
223
|
+
</form>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
`useStoreMap` re-renders only the field whose value changed. Here every field re-renders with the form, which is fine for a small form. If it matters, use the selector of your store (Zustand and Jotai have one).
|
|
229
|
+
|
|
230
|
+
If the values live in a store outside React, give the store to the handlers with `createBus({ context: { store } })` ([Context fields](context.md)) and read it in the handler, as `sample` read `$form`.
|
|
231
|
+
|
|
232
|
+
### Replace the log with a request
|
|
233
|
+
|
|
234
|
+
`sendFormFx` only logs. A real sign-up sends a request, and then the double click and the network matter. `leading` drops a second submit while the first one runs, and `ctx.signal` lets the request be cancelled:
|
|
235
|
+
|
|
236
|
+
```js
|
|
237
|
+
import { createBus } from "nanoeffects";
|
|
238
|
+
import { leading } from "nanoeffects/policies";
|
|
239
|
+
|
|
240
|
+
const bus = createBus(); // the bus: events and their handlers
|
|
241
|
+
|
|
242
|
+
// leading — a second submit is ignored while the request is running
|
|
243
|
+
bus.on(
|
|
244
|
+
"form/submitted",
|
|
245
|
+
leading(async (values, ctx) => {
|
|
246
|
+
// api — your client; ctx.signal lets the request be cancelled
|
|
247
|
+
await api.signUp(values, { signal: ctx.signal });
|
|
248
|
+
}),
|
|
249
|
+
);
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
Retrying a failed network request, and cancelling when the form closes, are in [Build a sign-up form in React](react.md).
|
|
253
|
+
|
|
254
|
+
## Use both in one app
|
|
255
|
+
|
|
256
|
+
Keep the Effector stores, and run the effects on the bus. A store calls the bus through `bus.emitter`, and the handler sends the result back as an event that the store listens to.
|
|
257
|
+
|
|
258
|
+
<!-- docs-check: skip - needs effector, which the project does not depend on -->
|
|
259
|
+
```js
|
|
260
|
+
import { createEvent, createStore } from "effector";
|
|
261
|
+
import { createBus } from "nanoeffects";
|
|
262
|
+
import { createAction, emitterPlugin } from "nanoeffects/actions";
|
|
263
|
+
import { latest } from "nanoeffects/policies";
|
|
264
|
+
|
|
265
|
+
const bus = createBus().use(emitterPlugin()); // adds bus.emitter
|
|
266
|
+
|
|
267
|
+
const queryChanged = createAction("query/changed"); // a plain action creator
|
|
268
|
+
|
|
269
|
+
const found = createEvent(); // Effector: the result comes back as an event
|
|
270
|
+
const $items = createStore([]).on(found, (_, items) => items);
|
|
271
|
+
const $query = createStore("");
|
|
272
|
+
|
|
273
|
+
// watch calls back on every change of the store; bus.emitter turns it into an emit
|
|
274
|
+
$query.watch(bus.emitter(queryChanged));
|
|
275
|
+
|
|
276
|
+
bus.on(
|
|
277
|
+
queryChanged,
|
|
278
|
+
latest(async (query, { signal }) => {
|
|
279
|
+
const items = await api.search(query, { signal });
|
|
280
|
+
found(items); // back to Effector
|
|
281
|
+
}),
|
|
282
|
+
);
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
## What is different
|
|
286
|
+
|
|
287
|
+
- **No state.** There is no `createStore`, `combine`, `restore`, `$store.map`. The bus holds listeners only. Take a store such as Zustand for state ([Stores](stores.md)).
|
|
288
|
+
- **No `pending`.** A running task is visible through [Tracing](tracing.md), not through a store. A flag in your store set at the start and the end of the handler does the job.
|
|
289
|
+
- **Cancellation is the default tool.** An Effector effect runs every call to the end. Here `latest` and `ctx.signal` stop what is no longer needed.
|
|
290
|
+
- **Events are matched by name, not by identity.** An event is a string or a `createAction` creator, not a unit that you import and call. Two modules agree on the name.
|
|
291
|
+
- **No graph.** `sample` and `forward` build a static graph. A bus subscribes handlers at runtime and the call order is the order of `on`.
|
|
292
|
+
- **The event carries the data.** `sample({ source })` reads a store at the moment of the clock. Here the sender puts the values in the event (`bus.emit("form/submitted", form)`), or the handler reads a store from a [context field](context.md).
|
|
293
|
+
- **Scopes are buses.** `fork()` becomes a `createBus()` per scope (a test, a request on the server).
|