nanoeffects 0.3.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.
Files changed (60) hide show
  1. package/README.md +53 -55
  2. package/dist/{action-Bdfp-Wvd.d.ts → action-creator-DOWcbXpy.d.ts} +79 -73
  3. package/dist/actions.d.ts +75 -6
  4. package/dist/actions.js +1 -1
  5. package/dist/bus-BAs9x7IQ.js +1 -0
  6. package/dist/{context-DlA80GOA.js → context-CK0ZomWt.js} +1 -1
  7. package/dist/context-plugin-B0sLWOxR.d.ts +34 -0
  8. package/dist/dev.d.ts +2 -2
  9. package/dist/effects.d.ts +153 -56
  10. package/dist/effects.js +1 -1
  11. package/dist/index.d.ts +3 -2
  12. package/dist/index.js +1 -1
  13. package/dist/{module-I1ixyJzx.d.ts → module-DCJl-qfi.d.ts} +24 -9
  14. package/dist/notrace/{action-Bdfp-Wvd.d.ts → action-creator-DOWcbXpy.d.ts} +79 -73
  15. package/dist/notrace/actions.d.ts +97 -13
  16. package/dist/notrace/actions.js +1 -1
  17. package/dist/notrace/bus-Bdk0YyFQ.js +1 -0
  18. package/dist/notrace/{context-yAWj0BFZ.js → context-CctVmHLC.js} +1 -1
  19. package/dist/notrace/context-plugin-B0sLWOxR.d.ts +34 -0
  20. package/dist/notrace/{delay-Cgvn16oY.js → delay-B_W1tnkS.js} +1 -1
  21. package/dist/notrace/effects.d.ts +153 -56
  22. package/dist/notrace/effects.js +1 -1
  23. package/dist/notrace/index.d.ts +3 -2
  24. package/dist/notrace/index.js +1 -1
  25. package/dist/notrace/policies.d.ts +1 -1
  26. package/dist/notrace/policies.js +1 -1
  27. package/dist/notrace/redux.d.ts +18 -20
  28. package/dist/notrace/redux.js +1 -1
  29. package/dist/observable.d.ts +1 -1
  30. package/dist/policies.d.ts +1 -1
  31. package/dist/policies.js +1 -1
  32. package/dist/react.d.ts +50 -0
  33. package/dist/react.js +1 -0
  34. package/dist/redux.d.ts +18 -20
  35. package/dist/redux.js +1 -1
  36. package/dist/trace.d.ts +1 -1
  37. package/docs/README.md +30 -9
  38. package/docs/actions.md +117 -41
  39. package/docs/context.md +48 -7
  40. package/docs/effects.md +229 -73
  41. package/docs/errors.md +23 -10
  42. package/docs/guide.md +238 -129
  43. package/docs/hmr.md +25 -13
  44. package/docs/migrating-from-effector.md +293 -0
  45. package/docs/migrating-from-rxjs.md +127 -0
  46. package/docs/migrating-from-saga.md +93 -0
  47. package/docs/observable.md +11 -9
  48. package/docs/package.md +24 -0
  49. package/docs/policies.md +93 -30
  50. package/docs/react.md +157 -0
  51. package/docs/semantics.md +5 -3
  52. package/docs/stores.md +488 -25
  53. package/docs/tracing.md +69 -54
  54. package/docs/typescript.md +153 -0
  55. package/llms-full.txt +2645 -0
  56. package/llms.txt +48 -0
  57. package/package.json +39 -9
  58. package/dist/bus-CRnlwmQ4.js +0 -1
  59. package/dist/notrace/bus-Dx0l9X6h.js +0 -1
  60. package/dist/notrace/event-0RIBGYqS.js +0 -1
package/docs/stores.md CHANGED
@@ -1,48 +1,511 @@
1
- # Redux, Zustand, MobX
1
+ # Connect Redux, Zustand, MobX, or Pinia
2
2
 
3
- ## Redux
3
+ Use this page when you **already have a store** and want async work with cancellation and policies next to it. It is not a reason to pick Redux (or any store).
4
4
 
5
- `createEffectsMiddleware()` from `nanoeffects/redux` gives you a bus and the Redux middleware that feeds it: the effects of an app in one place, without sagas. Every action goes to the bus after the reducers have handled it, so a handler that reads the store sees the new state, and every task gets `ctx.store` (`dispatch` and `getState`).
5
+ If you do not have a store yet, use `createBus` from the [Guide](guide.md).
6
6
 
7
- ```ts
8
- type Events = { userRequested: string } & WithContext<{ store: ReduxStoreApi<RootState> }>;
7
+ ## On this page
8
+
9
+ - [Wire Redux Toolkit to a bus](#wire-redux-toolkit-to-a-bus)
10
+ - [Emit from a state manager](#emit-from-a-state-manager)
11
+ - [Nanostores](#nanostores)
12
+ - [MobX](#mobx)
13
+ - [Valtio](#valtio)
14
+ - [Jotai](#jotai)
15
+ - [Zustand](#zustand)
16
+ - [Pinia](#pinia)
17
+ - [React: sharing a value across components](#react-sharing-a-value-across-components)
18
+ - [Callbacks on every change](#callbacks-on-every-change)
19
+ - [Stop watching a store](#stop-watching-a-store)
20
+ - [Give the store to every handler](#give-the-store-to-every-handler)
21
+
22
+ ## Wire Redux Toolkit to a bus
23
+
24
+ Redux’s `dispatch` → reducers updates **state**. It does not give you first-class **tasks** (abort in-flight work, `latest` / `debounce` / `queue`, `retry` / `timeout`). People usually reach for thunks, RTK listener middleware, or sagas for that.
25
+
26
+ `createEffectsMiddleware(bus)` does the same job: after the reducer runs, the action also hits the nanoeffects bus you pass to it. Keep RTK for state; use policies and effects for async.
27
+
28
+ Skip this section if you are not already on Redux. The recipe as a file, type-checked with the project: [`examples/redux.ts`](../examples/redux.ts).
29
+
30
+ ```js
31
+ import { configureStore, createAction } from "@reduxjs/toolkit";
32
+ import { createBus } from "nanoeffects";
33
+ import { latest } from "nanoeffects/policies";
34
+ import { createEffectsMiddleware } from "nanoeffects/redux";
35
+
36
+ const userRequested = createAction("user/requested"); // RTK
37
+ const userLoaded = createAction("user/loaded");
38
+
39
+ const bus = createBus(); // the bus of your app; it can exist before the store
9
40
 
10
- const effects = createEffectsMiddleware<Events>();
41
+ // createEffectsMiddleware — actions reach the bus after reducers; tasks get ctx.store
42
+ const effects = createEffectsMiddleware(bus);
11
43
 
12
44
  const store = configureStore({
13
45
  reducer,
14
- middleware: (getDefault) => getDefault().concat(effects.middleware),
46
+ middleware: (getDefault) => getDefault().concat(effects),
15
47
  });
16
48
 
17
- effects.bus.on(userRequested, latest(async (id, { store, signal }) => {
18
- store.dispatch(userLoaded(await fetchUser(id, { signal })));
19
- }));
49
+ // latest — a newer run aborts the older one
50
+ bus.on(
51
+ userRequested,
52
+ latest(async (id, { store, signal }) => {
53
+ const user = await fetchUser(id, { signal });
54
+ store.dispatch(userLoaded(user));
55
+ }),
56
+ );
20
57
  ```
21
58
 
22
- It looks after what is easy to get wrong:
59
+ String event names (`"user/requested"`) work the same. Use your usual RTK actions (`createSlice` / `createAction`). nanoeffects adds the middleware and policies; it does not replace RTK actions.
23
60
 
24
- - **A handler does not loop on its own action.** An action that a handler dispatches through `ctx.store` and that has the type the handler works on does not go back to the bus. The action is not marked or changed: the middleware remembers which task dispatched it. Other handlers of that type do not get it either. Longer cycles, `a` to `b` to `a`, are yours to avoid.
25
- - **A failing handler does not break `dispatch`.** Errors go to `onError` (default `console.error`), as in Redux Toolkit listeners. Reducers still throw to the caller.
26
- - **The store gets to the tasks by itself**, though the bus is made before the store: `ctx.store` appears when the middleware is added.
61
+ ### What the middleware guarantees
27
62
 
28
- Options are those of `createBus` (except `context`, which adds fields next to `store`) and `filter`, which keeps the actions it rejects away from the bus. Values that are not plain actions (thunks) go through untouched. Nothing here depends on Redux.
63
+ - **No self-loop.** An action that a handler dispatches through `ctx.store` and that has the type the handler works on does not go back to the bus. Other handlers of that type do not get it either. Longer cycles (`a` → `b` → `a`) are yours to avoid.
64
+ - **A failing handler does not break `dispatch`.** With `onError` on the bus, the bus isolates the handlers. Without it the error goes to `console.error`, as in RTK listeners. Reducers still throw to the caller.
65
+ - **`store` reaches tasks when the middleware is installed**, even though the bus is created before `configureStore`.
29
66
 
30
- ## Zustand, MobX and other stores
67
+ The only option is `filter`, which keeps rejected actions away from the bus. Options of the bus (`onError`, `context`, `trace`) go to `createBus`. Use one bus for one store. Values that are not plain actions (thunks) go through untouched. Nothing here depends on the `redux` package at runtime.
31
68
 
32
- No adapter is needed: a store tells about its changes, and `bus.emit` is a call.
69
+ ### Type `ctx.store` (optional)
70
+
71
+ Only if you want TypeScript to know `store` on handlers. **Skip this in JavaScript** — the middleware already puts `store` on the task at runtime. These lines do not connect the store across the app.
33
72
 
34
73
  ```ts
35
- // Zustand
36
- const stop = store.subscribe((state, previous) => {
37
- if (state.user !== previous.user) bus.emit(userChanged, state.user);
74
+ import { createBus, type WithContext } from "nanoeffects";
75
+ import {
76
+ createEffectsMiddleware,
77
+ type ReduxStoreApi,
78
+ } from "nanoeffects/redux";
79
+
80
+ // RootState — your Redux state shape (for getState)
81
+ type RootState = { user: string | null };
82
+
83
+ // Events — event name → payload for this bus
84
+ // WithContext — checker-only: handlers also have ctx.store
85
+ type Events = {
86
+ "user/requested": string;
87
+ } & WithContext<{ store: ReduxStoreApi<RootState> }>;
88
+
89
+ const bus = createBus<Events>(); // the event map types ctx.store
90
+ const effects = createEffectsMiddleware(bus);
91
+ ```
92
+
93
+ ## Emit from a state manager
94
+
95
+ Zustand, MobX, Jotai, Valtio, Nanostores and Pinia have no adapter. Redux is different: use the middleware from the first section, it sends actions to the bus. A plain store works with the bus in two directions, and both are shown for each store below:
96
+
97
+ - **The store triggers an event.** The store tells you about a change with a callback. You pass that callback `bus.emitter(action)`, and the change becomes an event that starts a handler.
98
+ - **The event changes the store.** The handler reports its result as an event. A listener of the bus writes it into the store.
99
+
100
+ The example is a search box. The store holds two fields: `query`, which the user types, and `results`, which a request fills. A change of `query` starts a debounced search; the search ends with an event that writes `results`.
101
+
102
+ Writing `(query) => bus.emit(queryChanged(query))` for every subscription repeats the bus and the action. `emitterPlugin` adds `bus.emitter(action)`: it returns that callback for you. The action stays a plain `createAction`, so `bus.emit(queryChanged("nano"))` and `bus.on(queryChanged, …)` work as before.
103
+
104
+ The bus is the same for every store below. Create it once; each subsection imports it.
105
+
106
+ ```js
107
+ // search-bus.js
108
+ import { createBus } from "nanoeffects";
109
+ import { createAction, emitterPlugin } from "nanoeffects/actions";
110
+ import { debounce, latest } from "nanoeffects/policies";
111
+
112
+ export const bus = createBus().use(emitterPlugin()); // adds bus.emitter; returns the same bus
113
+
114
+ // createAction — plain action creators; calling one only builds { type, payload }
115
+ export const queryChanged = createAction("search/queryChanged");
116
+ export const searchLoaded = createAction("search/loaded");
117
+
118
+ // bus.emitter: a callback (query) => bus.emit(queryChanged(query))
119
+ export const emitQueryChanged = bus.emitter(queryChanged);
120
+
121
+ // debounce — wait 300 ms of quiet; latest — abort the search of the previous query
122
+ bus.on(
123
+ queryChanged,
124
+ debounce(
125
+ 300,
126
+ latest(async (query, ctx) => {
127
+ // api — your client; ctx.signal lets latest cancel the request itself
128
+ const items = await api.search(query, { signal: ctx.signal });
129
+ ctx.emit(searchLoaded(items)); // report the result as an event
130
+ }),
131
+ ),
132
+ );
133
+ ```
134
+
135
+ Write `results` and never the field you watch: a handler that changed `query` would start itself again. Find your store below.
136
+
137
+ ### Nanostores
138
+
139
+ `listen` calls back with the new value. `subscribe` would also call at once, with the current value, so use `listen`.
140
+
141
+ ```js
142
+ import { atom } from "nanostores";
143
+ import { bus, emitQueryChanged, searchLoaded } from "./search-bus";
144
+
145
+ const $query = atom("");
146
+ const $results = atom([]);
147
+
148
+ // store → bus: listen calls back with the new query on every change
149
+ const stopQuery = $query.listen(emitQueryChanged);
150
+
151
+ // bus → store: bus.on returns the function that removes this listener
152
+ const stopResults = bus.on(searchLoaded, (items) => {
153
+ $results.set(items);
154
+ });
155
+ ```
156
+
157
+ ### MobX
158
+
159
+ `reaction` watches an expression and calls back with its value. Change observables inside `runInAction`.
160
+
161
+ ```js
162
+ import { observable, reaction, runInAction } from "mobx";
163
+ import { bus, emitQueryChanged, searchLoaded } from "./search-bus";
164
+
165
+ const search = observable({ query: "", results: [] });
166
+
167
+ // store → bus: reaction watches the expression and calls back with its value
168
+ const stopQuery = reaction(() => search.query, emitQueryChanged);
169
+
170
+ // bus → store: bus.on returns the function that removes this listener
171
+ const stopResults = bus.on(searchLoaded, (items) => {
172
+ runInAction(() => {
173
+ search.results = items;
174
+ });
175
+ });
176
+ ```
177
+
178
+ ### Valtio
179
+
180
+ `subscribeKey` watches one field of a proxy state. Assign to the proxy to change it.
181
+
182
+ ```js
183
+ import { proxy } from "valtio";
184
+ import { subscribeKey } from "valtio/utils";
185
+ import { bus, emitQueryChanged, searchLoaded } from "./search-bus";
186
+
187
+ const state = proxy({ query: "", results: [] });
188
+
189
+ // store → bus: subscribeKey watches one field
190
+ const stopQuery = subscribeKey(state, "query", emitQueryChanged);
191
+
192
+ // bus → store: bus.on returns the function that removes this listener
193
+ const stopResults = bus.on(searchLoaded, (items) => {
194
+ state.results = items;
195
+ });
196
+ ```
197
+
198
+ ### Jotai
199
+
200
+ `sub` gives no value, so read it yourself. Write with `store.set`.
201
+
202
+ ```js
203
+ import { atom, createStore } from "jotai";
204
+ import { bus, emitQueryChanged, searchLoaded } from "./search-bus";
205
+
206
+ const queryAtom = atom("");
207
+ const resultsAtom = atom([]);
208
+ const store = createStore();
209
+
210
+ // store → bus: sub calls back without a value; store.get reads it
211
+ const stopQuery = store.sub(queryAtom, () => emitQueryChanged(store.get(queryAtom)));
212
+
213
+ // bus → store: bus.on returns the function that removes this listener
214
+ const stopResults = bus.on(searchLoaded, (items) => {
215
+ store.set(resultsAtom, items);
216
+ });
217
+ ```
218
+
219
+ ### Zustand
220
+
221
+ With `subscribeWithSelector` the selector picks the field, and the callback is called only when it changes. Without a selector Zustand calls back on every `setState`, with the whole state.
222
+
223
+ ```js
224
+ import { create } from "zustand";
225
+ import { subscribeWithSelector } from "zustand/middleware";
226
+ import { bus, emitQueryChanged, searchLoaded } from "./search-bus";
227
+
228
+ const useSearchStore = create(
229
+ subscribeWithSelector(() => ({ query: "", results: [] })),
230
+ );
231
+
232
+ // store → bus: subscribe with a selector calls back with state.query only when it changes
233
+ const stopQuery = useSearchStore.subscribe((state) => state.query, emitQueryChanged);
234
+
235
+ // bus → store: bus.on returns the function that removes this listener
236
+ const stopResults = bus.on(searchLoaded, (items) => {
237
+ useSearchStore.setState({ results: items });
238
+ });
239
+ ```
240
+
241
+ ### Pinia
242
+
243
+ Pinia (the store of Vue) keeps reactive objects, and its `$subscribe` reports every mutation of the store, not one field. Watch one getter with Vue's `watch` instead: it calls back with the new value. Assign to the store to change it.
244
+
245
+ ```js
246
+ import { watch } from "vue";
247
+ import { defineStore } from "pinia";
248
+ import { bus, emitQueryChanged, searchLoaded } from "./search-bus";
249
+
250
+ // defineStore — Pinia: the state of one store
251
+ const useSearch = defineStore("search", {
252
+ state: () => ({ query: "", results: [] }),
38
253
  });
39
254
 
40
- // MobX
41
- const stop = reaction(() => store.user, (user) => bus.emit(userChanged, user));
255
+ const search = useSearch(); // call it after the app has installed Pinia
256
+
257
+ // store → bus: watch calls back with the new query when search.query changes
258
+ const stopQuery = watch(() => search.query, emitQueryChanged);
259
+
260
+ // bus → store: bus.on returns the function that removes this listener
261
+ const stopResults = bus.on(searchLoaded, (items) => {
262
+ search.results = items;
263
+ });
264
+ ```
265
+
266
+ ### React: sharing a value across components
267
+
268
+ A value of your UI (a query, a filter, a name) often has two jobs. Several components read it and re-render when it changes, and the bus must hear about each change to start a handler. The recipes below differ in where the value lives. Pick the first one unless a handler must read the value.
269
+
270
+ | The value lives in | Use when |
271
+ | --- | --- |
272
+ | A Context provider with `useState` | Only components read it. Plain React, one `useEmitter` call. |
273
+ | A small store on `useSyncExternalStore` | A bus handler, a test or a timer must read it too. |
274
+ | An atom of a store library | You already use Nanostores, Jotai and the like. |
42
275
 
43
- bus.on(userChanged, latest((user, { signal }) => sync(user, { signal })));
276
+ #### Plain React: Context and `useEmitter`
277
+
278
+ Writing `useEffect(() => bus.emit(...), [value])` looks enough, but it emits on mount, emits twice on mount under StrictMode, and re-runs when the emit function is new on every render. `useEmitter` from `nanoeffects/react` avoids all three. It emits after a render commits with a changed value, never on mount, and compares with `Object.is`. You need React 18 or newer; no other entry of the package uses React.
279
+
280
+ First the bus. `reactPlugin()` adds `bus.useEmitter`, and destructuring it gives a plain hook that you export:
281
+
282
+ ```js
283
+ // react-search-bus.js
284
+ import { createBus } from "nanoeffects";
285
+ import { createAction } from "nanoeffects/actions";
286
+ import { debounce, latest } from "nanoeffects/policies";
287
+ import { reactPlugin } from "nanoeffects/react";
288
+
289
+ export const bus = createBus().use(reactPlugin()); // the bus; reactPlugin adds bus.useEmitter
290
+ export const { useEmitter } = bus; // a free hook: it does not read `this`
291
+
292
+ export const queryChanged = createAction("query/changed");
293
+ export const searchLoaded = createAction("search/loaded");
294
+
295
+ // debounce — wait 300 ms of quiet; latest — a newer query aborts the older request
296
+ bus.on(
297
+ queryChanged,
298
+ debounce(
299
+ 300,
300
+ latest(async (query, ctx) => {
301
+ const items = await api.search(query, { signal: ctx.signal });
302
+ ctx.emit(searchLoaded(items)); // the result goes back to the bus as an event
303
+ }),
304
+ ),
305
+ );
44
306
  ```
45
307
 
46
- Stop watching with what the store returned: `onAbort(ctx, stop)` inside a task, or `m.signal.addEventListener("abort", stop)` in a module.
308
+ Then the provider. It holds the state, calls `useEmitter` once, and listens for the result. Every component below it only reads the context:
309
+
310
+ ```jsx
311
+ // SearchProvider.jsx
312
+ import { createContext, useContext, useEffect, useState } from "react";
313
+ import { bus, queryChanged, searchLoaded, useEmitter } from "./react-search-bus";
314
+
315
+ const SearchContext = createContext(null);
316
+
317
+ export function SearchProvider({ children }) {
318
+ const [query, setQuery] = useState("");
319
+ const [items, setItems] = useState([]);
320
+
321
+ // React → bus: emits queryChanged(query) after every change of query, not on mount
322
+ useEmitter(queryChanged, query);
323
+
324
+ // bus → React: bus.on returns the unsubscribe function, which the effect returns
325
+ useEffect(() => bus.on(searchLoaded, setItems), []);
326
+
327
+ return (
328
+ <SearchContext.Provider value={{ query, setQuery, items }}>
329
+ {children}
330
+ </SearchContext.Provider>
331
+ );
332
+ }
333
+
334
+ export const useSearch = () => useContext(SearchContext);
335
+ ```
336
+
337
+ ```jsx
338
+ // Search.jsx
339
+ import { useSearch } from "./SearchProvider";
340
+
341
+ // any number of components can read the same value
342
+ export function SearchBox() {
343
+ const { query, setQuery } = useSearch();
344
+ return <input value={query} onChange={(event) => setQuery(event.target.value)} />;
345
+ }
346
+
347
+ export function Results() {
348
+ const { items } = useSearch();
349
+ return (
350
+ <ul>
351
+ {items.map((item) => (
352
+ <li key={item.id}>{item.title}</li>
353
+ ))}
354
+ </ul>
355
+ );
356
+ }
357
+ ```
358
+
359
+ Two limits to know:
360
+
361
+ - **All consumers re-render** on any change of the context value. Split it into two contexts (`query` and `items`) if that is slow.
362
+ - **A new object counts as a change.** For a value that is rebuilt with the same content, pass a third argument that says when two values are the same: `useEmitter(profileChanged, profile, (previous, next) => previous.id === next.id)`.
363
+
364
+ `useEmitter` emits after the render, not at the moment of `setQuery`. To emit on every call, even with the same value, call an emitter from the event handler (`bus.emitter(action)`, see [Zustand](#zustand)) instead.
365
+
366
+ #### A value that a handler must read
367
+
368
+ A context is not visible to a bus handler. When a handler needs the current value, keep the value outside React. About twelve lines of `useSyncExternalStore` do it, and `contextPlugin` hands it to every handler as `ctx.query`:
369
+
370
+ ```jsx
371
+ // value-store.js
372
+ import { useSyncExternalStore } from "react";
373
+
374
+ export function createValue(initial) {
375
+ let value = initial;
376
+ const listeners = new Set();
377
+
378
+ return {
379
+ get: () => value,
380
+ set(next) {
381
+ if (Object.is(next, value)) {
382
+ return; // no change, no notification: this also stops loops
383
+ }
384
+ value = next;
385
+ for (const listener of listeners) {
386
+ listener();
387
+ }
388
+ },
389
+ subscribe(listener) {
390
+ listeners.add(listener);
391
+ return () => listeners.delete(listener);
392
+ },
393
+ };
394
+ }
395
+
396
+ export const useValue = (store) => useSyncExternalStore(store.subscribe, store.get);
397
+ ```
398
+
399
+ ```jsx
400
+ // search-value.jsx
401
+ import { createBus, contextPlugin } from "nanoeffects";
402
+ import { createAction, emitterPlugin } from "nanoeffects/actions";
403
+ import { createValue, useValue } from "./value-store";
404
+
405
+ const query = createValue("");
406
+
407
+ // emitterPlugin adds bus.emitter; contextPlugin puts `query` on every task as ctx.query
408
+ const bus = createBus().use(emitterPlugin()).use(contextPlugin({ query }));
409
+
410
+ const queryChanged = createAction("query/changed");
411
+ const refreshed = createAction("search/refreshed");
412
+
413
+ // store → bus: bus.emitter turns the action into a callback; one place, any number of setters
414
+ const emitQueryChanged = bus.emitter(queryChanged);
415
+ query.subscribe(() => emitQueryChanged(query.get()));
416
+
417
+ // the handler reads the current value from ctx, whoever changed it
418
+ bus.on(refreshed, async (_payload, ctx) => {
419
+ const items = await api.search(ctx.query.get(), { signal: ctx.signal });
420
+ console.log(items.length);
421
+ });
422
+
423
+ // any number of components read and write the same value
424
+ export function SearchBox() {
425
+ const text = useValue(query);
426
+ return <input value={text} onChange={(event) => query.set(event.target.value)} />;
427
+ }
428
+ ```
429
+
430
+ #### A store library
431
+
432
+ With Nanostores, Jotai or Zustand the value already lives outside React and has a subscription. Connect it to the bus once, as in [Nanostores](#nanostores), and read it in components with the library's own hook:
433
+
434
+ ```jsx
435
+ // search-atom.jsx
436
+ import { atom } from "nanostores";
437
+ import { useStore } from "@nanostores/react";
438
+ import { createBus } from "nanoeffects";
439
+ import { createAction, emitterPlugin } from "nanoeffects/actions";
440
+
441
+ const bus = createBus().use(emitterPlugin()); // adds bus.emitter
442
+ const $query = atom("");
443
+ const queryChanged = createAction("query/changed");
444
+
445
+ // listen, not subscribe: listen skips the call with the current value at start
446
+ $query.listen(bus.emitter(queryChanged));
447
+
448
+ export function SearchBox() {
449
+ const query = useStore($query); // the hook of Nanostores; it re-renders on every change
450
+ return <input value={query} onChange={(event) => $query.set(event.target.value)} />;
451
+ }
452
+ ```
453
+
454
+ #### Do not emit inside a `useReducer` reducer
455
+
456
+ React may run a reducer more than once for one dispatch (twice in StrictMode), so a `bus.emit` inside it fires twice. Dispatch to the reducer first, then emit, from one helper. Or keep the state in a reducer and pass it to `useEmitter` like any other value.
457
+
458
+ ```jsx
459
+ // search-reducer.jsx
460
+ import { useReducer } from "react";
461
+ import { createBus } from "nanoeffects";
462
+ import { createAction } from "nanoeffects/actions";
463
+
464
+ const bus = createBus(); // the bus: created once
465
+
466
+ const queryChanged = createAction("query/changed");
467
+ const queryCleared = createAction("query/cleared");
468
+
469
+ function reducer(state, action) {
470
+ if (queryChanged.match(action)) {
471
+ return action.payload;
472
+ }
473
+ if (queryCleared.match(action)) {
474
+ return "";
475
+ }
476
+ return state;
477
+ }
478
+
479
+ export function Search() {
480
+ const [query, dispatch] = useReducer(reducer, "");
481
+
482
+ // send: the reducer first, then the bus (bus.emit accepts an action)
483
+ function send(action) {
484
+ dispatch(action);
485
+ bus.emit(action);
486
+ }
487
+
488
+ return (
489
+ <div>
490
+ <input value={query} onChange={(event) => send(queryChanged(event.target.value))} />
491
+ <button type="button" onClick={() => send(queryCleared())}>
492
+ Clear
493
+ </button>
494
+ </div>
495
+ );
496
+ }
497
+ ```
498
+
499
+ A plain `dispatch` skips the bus, so call `send` everywhere.
500
+
501
+ ### Callbacks on every change
502
+
503
+ The stores above call back only when the value changed, so the emitter does not compare values. For a store that calls back on every change, select a smaller value first, as Zustand does above.
504
+
505
+ ### Stop watching a store
506
+
507
+ Stop both directions with what the calls returned: `stopQuery` from the store and `stopResults` from `bus.on`. Call them yourself, or hand them over: `onAbort(ctx, stopQuery)` inside a task, or `m.signal.addEventListener("abort", stopQuery)` in a module.
508
+
509
+ ### Give the store to every handler
47
510
 
48
- The other way, give the store to the tasks, `createBus({ context: { store } })`, and change it from a handler: `setState` for Zustand, an action of the MobX store (it wraps `runInAction`). A handler that changes the value it was started by emits the event again, so watch the smallest part of the state. For a MobX `flow`, `onAbort(ctx, () => promise.cancel())` cancels it with the task.
511
+ Instead of a listener per store, give the store to all tasks with `createBus({ context: { store } })` ([Context fields](context.md)) and change it from any handler (`setState`, a MobX action, a Pinia store). The same rule holds: write the field you do not watch. For a MobX `flow`, `onAbort(ctx, () => promise.cancel())` cancels it with the task.