nanoeffects 0.17.0 → 0.18.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/react.d.ts +23 -0
- package/dist/react.js +1 -1
- package/docs/react.md +62 -0
- package/docs/starter.md +10 -2
- package/llms-full.txt +73 -3
- package/llms.txt +2 -2
- package/package.json +4 -3
package/dist/react.d.ts
CHANGED
|
@@ -24,7 +24,30 @@ type ReactApi<E extends EventMap> = {
|
|
|
24
24
|
<K extends EventKey<E>>(creator: FreeActionCreator<K>, value: E[K], options?: UseEmitterOptions<E[K]>): void;
|
|
25
25
|
<P, T extends string>(creator: ActionCreator<P, T>, value: P, options?: UseEmitterOptions<P>): void;
|
|
26
26
|
};
|
|
27
|
+
/**
|
|
28
|
+
* A React hook with the signature of `useState`: returns `[state, setState]`, and
|
|
29
|
+
* every emit of `event` sets the state to its payload while the component is
|
|
30
|
+
* mounted. `initial` may be a function, as in `useState`. The bus keeps no values,
|
|
31
|
+
* so an event emitted before the first commit is not seen: read a current value
|
|
32
|
+
* into `initial` when that matters. The method does not read `this`, so
|
|
33
|
+
* `export const { useEventState } = bus` gives a free hook.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```tsx
|
|
37
|
+
* const [found, setFound] = useEventState(searchLoaded, []); // [] until the first searchLoaded
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
useEventState: {
|
|
41
|
+
<K extends EventKey<E>, S>(event: K, initial: StateOf<E[K], S> | (() => StateOf<E[K], S>)): EventState<StateOf<E[K], S>>;
|
|
42
|
+
/** A creator without a payload type: the payload comes from the event map. */
|
|
43
|
+
<K extends EventKey<E>, S>(creator: FreeActionCreator<K>, initial: StateOf<E[K], S> | (() => StateOf<E[K], S>)): EventState<StateOf<E[K], S>>;
|
|
44
|
+
<P, T extends string>(creator: ActionCreator<P, T>, initial: P | (() => P)): EventState<P>;
|
|
45
|
+
};
|
|
27
46
|
};
|
|
47
|
+
/** What `useEventState` returns: the pair of `useState`, with the payload of the event as state. */
|
|
48
|
+
type EventState<P> = [P, (next: P | ((previous: P) => P)) => void];
|
|
49
|
+
type Widen<S> = [S] extends [never[]] ? unknown[] : S;
|
|
50
|
+
type StateOf<P, S> = unknown extends P ? Widen<S> : P;
|
|
28
51
|
/**
|
|
29
52
|
* Adds `bus.useEmitter(event, value)`: a hook that tells the bus when a value of
|
|
30
53
|
* a component changes, without the traps of `useEffect(() => emit(value), [value])`
|
package/dist/react.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{t as e}from"./with-policy-CW50Edgk.js";import{t}from"./origin-key-BbWN3gmN.js";import{useEffect as n,useMemo as r,useRef as i}from"react";const
|
|
1
|
+
import{t as e}from"./with-policy-CW50Edgk.js";import{t}from"./origin-key-BbWN3gmN.js";import{useEffect as n,useMemo as r,useRef as i,useState as a}from"react";const o=()=>e=>{function r(r,a,o){let s=i(a),c=i({event:r,options:o});n(()=>{c.current={event:r,options:o}}),n(()=>{let{event:n,options:r}=c.current,i=r?.equals??Object.is,o=s.current;if(i(o,a))return;s.current=a;let l=typeof n==`function`?n(a):{type:n,payload:a},u=Reflect.get(e,t);u?u(l,r??{}):e.emit(l)},[a])}function o(t,r){let[i,o]=a(r);return n(()=>{let n=t;return e.on(n,e=>{let t=e;o(()=>t)})},[t]),[i,o]}return Object.assign(e,{useEmitter:r,useEventState:o})},s=(t,a,o=[])=>{let s=i(t);n(()=>{s.current=t});let c=r(()=>e(a((e,t)=>s.current(e,t))),o);return n(()=>()=>c.cancel(),[c]),c};export{o as reactPlugin,s as usePolicy};
|
package/docs/react.md
CHANGED
|
@@ -162,6 +162,68 @@ const checkName = latest(async (name, ctx) => {
|
|
|
162
162
|
|
|
163
163
|
In the component, add `bus.emit("form/closed")` to the cleanup function of the effect. This is optional: skip it when finishing a request after unmount does no harm, as for a cheap check.
|
|
164
164
|
|
|
165
|
+
## Show the last value of an event as state
|
|
166
|
+
|
|
167
|
+
**Problem.** A component often needs the latest result of an event, such as the found items of a search. The hand-written form is easy to get slightly wrong:
|
|
168
|
+
|
|
169
|
+
<!-- docs-check: skip - the hand-written form that this section replaces -->
|
|
170
|
+
```jsx
|
|
171
|
+
useEffect(() => bus.on(searchLoaded, setFound), []);
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
It needs a `useState` next to it, the empty `[]` hides the dependency on the event, and it breaks if the payload is a function, because React calls `setFound(payload)` as an updater.
|
|
175
|
+
|
|
176
|
+
**When.** The value you show comes from the bus, not from the component. For the opposite direction, a value of the component that the bus must hear, use `useEmitter`.
|
|
177
|
+
|
|
178
|
+
`useEventState(event, initial)` has the signature of `useState`: it returns `[state, setState]`. `initial` is the state before the first event and may be a function, as in `useState`. After that, every emit of `event` sets the state to its payload. The hook unsubscribes when the component unmounts.
|
|
179
|
+
|
|
180
|
+
```js
|
|
181
|
+
// bus.js
|
|
182
|
+
import { createBus } from "nanoeffects";
|
|
183
|
+
import { reactPlugin } from "nanoeffects/react";
|
|
184
|
+
|
|
185
|
+
// createBus — the event bus, created once outside React
|
|
186
|
+
// reactPlugin — adds the hooks bus.useEmitter and bus.useEventState
|
|
187
|
+
export const bus = createBus().use(reactPlugin());
|
|
188
|
+
export const { useEventState } = bus; // a free hook: components import it without the bus
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
```js
|
|
192
|
+
// search.js
|
|
193
|
+
import { createAction } from "nanoeffects/actions";
|
|
194
|
+
|
|
195
|
+
// createAction — the event name; the handlers emit it with the found titles
|
|
196
|
+
export const searchLoaded = createAction("search/loaded");
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
```jsx
|
|
200
|
+
// Found.jsx
|
|
201
|
+
import { useEventState } from "./bus";
|
|
202
|
+
import { searchLoaded } from "./search";
|
|
203
|
+
|
|
204
|
+
export function Found() {
|
|
205
|
+
// useEventState — like useState([]), but searchLoaded sets the value
|
|
206
|
+
const [found, setFound] = useEventState(searchLoaded, []);
|
|
207
|
+
|
|
208
|
+
return (
|
|
209
|
+
<>
|
|
210
|
+
<ul>
|
|
211
|
+
{found.map((title) => (
|
|
212
|
+
<li key={title}>{title}</li>
|
|
213
|
+
))}
|
|
214
|
+
</ul>
|
|
215
|
+
<button onClick={() => setFound([])}>Clear</button>
|
|
216
|
+
</>
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
`setFound` is the setter of React. It changes the state of this component only and emits nothing.
|
|
222
|
+
|
|
223
|
+
In TypeScript, the state type comes from the event: from `createAction<string[]>("search/loaded")` or from the event map of the bus. When the event has no payload type, as in the example above, it comes from `initial`: `["a"]` is `string[]`, and an empty `[]` is `unknown[]`. Give the event a type, or pass a typed `initial`, when you need more.
|
|
224
|
+
|
|
225
|
+
The bus keeps no values, so the hook cannot replay an event. An event emitted before the component mounts is not seen: if that matters, read the current value into `initial`.
|
|
226
|
+
|
|
165
227
|
## Keep a debounced handler between renders
|
|
166
228
|
|
|
167
229
|
**Problem.** A component runs again on every render. A policy made in its body, such as `debounce(300, search)`, is made again each time and loses its timer. A policy made once keeps the callback of the first render, which reads old props and state.
|
package/docs/starter.md
CHANGED
|
@@ -43,15 +43,23 @@ Pass `--template` to skip the question. With npm, put `--` before the option.
|
|
|
43
43
|
| Template | What it shows | Use it when |
|
|
44
44
|
| -------- | ------------- | ----------- |
|
|
45
45
|
| `vanilla` | A bus, one event and a `debounce` plus `latest` handler, with plain DOM code | You use no framework, or want to see the bus alone |
|
|
46
|
-
| `react` | The same handler with `useEmitter`, and `usePolicy` for a debounced save in a component | You build a React app |
|
|
46
|
+
| `react` | The same handler with `useEmitter` and `useEventState`, and `usePolicy` for a debounced save in a component | You build a React app |
|
|
47
|
+
| `vanilla-ts` | The `vanilla` app in TypeScript | You want the compiler to check events and handlers |
|
|
48
|
+
| `react-ts` | The `react` app in TypeScript | You build a React app in TypeScript |
|
|
47
49
|
|
|
48
50
|
```bash
|
|
49
51
|
npm create nanoeffects@latest my-app -- --template react
|
|
50
52
|
```
|
|
51
53
|
|
|
54
|
+
The TypeScript templates are the same apps with the same files, written in `.ts` and `.tsx`. Events are made with `createAction<Payload>`, so handlers and `useEventState` get their types from the events, with no annotation in the component. `npm run build` type-checks the project (`tsc --noEmit`) before bundling it.
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
npm create nanoeffects@latest my-app -- --template react-ts
|
|
58
|
+
```
|
|
59
|
+
|
|
52
60
|
## Read the files
|
|
53
61
|
|
|
54
|
-
|
|
62
|
+
Every template 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).
|
|
55
63
|
|
|
56
64
|
| File | Job |
|
|
57
65
|
| ---- | --- |
|
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`; `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/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
|
---
|
|
@@ -548,15 +548,23 @@ Pass `--template` to skip the question. With npm, put `--` before the option.
|
|
|
548
548
|
| Template | What it shows | Use it when |
|
|
549
549
|
| -------- | ------------- | ----------- |
|
|
550
550
|
| `vanilla` | A bus, one event and a `debounce` plus `latest` handler, with plain DOM code | You use no framework, or want to see the bus alone |
|
|
551
|
-
| `react` | The same handler with `useEmitter`, and `usePolicy` for a debounced save in a component | You build a React app |
|
|
551
|
+
| `react` | The same handler with `useEmitter` and `useEventState`, and `usePolicy` for a debounced save in a component | You build a React app |
|
|
552
|
+
| `vanilla-ts` | The `vanilla` app in TypeScript | You want the compiler to check events and handlers |
|
|
553
|
+
| `react-ts` | The `react` app in TypeScript | You build a React app in TypeScript |
|
|
552
554
|
|
|
553
555
|
```bash
|
|
554
556
|
npm create nanoeffects@latest my-app -- --template react
|
|
555
557
|
```
|
|
556
558
|
|
|
559
|
+
The TypeScript templates are the same apps with the same files, written in `.ts` and `.tsx`. Events are made with `createAction<Payload>`, so handlers and `useEventState` get their types from the events, with no annotation in the component. `npm run build` type-checks the project (`tsc --noEmit`) before bundling it.
|
|
560
|
+
|
|
561
|
+
```bash
|
|
562
|
+
npm create nanoeffects@latest my-app -- --template react-ts
|
|
563
|
+
```
|
|
564
|
+
|
|
557
565
|
## Read the files
|
|
558
566
|
|
|
559
|
-
|
|
567
|
+
Every template 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).
|
|
560
568
|
|
|
561
569
|
| File | Job |
|
|
562
570
|
| ---- | --- |
|
|
@@ -3114,6 +3122,68 @@ const checkName = latest(async (name, ctx) => {
|
|
|
3114
3122
|
|
|
3115
3123
|
In the component, add `bus.emit("form/closed")` to the cleanup function of the effect. This is optional: skip it when finishing a request after unmount does no harm, as for a cheap check.
|
|
3116
3124
|
|
|
3125
|
+
## Show the last value of an event as state
|
|
3126
|
+
|
|
3127
|
+
**Problem.** A component often needs the latest result of an event, such as the found items of a search. The hand-written form is easy to get slightly wrong:
|
|
3128
|
+
|
|
3129
|
+
<!-- docs-check: skip - the hand-written form that this section replaces -->
|
|
3130
|
+
```jsx
|
|
3131
|
+
useEffect(() => bus.on(searchLoaded, setFound), []);
|
|
3132
|
+
```
|
|
3133
|
+
|
|
3134
|
+
It needs a `useState` next to it, the empty `[]` hides the dependency on the event, and it breaks if the payload is a function, because React calls `setFound(payload)` as an updater.
|
|
3135
|
+
|
|
3136
|
+
**When.** The value you show comes from the bus, not from the component. For the opposite direction, a value of the component that the bus must hear, use `useEmitter`.
|
|
3137
|
+
|
|
3138
|
+
`useEventState(event, initial)` has the signature of `useState`: it returns `[state, setState]`. `initial` is the state before the first event and may be a function, as in `useState`. After that, every emit of `event` sets the state to its payload. The hook unsubscribes when the component unmounts.
|
|
3139
|
+
|
|
3140
|
+
```js
|
|
3141
|
+
// bus.js
|
|
3142
|
+
import { createBus } from "nanoeffects";
|
|
3143
|
+
import { reactPlugin } from "nanoeffects/react";
|
|
3144
|
+
|
|
3145
|
+
// createBus — the event bus, created once outside React
|
|
3146
|
+
// reactPlugin — adds the hooks bus.useEmitter and bus.useEventState
|
|
3147
|
+
export const bus = createBus().use(reactPlugin());
|
|
3148
|
+
export const { useEventState } = bus; // a free hook: components import it without the bus
|
|
3149
|
+
```
|
|
3150
|
+
|
|
3151
|
+
```js
|
|
3152
|
+
// search.js
|
|
3153
|
+
import { createAction } from "nanoeffects/actions";
|
|
3154
|
+
|
|
3155
|
+
// createAction — the event name; the handlers emit it with the found titles
|
|
3156
|
+
export const searchLoaded = createAction("search/loaded");
|
|
3157
|
+
```
|
|
3158
|
+
|
|
3159
|
+
```jsx
|
|
3160
|
+
// Found.jsx
|
|
3161
|
+
import { useEventState } from "./bus";
|
|
3162
|
+
import { searchLoaded } from "./search";
|
|
3163
|
+
|
|
3164
|
+
export function Found() {
|
|
3165
|
+
// useEventState — like useState([]), but searchLoaded sets the value
|
|
3166
|
+
const [found, setFound] = useEventState(searchLoaded, []);
|
|
3167
|
+
|
|
3168
|
+
return (
|
|
3169
|
+
<>
|
|
3170
|
+
<ul>
|
|
3171
|
+
{found.map((title) => (
|
|
3172
|
+
<li key={title}>{title}</li>
|
|
3173
|
+
))}
|
|
3174
|
+
</ul>
|
|
3175
|
+
<button onClick={() => setFound([])}>Clear</button>
|
|
3176
|
+
</>
|
|
3177
|
+
);
|
|
3178
|
+
}
|
|
3179
|
+
```
|
|
3180
|
+
|
|
3181
|
+
`setFound` is the setter of React. It changes the state of this component only and emits nothing.
|
|
3182
|
+
|
|
3183
|
+
In TypeScript, the state type comes from the event: from `createAction<string[]>("search/loaded")` or from the event map of the bus. When the event has no payload type, as in the example above, it comes from `initial`: `["a"]` is `string[]`, and an empty `[]` is `unknown[]`. Give the event a type, or pass a typed `initial`, when you need more.
|
|
3184
|
+
|
|
3185
|
+
The bus keeps no values, so the hook cannot replay an event. An event emitted before the component mounts is not seen: if that matters, read the current value into `initial`.
|
|
3186
|
+
|
|
3117
3187
|
## Keep a debounced handler between renders
|
|
3118
3188
|
|
|
3119
3189
|
**Problem.** A component runs again on every render. A policy made in its body, such as `debounce(300, search)`, is made again each time and loses its timer. A policy made once keeps the callback of the first render, which reads old props and state.
|
package/llms.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`; `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/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
|
|
@@ -34,7 +34,7 @@ Rules that generated code often gets wrong:
|
|
|
34
34
|
- [Actions, commands, jobs and modules](docs/actions.md): `createAction`, `createCommand`, `createJob`, `createModule`
|
|
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
|
-
- [Build a sign-up form in React](docs/react.md): a form with a username check, a submit that cannot fire twice, and retry; `usePolicy` keeps a debounced handler between renders
|
|
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
38
|
- [Context fields](docs/context.md): put a store or client on every task
|
|
39
39
|
- [Scopes](docs/scope.md): fields for one command, action, job, module or `emit`, and a way to replace them in a test
|
|
40
40
|
- [Use the bus from a class](docs/oop.md): `Emitter`, `extendBus`, `@WithBus`, `@On`, `@Once`, and `dispose()` for the subscriptions of an instance
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nanoeffects",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Cancel stale async work and fix race conditions in event handlers: a typed event bus where every handler is a task with an AbortSignal, plus latest, debounce, throttle, queue, retry, timeout and poll. About 1.4 kB gzip.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"event-bus",
|
|
@@ -146,8 +146,9 @@
|
|
|
146
146
|
"prepare": "lefthook install",
|
|
147
147
|
"lint:package": "publint && attw --pack . --profile esm-only && publint packages/create-nanoeffects",
|
|
148
148
|
"size": "size-limit",
|
|
149
|
-
"verify": "pnpm tsgo && pnpm check && pnpm docs:check && pnpm llms:check && pnpm test && pnpm build && pnpm test:integration && pnpm smoke && pnpm lint:package && pnpm size",
|
|
149
|
+
"verify": "pnpm tsgo && pnpm check && pnpm docs:check && pnpm llms:check && pnpm templates:check && pnpm test && pnpm build && pnpm test:integration && pnpm smoke && pnpm lint:package && pnpm size",
|
|
150
150
|
"test:integration": "vitest run --config vitest.integration.config.ts",
|
|
151
|
+
"templates:check": "node scripts/templates-check.mjs",
|
|
151
152
|
"docs:check": "node scripts/docs-check.mjs",
|
|
152
153
|
"llms:check": "node scripts/llms-full.mjs --check",
|
|
153
154
|
"smoke": "node scripts/smoke.mjs && node scripts/smoke.mjs --notrace",
|
|
@@ -250,7 +251,7 @@
|
|
|
250
251
|
"ignore": [
|
|
251
252
|
"react"
|
|
252
253
|
],
|
|
253
|
-
"limit": "1.
|
|
254
|
+
"limit": "1.24 kB"
|
|
254
255
|
},
|
|
255
256
|
{
|
|
256
257
|
"name": "dev",
|