nanoeffects 0.4.0 → 0.6.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 +4 -1
  2. package/dist/{action-creator-D48fp13T.d.ts → action-creator-c292nK7v.d.ts} +130 -34
  3. package/dist/actions.d.ts +54 -5
  4. package/dist/actions.js +1 -1
  5. package/dist/{bus-Cho463Sq.js → bus-BAs9x7IQ.js} +1 -1
  6. package/dist/{context-DlA80GOA.js → context-CK0ZomWt.js} +1 -1
  7. package/dist/context-plugin-1pYmSQEG.d.ts +34 -0
  8. package/dist/dev.d.ts +2 -2
  9. package/dist/effects.d.ts +27 -129
  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-DVX8aYlq.d.ts → module-BlF2KSgy.d.ts} +34 -5
  14. package/dist/notrace/{action-creator-D48fp13T.d.ts → action-creator-DKG_sIia.d.ts} +130 -34
  15. package/dist/notrace/actions.d.ts +96 -7
  16. package/dist/notrace/actions.js +1 -1
  17. package/dist/notrace/{bus-B12SvxLv.js → bus-Bdk0YyFQ.js} +1 -1
  18. package/dist/notrace/{context-yAWj0BFZ.js → context-CctVmHLC.js} +1 -1
  19. package/dist/notrace/context-plugin-Dt1IgVo8.d.ts +34 -0
  20. package/dist/notrace/{delay-Cgvn16oY.js → delay-B_W1tnkS.js} +1 -1
  21. package/dist/notrace/effects.d.ts +27 -129
  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/plugin-B0AXKusc.d.ts +207 -0
  26. package/dist/notrace/policies.d.ts +1 -1
  27. package/dist/notrace/policies.js +1 -1
  28. package/dist/notrace/redux.d.ts +18 -20
  29. package/dist/notrace/redux.js +1 -1
  30. package/dist/observable.d.ts +1 -1
  31. package/dist/plugin-CCYlC0XS.d.ts +207 -0
  32. package/dist/policies.d.ts +1 -1
  33. package/dist/policies.js +1 -1
  34. package/dist/react.d.ts +50 -0
  35. package/dist/react.js +1 -0
  36. package/dist/redux.d.ts +18 -20
  37. package/dist/redux.js +1 -1
  38. package/dist/scope-key-B7l7mUS5.js +1 -0
  39. package/dist/scope.d.ts +58 -0
  40. package/dist/scope.js +1 -0
  41. package/dist/trace.d.ts +1 -1
  42. package/dist/types-mLaAx-EZ.d.ts +15 -0
  43. package/docs/README.md +7 -1
  44. package/docs/actions.md +25 -1
  45. package/docs/context.md +26 -2
  46. package/docs/effects.md +83 -5
  47. package/docs/guide.md +24 -0
  48. package/docs/migrating-from-effector.md +293 -0
  49. package/docs/migrating-from-rxjs.md +127 -0
  50. package/docs/migrating-from-saga.md +93 -0
  51. package/docs/observable.md +1 -0
  52. package/docs/package.md +13 -2
  53. package/docs/policies.md +1 -0
  54. package/docs/react.md +157 -0
  55. package/docs/scope.md +226 -0
  56. package/docs/stores.md +429 -40
  57. package/docs/typescript.md +153 -0
  58. package/llms-full.txt +2925 -0
  59. package/llms.txt +49 -0
  60. package/package.json +44 -6
package/docs/stores.md CHANGED
@@ -1,35 +1,53 @@
1
- # Connect Redux, Zustand, or MobX
1
+ # Connect Redux, Zustand, MobX, or Pinia
2
2
 
3
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
- If you do not have a store yet, use `createBus` from the [Guide](guide.md). Recipe: [`examples/redux.ts`](../examples/redux.ts).
5
+ If you do not have a store yet, use `createBus` from the [Guide](guide.md).
6
+
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)
6
21
 
7
22
  ## Wire Redux Toolkit to a bus
8
23
 
9
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.
10
25
 
11
- `createEffectsMiddleware` does the same job: after the reducer runs, the action also hits a nanoeffects bus. Keep RTK for state; use policies and effects for async.
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.
12
27
 
13
- Skip this section if you are not already on Redux.
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).
14
29
 
15
30
  ```js
16
31
  import { configureStore, createAction } from "@reduxjs/toolkit";
17
- import { createEffectsMiddleware } from "nanoeffects/redux";
32
+ import { createBus } from "nanoeffects";
18
33
  import { latest } from "nanoeffects/policies";
34
+ import { createEffectsMiddleware } from "nanoeffects/redux";
19
35
 
20
36
  const userRequested = createAction("user/requested"); // RTK
21
37
  const userLoaded = createAction("user/loaded");
22
38
 
23
- // bus + middleware: actions reach the bus after reducers; tasks get ctx.store
24
- const effects = createEffectsMiddleware();
39
+ const bus = createBus(); // the bus of your app; it can exist before the store
40
+
41
+ // createEffectsMiddleware — actions reach the bus after reducers; tasks get ctx.store
42
+ const effects = createEffectsMiddleware(bus);
25
43
 
26
44
  const store = configureStore({
27
45
  reducer,
28
- middleware: (getDefault) => getDefault().concat(effects.middleware),
46
+ middleware: (getDefault) => getDefault().concat(effects),
29
47
  });
30
48
 
31
49
  // latest — a newer run aborts the older one
32
- effects.bus.on(
50
+ bus.on(
33
51
  userRequested,
34
52
  latest(async (id, { store, signal }) => {
35
53
  const user = await fetchUser(id, { signal });
@@ -43,17 +61,17 @@ String event names (`"user/requested"`) work the same. Use your usual RTK action
43
61
  ### What the middleware guarantees
44
62
 
45
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.
46
- - **A failing handler does not break `dispatch`.** Errors go to `onError` (default `console.error`), as in RTK listeners. Reducers still throw to the caller.
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.
47
65
  - **`store` reaches tasks when the middleware is installed**, even though the bus is created before `configureStore`.
48
66
 
49
- Options are those of `createBus` (except `context`, which adds fields next to `store`) and `filter`, which keeps rejected actions away from the bus. Values that are not plain actions (thunks) go through untouched. Nothing here depends on the `redux` package at runtime.
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.
50
68
 
51
69
  ### Type `ctx.store` (optional)
52
70
 
53
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.
54
72
 
55
73
  ```ts
56
- import type { WithContext } from "nanoeffects";
74
+ import { createBus, type WithContext } from "nanoeffects";
57
75
  import {
58
76
  createEffectsMiddleware,
59
77
  type ReduxStoreApi,
@@ -68,55 +86,426 @@ type Events = {
68
86
  "user/requested": string;
69
87
  } & WithContext<{ store: ReduxStoreApi<RootState> }>;
70
88
 
71
- const effects = createEffectsMiddleware<Events>();
89
+ const bus = createBus<Events>(); // the event map types ctx.store
90
+ const effects = createEffectsMiddleware(bus);
72
91
  ```
73
92
 
74
- ## Emit from Zustand
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.
75
99
 
76
- No adapter: the store notifies, you `emit` onto a normal bus.
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.
77
105
 
78
106
  ```js
107
+ // search-bus.js
79
108
  import { createBus } from "nanoeffects";
80
- import { latest } from "nanoeffects/policies";
109
+ import { createAction, emitterPlugin } from "nanoeffects/actions";
110
+ import { debounce, latest } from "nanoeffects/policies";
81
111
 
82
- const bus = createBus(); // plain bus; no Redux middleware
112
+ export const bus = createBus().use(emitterPlugin()); // adds bus.emitter; returns the same bus
83
113
 
84
- const stop = store.subscribe((state, previous) => {
85
- if (state.user === previous.user) return;
86
- bus.emit("user/changed", state.user);
87
- });
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");
88
117
 
89
- // latest — drop in-flight sync when the user changes again
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
90
122
  bus.on(
91
- "user/changed",
92
- latest(async (user, { signal }) => {
93
- await sync(user, { signal });
94
- }),
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
+ ),
95
132
  );
96
133
  ```
97
134
 
98
- ## Emit from MobX
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`.
99
140
 
100
141
  ```js
101
- import { createBus } from "nanoeffects";
102
- import { latest } from "nanoeffects/policies";
103
- import { reaction } from "mobx";
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
+ ```
104
177
 
105
- const bus = createBus();
178
+ ### Valtio
106
179
 
107
- const stop = reaction(
108
- () => store.user,
109
- (user) => bus.emit("user/changed", user),
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: [] })),
110
230
  );
111
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: [] }),
253
+ });
254
+
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. |
275
+
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
112
296
  bus.on(
113
- "user/changed",
114
- latest(async (user, { signal }) => {
115
- await sync(user, { signal });
116
- }),
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
+ ),
117
305
  );
118
306
  ```
119
307
 
120
- 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
121
510
 
122
- The other way: give the store to tasks with `createBus({ context: { store } })`, and change it from a handler (`setState`, MobX action). A handler that changes the value it was started by emits again, so watch the smallest slice. 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.