nanoeffects 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/dist/{action-creator-D48fp13T.d.ts → action-creator-DOWcbXpy.d.ts} +16 -2
- package/dist/actions.d.ts +38 -3
- package/dist/actions.js +1 -1
- package/dist/{bus-Cho463Sq.js → bus-BAs9x7IQ.js} +1 -1
- package/dist/{context-DlA80GOA.js → context-CK0ZomWt.js} +1 -1
- package/dist/context-plugin-B0sLWOxR.d.ts +34 -0
- package/dist/dev.d.ts +2 -2
- package/dist/effects.d.ts +153 -56
- package/dist/effects.js +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1 -1
- package/dist/{module-DVX8aYlq.d.ts → module-DCJl-qfi.d.ts} +16 -2
- package/dist/notrace/{action-creator-D48fp13T.d.ts → action-creator-DOWcbXpy.d.ts} +16 -2
- package/dist/notrace/actions.d.ts +52 -3
- package/dist/notrace/actions.js +1 -1
- package/dist/notrace/{bus-B12SvxLv.js → bus-Bdk0YyFQ.js} +1 -1
- package/dist/notrace/{context-yAWj0BFZ.js → context-CctVmHLC.js} +1 -1
- package/dist/notrace/context-plugin-B0sLWOxR.d.ts +34 -0
- package/dist/notrace/{delay-Cgvn16oY.js → delay-B_W1tnkS.js} +1 -1
- package/dist/notrace/effects.d.ts +153 -56
- package/dist/notrace/effects.js +1 -1
- package/dist/notrace/index.d.ts +3 -2
- package/dist/notrace/index.js +1 -1
- package/dist/notrace/policies.d.ts +1 -1
- package/dist/notrace/policies.js +1 -1
- package/dist/notrace/redux.d.ts +18 -20
- package/dist/notrace/redux.js +1 -1
- package/dist/observable.d.ts +1 -1
- package/dist/policies.d.ts +1 -1
- package/dist/policies.js +1 -1
- package/dist/react.d.ts +50 -0
- package/dist/react.js +1 -0
- package/dist/redux.d.ts +18 -20
- package/dist/redux.js +1 -1
- package/dist/trace.d.ts +1 -1
- package/docs/README.md +7 -1
- package/docs/actions.md +1 -1
- package/docs/context.md +24 -2
- package/docs/effects.md +82 -5
- package/docs/guide.md +2 -0
- package/docs/migrating-from-effector.md +293 -0
- package/docs/migrating-from-rxjs.md +127 -0
- package/docs/migrating-from-saga.md +93 -0
- package/docs/observable.md +1 -0
- package/docs/package.md +12 -2
- package/docs/policies.md +1 -0
- package/docs/react.md +157 -0
- package/docs/stores.md +429 -40
- package/docs/typescript.md +153 -0
- package/llms-full.txt +2645 -0
- package/llms.txt +48 -0
- package/package.json +35 -6
package/docs/stores.md
CHANGED
|
@@ -1,35 +1,53 @@
|
|
|
1
|
-
# Connect Redux, Zustand, or
|
|
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).
|
|
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
|
|
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 {
|
|
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
|
-
|
|
24
|
-
|
|
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
|
|
46
|
+
middleware: (getDefault) => getDefault().concat(effects),
|
|
29
47
|
});
|
|
30
48
|
|
|
31
49
|
// latest — a newer run aborts the older one
|
|
32
|
-
|
|
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`.**
|
|
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
|
-
|
|
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
|
|
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
|
|
89
|
+
const bus = createBus<Events>(); // the event map types ctx.store
|
|
90
|
+
const effects = createEffectsMiddleware(bus);
|
|
72
91
|
```
|
|
73
92
|
|
|
74
|
-
## Emit from
|
|
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
|
-
|
|
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 {
|
|
109
|
+
import { createAction, emitterPlugin } from "nanoeffects/actions";
|
|
110
|
+
import { debounce, latest } from "nanoeffects/policies";
|
|
81
111
|
|
|
82
|
-
const bus = createBus(); //
|
|
112
|
+
export const bus = createBus().use(emitterPlugin()); // adds bus.emitter; returns the same bus
|
|
83
113
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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 {
|
|
102
|
-
import {
|
|
103
|
-
|
|
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
|
-
|
|
178
|
+
### Valtio
|
|
106
179
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|