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 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 a=()=>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])}return Object.assign(e,{useEmitter:r})},o=(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{a as reactPlugin,o as usePolicy};
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
- Both templates split the code the way [the guide](guide.md) does: one job per file.
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
- Both templates split the code the way [the guide](guide.md) does: one job per file.
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.17.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.18 kB"
254
+ "limit": "1.24 kB"
254
255
  },
255
256
  {
256
257
  "name": "dev",