nanoeffects 0.28.0 → 0.30.1

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 (139) hide show
  1. package/README.md +6 -5
  2. package/dist/{action-creator-CcAEvUnw.d.ts → action-creator-6rlBTmH9.d.ts} +60 -33
  3. package/dist/actions.d.ts +18 -18
  4. package/dist/actions.js +1 -1
  5. package/dist/bus-DFlmNJ6h.js +1 -0
  6. package/dist/context-B5Z2RpJt.js +1 -0
  7. package/dist/context-types-DKNc5mkS.d.ts +31 -0
  8. package/dist/context.d.ts +8 -3
  9. package/dist/context.js +1 -1
  10. package/dist/{delay-18LJ8jSk.js → delay-5OhJZZQ0.js} +1 -1
  11. package/dist/dev.d.ts +44 -3
  12. package/dist/dev.js +2 -1
  13. package/dist/effects.d.ts +2 -2
  14. package/dist/effects.js +1 -1
  15. package/dist/emit-key-C6FL7Ewn.js +1 -0
  16. package/dist/guard.d.ts +59 -0
  17. package/dist/guard.js +1 -0
  18. package/dist/index.d.ts +2 -2
  19. package/dist/index.js +1 -1
  20. package/dist/match.d.ts +3 -3
  21. package/dist/match.js +1 -1
  22. package/dist/{module-C_oEXw1n.d.ts → module-G39MaVOt.d.ts} +38 -38
  23. package/dist/notrace/{action-creator-DxOvVo8I.d.ts → action-creator-BrMSO3RP.d.ts} +35 -32
  24. package/dist/notrace/actions.d.ts +52 -52
  25. package/dist/notrace/actions.js +1 -1
  26. package/dist/notrace/{bus-DmKn4SDD.js → bus-37nlVljc.js} +1 -1
  27. package/dist/notrace/effects.d.ts +2 -2
  28. package/dist/notrace/index.d.ts +2 -2
  29. package/dist/notrace/index.js +1 -1
  30. package/dist/notrace/{plugin-lJjjd-SN.d.ts → plugin-7GUL2EW5.d.ts} +1 -1
  31. package/dist/notrace/policies.d.ts +1 -1
  32. package/dist/notrace/redux.d.ts +1 -1
  33. package/dist/observable.d.ts +43 -1
  34. package/dist/observable.js +1 -1
  35. package/dist/onAbort-BgMfspK4.js +1 -0
  36. package/dist/oop.d.ts +3 -3
  37. package/dist/oop.js +1 -1
  38. package/dist/{plugin-CuvIgjnX.d.ts → plugin-BWNP23hr.d.ts} +2 -2
  39. package/dist/policies.d.ts +2 -2
  40. package/dist/policies.js +1 -1
  41. package/dist/production/dev.js +1 -1
  42. package/dist/react.d.ts +2 -2
  43. package/dist/react.js +1 -1
  44. package/dist/redux.d.ts +1 -1
  45. package/dist/scope.d.ts +9 -9
  46. package/dist/signal-field-key-CWT-3OXC.js +1 -0
  47. package/dist/signal.d.ts +34 -6
  48. package/dist/signal.js +1 -1
  49. package/dist/site-DLvUoXs3.js +1 -0
  50. package/dist/svelte.d.ts +1 -1
  51. package/dist/trace.d.ts +170 -6
  52. package/dist/trace.js +3 -1
  53. package/dist/{types-FV8fuvI9.d.ts → types-N_cdRr1r.d.ts} +2 -2
  54. package/dist/vue.d.ts +1 -1
  55. package/dist/{with-policy-D-SQRkPh.js → with-policy-CeM1rtES.js} +1 -1
  56. package/dist/{with-policy-3bhFLEXs.d.ts → with-policy-CthiBp_z.d.ts} +1 -1
  57. package/docs/README.md +65 -22
  58. package/docs/actions.md +56 -13
  59. package/docs/cancel-stale-requests.md +2 -0
  60. package/docs/chat-example.md +15 -1
  61. package/docs/chat-widget.md +9 -7
  62. package/docs/context.md +352 -8
  63. package/docs/debounce-and-abort.md +3 -1
  64. package/docs/debugging-loops.md +283 -0
  65. package/docs/effects.md +259 -131
  66. package/docs/errors.md +4 -2
  67. package/docs/examples.md +60 -0
  68. package/docs/guide.md +57 -22
  69. package/docs/hmr.md +35 -1
  70. package/docs/loop-guard.md +109 -0
  71. package/docs/migrating-from-effector.md +15 -2
  72. package/docs/migrating-from-mitt.md +6 -4
  73. package/docs/migrating-from-rxjs.md +2 -0
  74. package/docs/migrating-from-saga.md +2 -0
  75. package/docs/motivation.md +14 -21
  76. package/docs/observable.md +45 -0
  77. package/docs/oop.md +20 -1
  78. package/docs/package.md +25 -13
  79. package/docs/pass-the-signal.md +281 -0
  80. package/docs/plugins.md +90 -15
  81. package/docs/policies.md +73 -46
  82. package/docs/prevent-double-submit.md +3 -1
  83. package/docs/query-libraries.md +276 -6
  84. package/docs/react.md +50 -11
  85. package/docs/recipes.md +528 -0
  86. package/docs/ru/README.md +62 -0
  87. package/docs/ru/actions.md +412 -0
  88. package/docs/ru/cancel-stale-requests.md +106 -0
  89. package/docs/ru/chat-example.md +259 -0
  90. package/docs/ru/chat-widget.md +131 -0
  91. package/docs/ru/context.md +401 -0
  92. package/docs/ru/debounce-and-abort.md +105 -0
  93. package/docs/ru/debugging-loops.md +283 -0
  94. package/docs/ru/effects.md +538 -0
  95. package/docs/ru/errors.md +36 -0
  96. package/docs/ru/examples.md +60 -0
  97. package/docs/ru/guide.md +530 -0
  98. package/docs/ru/hmr.md +83 -0
  99. package/docs/ru/loop-guard.md +109 -0
  100. package/docs/ru/migrating-from-effector.md +339 -0
  101. package/docs/ru/migrating-from-mitt.md +99 -0
  102. package/docs/ru/migrating-from-rxjs.md +197 -0
  103. package/docs/ru/migrating-from-saga.md +97 -0
  104. package/docs/ru/motivation.md +47 -0
  105. package/docs/ru/observable.md +233 -0
  106. package/docs/ru/oop.md +431 -0
  107. package/docs/ru/package.md +42 -0
  108. package/docs/ru/pass-the-signal.md +281 -0
  109. package/docs/ru/plugins.md +398 -0
  110. package/docs/ru/policies.md +201 -0
  111. package/docs/ru/prevent-double-submit.md +122 -0
  112. package/docs/ru/query-libraries.md +392 -0
  113. package/docs/ru/react.md +309 -0
  114. package/docs/ru/recipes.md +528 -0
  115. package/docs/ru/semantics.md +19 -0
  116. package/docs/ru/server.md +204 -0
  117. package/docs/ru/starter.md +118 -0
  118. package/docs/ru/stores.md +673 -0
  119. package/docs/ru/svelte.md +78 -0
  120. package/docs/ru/tracing.md +466 -0
  121. package/docs/ru/typescript.md +170 -0
  122. package/docs/ru/vue.md +92 -0
  123. package/docs/semantics.md +12 -0
  124. package/docs/server.md +5 -3
  125. package/docs/starter.md +2 -0
  126. package/docs/stores.md +2 -0
  127. package/docs/svelte.md +3 -1
  128. package/docs/tracing.md +132 -7
  129. package/docs/typescript.md +3 -1
  130. package/docs/vue.md +3 -1
  131. package/llms-full.txt +2718 -545
  132. package/llms.txt +17 -14
  133. package/package.json +25 -8
  134. package/skills/nanoeffects/SKILL.md +2 -2
  135. package/dist/bus-DlpEluzd.js +0 -1
  136. package/dist/context-UcmrAIhP.js +0 -1
  137. package/dist/context-types-hCKgU8sC.d.ts +0 -14
  138. package/docs/scope.md +0 -266
  139. package/docs/stability.md +0 -90
@@ -0,0 +1,673 @@
1
+ # Подключение Redux, Zustand, MobX и Pinia
2
+
3
+ > **[Уровень E](README.md#виды-проектов)**: готовое приложение на Redux, Zustand, MobX или Pinia, либо библиотека данных. Сначала прочитайте: [Руководство](guide.md#1-создание-шины-событий).
4
+
5
+ Если **store уже есть**, а рядом нужна async-работа с отменой и политиками, подключите к нему шину. Это не причина выбирать Redux или любой другой store.
6
+
7
+ Store ещё нет? Берите `createBus` из [Руководства](guide.md).
8
+
9
+ ## На этой странице
10
+
11
+ - [Подключение Redux Toolkit к шине](#подключение-redux-toolkit-к-шине)
12
+ - [События из state manager](#события-из-state-manager)
13
+ - [Nanostores](#nanostores)
14
+ - [MobX](#mobx)
15
+ - [Valtio](#valtio)
16
+ - [Jotai](#jotai)
17
+ - [Zustand](#zustand)
18
+ - [Pinia](#pinia)
19
+ - [React: одно значение на несколько компонентов](#react-одно-значение-на-несколько-компонентов)
20
+ - [Callbacks на каждое изменение](#callbacks-на-каждое-изменение)
21
+ - [Остановка слежения за store](#остановка-слежения-за-store)
22
+ - [Изменение store из любого обработчика](#изменение-store-из-любого-обработчика)
23
+
24
+ ## Подключение Redux Toolkit к шине
25
+
26
+ `dispatch` в Redux запускает редьюсеры, и они обновляют **state**. Полноценных **задач** (прервать работу в полёте, `latest` / `debounce` / `queue`, `retry` / `timeout`) Redux не даёт. Для них обычно берут thunks, listener middleware из RTK или саги.
27
+
28
+ `createEffectsMiddleware(bus)` закрывает эту потребность: после редьюсера action попадает ещё и на переданную шину nanoeffects. Оставьте RTK для state, а для async берите политики и эффекты.
29
+
30
+ Если вы не на Redux, пропустите раздел. Рецепт в виде файла, который проверяется типами вместе с проектом: [`examples/redux.ts`](../../examples/redux.ts). Запустить его можно в [примере counter](../../examples/counter): `redux.html` — счётчик на Redux Toolkit с этим middleware.
31
+
32
+ ```js
33
+ // bus.js
34
+ import { createBus } from "nanoeffects";
35
+
36
+ export const bus = createBus(); // шина вашего приложения; она может существовать до store
37
+ ```
38
+
39
+ ```js
40
+ // user.js
41
+ import { createAction } from "@reduxjs/toolkit";
42
+
43
+ export const userRequested = createAction("user/requested"); // RTK
44
+ export const userLoaded = createAction("user/loaded");
45
+ ```
46
+
47
+ ```js
48
+ // store.js
49
+ import { configureStore } from "@reduxjs/toolkit";
50
+ import { createEffectsMiddleware } from "nanoeffects/redux";
51
+ import { bus } from "./bus";
52
+
53
+ // createEffectsMiddleware — actions попадают на шину после редьюсеров; задачи получают ctx.store
54
+ const effects = createEffectsMiddleware(bus);
55
+
56
+ export const store = configureStore({
57
+ reducer,
58
+ middleware: (getDefault) => getDefault().concat(effects),
59
+ });
60
+ ```
61
+
62
+ ```js
63
+ // user-handlers.js
64
+ import { latest } from "nanoeffects/policies";
65
+ import { bus } from "./bus";
66
+ import { userLoaded, userRequested } from "./user";
67
+
68
+ // latest — новый запуск прерывает старый
69
+ bus.on(
70
+ userRequested,
71
+ latest(async (id, { store, signal }) => {
72
+ const user = await fetchUser(id, { signal });
73
+ store.dispatch(userLoaded(user));
74
+ }),
75
+ );
76
+ ```
77
+
78
+ Строковые имена событий (`"user/requested"`) работают так же. Используйте ваши обычные actions RTK (`createSlice` / `createAction`). nanoeffects добавляет middleware и политики; actions RTK она не заменяет.
79
+
80
+ ### Что гарантирует middleware
81
+
82
+ - **Нет самозацикливания.** Action, который обработчик отправляет через `ctx.store` и тип которого совпадает с тем, над которым работает этот обработчик, обратно на шину не попадает. Другие обработчики этого типа его тоже не получают. Более длинных циклов (`a` → `b` → `a`) избегайте сами.
83
+ - **Упавший обработчик не ломает `dispatch`.** Если на шине задан `onError`, шина изолирует обработчики. Без него ошибка уходит в `console.error`, как в listener middleware RTK. Редьюсеры по-прежнему выбрасывают ошибку вызывающему.
84
+ - **`store` доходит до задач, когда middleware установлено**, хотя шина создаётся раньше `configureStore`.
85
+
86
+ Единственный параметр — `filter`, который не пускает отклонённые actions на шину. Параметры шины (`onError`, `context`, `trace`) идут в `createBus`. Используйте одну шину на один store. Значения, не являющиеся обычными actions (thunks), проходят нетронутыми. Ничто здесь не зависит от пакета `redux` во время выполнения.
87
+
88
+ ### Какой dispatch начал цепочку
89
+
90
+ Если на шине есть трассировщик и `tracePlugin`, каждый отправленный action попадает на шину из корневой задачи трассировки. Назовите её через `traceId`, а поток соберите в группу через `traceGroupId`: `createEffectsMiddleware(bus, { traceId: "store", traceGroupId: "checkout" })`. Без `traceId` у корня тип action. Без плагина параметры игнорируются. Подробнее в [Трассировке](tracing.md#какая-подписка-начала-цепочку).
91
+
92
+ ### Типы `ctx.store`
93
+
94
+ Типы нужны только затем, чтобы TypeScript знал `store` у обработчиков. **В JavaScript пропустите**: middleware уже добавляет `store` в задачу во время выполнения. Эти строки не связывают store в приложении.
95
+
96
+ ```ts
97
+ import { createBus, type WithContext } from "nanoeffects";
98
+ import {
99
+ createEffectsMiddleware,
100
+ type ReduxStoreApi,
101
+ } from "nanoeffects/redux";
102
+
103
+ // RootState — форма вашего state Redux (для getState)
104
+ type RootState = { user: string | null };
105
+
106
+ // Events — имя события → payload для этой шины
107
+ // WithContext — только для проверки типов: у обработчиков есть и ctx.store
108
+ type Events = {
109
+ "user/requested": string;
110
+ } & WithContext<{ store: ReduxStoreApi<RootState> }>;
111
+
112
+ const bus = createBus<Events>(); // карта событий типизирует ctx.store
113
+ const effects = createEffectsMiddleware(bus);
114
+ ```
115
+
116
+ ## События из state manager
117
+
118
+ У Zustand, MobX, Jotai, Valtio, Nanostores и Pinia адаптера нет. Redux — другое дело: middleware из первого раздела отправляет его actions на шину. Остальные stores работают с шиной в двух направлениях, и ниже оба показаны для каждого store:
119
+
120
+ - **Store запускает событие.** Store сообщает об изменении callback. Вы передаёте ему callback `bus.emitter(action)`, и изменение становится событием, которое запускает обработчик.
121
+ - **Событие меняет store.** Обработчик сообщает результат событием. Подписанный обработчик берёт store из `ctx` и меняет его мутацией.
122
+
123
+ Пример — поле поиска. В store два поля: `query`, которое печатает пользователь, и `results`, которое заполняет запрос. Изменение `query` запускает поиск с debounce; поиск заканчивается событием, которое записывает `results`.
124
+
125
+ Писать `(query) => bus.emit(queryChanged(query))` для каждой подписки значит повторять шину и action. `emitterPlugin` добавляет `bus.emitter(action)`: он возвращает этот callback за вас. Action остаётся обычным `createAction`, так что `bus.emit(queryChanged("nano"))` и `bus.on(queryChanged, …)` работают как раньше.
126
+
127
+ Шина получает store как контекст: `contextPlugin({ store })` добавляет его в каждую задачу как `ctx.store` ([Поля контекста](context.md)). Тогда обработчик меняет store его мутациями и никогда не обращается к store по имени. Для каждого store ниже различается только `store.js`. Он экспортирует `store` с теми же тремя членами:
128
+
129
+ - `setQuery(query)` и `setResults(items)` — мутации. Ввод пользователя вызывает первую, обработчик — вторую.
130
+ - `onQueryChanged(callback)` сообщает об изменении `query` и возвращает функцию, которая это останавливает.
131
+
132
+ Шина, события, обработчики и связь store с шиной одинаковы для каждого store, каждое в своём файле: шина не знает событий, а события не знают шины.
133
+
134
+ ```js
135
+ // bus.js
136
+ import { createBus } from "nanoeffects";
137
+ import { emitterPlugin } from "nanoeffects/actions";
138
+ import { contextPlugin } from "nanoeffects/context";
139
+ import { store } from "./store";
140
+
141
+ export const bus = createBus()
142
+ .use(emitterPlugin()) // добавляет bus.emitter
143
+ .use(contextPlugin({ store })); // ctx.store на каждой задаче
144
+ ```
145
+
146
+ ```js
147
+ // search.js
148
+ import { createAction } from "nanoeffects/actions";
149
+
150
+ // createAction — обычные action creators; вызов лишь строит { type, payload }
151
+ export const queryChanged = createAction("search/queryChanged");
152
+ export const searchLoaded = createAction("search/loaded");
153
+ ```
154
+
155
+ ```js
156
+ // search-handlers.js
157
+ import { debounce, latest } from "nanoeffects/policies";
158
+ import { bus } from "./bus";
159
+ import { queryChanged, searchLoaded } from "./search";
160
+
161
+ // debounce — подождать 300 мс тишины; latest — прервать поиск предыдущего запроса
162
+ bus.on(
163
+ queryChanged,
164
+ debounce(
165
+ 300,
166
+ latest(async (query, ctx) => {
167
+ // api — ваш клиент; ctx.signal позволяет latest отменить сам запрос
168
+ const items = await api.search(query, { signal: ctx.signal });
169
+ ctx.emit(searchLoaded(items)); // сообщить результат событием
170
+ }),
171
+ ),
172
+ );
173
+
174
+ // событие → store: store берётся из ctx, а мутация его меняет
175
+ bus.on(searchLoaded, (items, { store }) => {
176
+ store.setResults(items);
177
+ });
178
+ ```
179
+
180
+ ```js
181
+ // connect.js
182
+ import { bus } from "./bus";
183
+ import { queryChanged } from "./search";
184
+ import { store } from "./store";
185
+
186
+ // store → событие: bus.emitter превращает callback в emit queryChanged
187
+ export const stopQuery = store.onQueryChanged(bus.emitter(queryChanged));
188
+ ```
189
+
190
+ Записывайте `results`, а не то поле, за которым следите: обработчик, изменивший `query`, запустил бы сам себя снова. Найдите свой store ниже.
191
+
192
+ ### Nanostores
193
+
194
+ `action` создаёт именованную мутацию атома. `listen` вызывает callback с новым значением; `subscribe` вызвал бы ещё и сразу, с текущим значением, поэтому используйте `listen`.
195
+
196
+ ```js
197
+ // store.js
198
+ import { action, atom } from "nanostores";
199
+
200
+ const $query = atom("");
201
+ const $results = atom([]);
202
+
203
+ export const store = {
204
+ // action — именованная мутация атома
205
+ setQuery: action($query, "setQuery", (target, query) => target.set(query)),
206
+ setResults: action($results, "setResults", (target, items) => target.set(items)),
207
+ // listen — вызывает callback с новым запросом при каждом изменении
208
+ onQueryChanged: (callback) => $query.listen(callback),
209
+ };
210
+ ```
211
+
212
+ ### MobX
213
+
214
+ `makeAutoObservable` превращает методы класса в actions — именно там MobX хочет видеть изменения. `reaction` следит за выражением и вызывает callback с его значением.
215
+
216
+ <!-- docs-check: fork -->
217
+
218
+ ```js
219
+ // store.js
220
+ import { makeAutoObservable, reaction } from "mobx";
221
+
222
+ class SearchStore {
223
+ query = "";
224
+ results = [];
225
+
226
+ constructor() {
227
+ makeAutoObservable(this); // методы ниже становятся actions
228
+ }
229
+
230
+ setQuery(query) {
231
+ this.query = query;
232
+ }
233
+
234
+ setResults(items) {
235
+ this.results = items;
236
+ }
237
+
238
+ // reaction — следит за выражением и вызывает callback с его значением
239
+ onQueryChanged(callback) {
240
+ return reaction(() => this.query, callback);
241
+ }
242
+ }
243
+
244
+ export const store = new SearchStore();
245
+ ```
246
+
247
+ ### Valtio
248
+
249
+ `subscribeKey` следит за одним полем прокси-state. Мутация присваивает значение прокси.
250
+
251
+ <!-- docs-check: fork -->
252
+
253
+ ```js
254
+ // store.js
255
+ import { proxy } from "valtio";
256
+ import { subscribeKey } from "valtio/utils";
257
+
258
+ const state = proxy({ query: "", results: [] });
259
+
260
+ export const store = {
261
+ setQuery(query) {
262
+ state.query = query;
263
+ },
264
+ setResults(items) {
265
+ state.results = items;
266
+ },
267
+ // subscribeKey — следит за одним полем
268
+ onQueryChanged: (callback) => subscribeKey(state, "query", callback),
269
+ };
270
+ ```
271
+
272
+ ### Jotai
273
+
274
+ `sub` не передаёт значение, так что читайте его сами. Мутация — атом только для записи, а `set` его запускает.
275
+
276
+ <!-- docs-check: fork -->
277
+
278
+ ```js
279
+ // store.js
280
+ import { atom, createStore } from "jotai";
281
+
282
+ const queryAtom = atom("");
283
+ const resultsAtom = atom([]);
284
+ const jotai = createStore();
285
+
286
+ // атомы только для записи: мутации
287
+ const setQueryAtom = atom(null, (_get, set, query) => set(queryAtom, query));
288
+ const setResultsAtom = atom(null, (_get, set, items) => set(resultsAtom, items));
289
+
290
+ export const store = {
291
+ setQuery: (query) => jotai.set(setQueryAtom, query),
292
+ setResults: (items) => jotai.set(setResultsAtom, items),
293
+ // sub вызывает callback без значения; get его читает
294
+ onQueryChanged: (callback) =>
295
+ jotai.sub(queryAtom, () => callback(jotai.get(queryAtom))),
296
+ };
297
+ ```
298
+
299
+ ### Zustand
300
+
301
+ С `subscribeWithSelector` селектор выбирает поле, а callback вызывается только когда оно меняется. Без селектора Zustand вызывает callback при каждом `setState`, со всем state. Мутации — функции в state.
302
+
303
+ <!-- docs-check: fork -->
304
+
305
+ ```js
306
+ // store.js
307
+ import { create } from "zustand";
308
+ import { subscribeWithSelector } from "zustand/middleware";
309
+
310
+ const useSearch = create(
311
+ subscribeWithSelector((set) => ({
312
+ query: "",
313
+ results: [],
314
+ // мутации — часть state
315
+ setQuery: (query) => set({ query }),
316
+ setResults: (items) => set({ results: items }),
317
+ })),
318
+ );
319
+
320
+ export const store = {
321
+ setQuery: (query) => useSearch.getState().setQuery(query),
322
+ setResults: (items) => useSearch.getState().setResults(items),
323
+ // селектор: callback получает state.query, только когда оно меняется
324
+ onQueryChanged: (callback) => useSearch.subscribe((state) => state.query, callback),
325
+ };
326
+ ```
327
+
328
+ ### Pinia
329
+
330
+ Pinia (store Vue) хранит реактивные объекты, а её `$subscribe` сообщает о каждой мутации store, а не об одном поле. Вместо этого следите за одним геттером через `watch` из Vue: он вызывает callback с новым значением. Мутации — actions store.
331
+
332
+ <!-- docs-check: fork -->
333
+
334
+ ```js
335
+ // store.js
336
+ import { defineStore } from "pinia";
337
+ import { watch } from "vue";
338
+
339
+ // defineStore — Pinia: state и actions одного store
340
+ const useSearch = defineStore("search", {
341
+ state: () => ({ query: "", results: [] }),
342
+ actions: {
343
+ setQuery(query) {
344
+ this.query = query;
345
+ },
346
+ setResults(items) {
347
+ this.results = items;
348
+ },
349
+ },
350
+ });
351
+
352
+ const search = useSearch(); // вызывайте после того, как приложение установило Pinia
353
+
354
+ export const store = {
355
+ setQuery: (query) => search.setQuery(query),
356
+ setResults: (items) => search.setResults(items),
357
+ // watch — вызывает callback с новым запросом, когда меняется search.query
358
+ onQueryChanged: (callback) => watch(() => search.query, callback),
359
+ };
360
+ ```
361
+
362
+ ### React: одно значение на несколько компонентов
363
+
364
+ <!-- docs-check: new -->
365
+
366
+ У значения UI (запрос, фильтр, имя) часто две роли. Несколько компонентов читают его и перерисовываются при изменении, а шина должна услышать каждое изменение, чтобы запустить обработчик. Рецепты ниже различаются тем, где живёт значение. Если обработчику не нужно читать значение, берите первый.
367
+
368
+ | Значение живёт в | Когда использовать |
369
+ | --- | --- |
370
+ | Провайдере Context с `useState` и `useEventState` | Его читают только компоненты. Чистый React, по одному вызову `useEmitter` и `useEventState`. |
371
+ | Небольшом store на `useSyncExternalStore` | Значение должны читать ещё и обработчик шины, тест или таймер. |
372
+ | Атоме библиотеки state | Вы уже используете Nanostores, Jotai и подобные. |
373
+
374
+ #### Чистый React: Context, `useEmitter` и `useEventState`
375
+
376
+ Кажется, достаточно написать `useEffect(() => bus.emit(...), [value])`, но такой код отправляет событие при монтировании, дважды при монтировании под StrictMode и срабатывает заново, если функция emit новая в каждом рендере. `useEmitter` из `nanoeffects/react` избегает всех трёх проблем. Он отправляет событие после коммита рендера с изменённым значением, не отправляет при монтировании и сравнивает значения через `Object.is`. Нужен React 18 или новее; другим точкам входа пакета React не нужен.
377
+
378
+ Сначала шина, события и обработчики, каждое в своём файле. `reactPlugin()` добавляет `bus.useEmitter` и `bus.useEventState`, а их деструктуризация даёт обычные хуки, которые вы экспортируете из файла шины:
379
+
380
+ ```js
381
+ // bus.js
382
+ import { createBus } from "nanoeffects";
383
+ import { reactPlugin } from "nanoeffects/react";
384
+
385
+ export const bus = createBus().use(reactPlugin()); // шина; reactPlugin добавляет хуки React
386
+ export const { useEmitter, useEventState } = bus; // свободные хуки: они не читают `this`
387
+ ```
388
+
389
+ ```js
390
+ // search.js
391
+ import { createAction } from "nanoeffects/actions";
392
+
393
+ export const queryChanged = createAction("query/changed");
394
+ export const searchLoaded = createAction("search/loaded");
395
+ ```
396
+
397
+ ```js
398
+ // search-handlers.js
399
+ import { debounce, latest } from "nanoeffects/policies";
400
+ import { bus } from "./bus";
401
+ import { queryChanged, searchLoaded } from "./search";
402
+
403
+ // debounce — подождать 300 мс тишины; latest — новый запрос прерывает старый
404
+ bus.on(
405
+ queryChanged,
406
+ debounce(
407
+ 300,
408
+ latest(async (query, ctx) => {
409
+ const items = await api.search(query, { signal: ctx.signal });
410
+ ctx.emit(searchLoaded(items)); // результат возвращается на шину событием
411
+ }),
412
+ ),
413
+ );
414
+ ```
415
+
416
+ Затем провайдер. Он держит state, один раз вызывает `useEmitter`, чтобы отправить запрос на шину, и один раз `useEventState`, чтобы получить результат. Каждый компонент ниже только читает контекст:
417
+
418
+ ```jsx
419
+ // SearchProvider.jsx
420
+ import { createContext, useContext, useState } from "react";
421
+ import { useEmitter, useEventState } from "./bus";
422
+ import { queryChanged, searchLoaded } from "./search";
423
+
424
+ const SearchContext = createContext(null);
425
+
426
+ export function SearchProvider({ children }) {
427
+ const [query, setQuery] = useState("");
428
+
429
+ // React → шина: отправляет queryChanged(query) после каждого изменения query, но не при монтировании
430
+ useEmitter(queryChanged, query);
431
+
432
+ // шина → React: как useState([]), но значение устанавливает searchLoaded; одна подписка на всё приложение
433
+ const [items] = useEventState(searchLoaded, []);
434
+
435
+ return (
436
+ <SearchContext.Provider value={{ query, setQuery, items }}>
437
+ {children}
438
+ </SearchContext.Provider>
439
+ );
440
+ }
441
+
442
+ export const useSearch = () => useContext(SearchContext);
443
+ ```
444
+
445
+ ```jsx
446
+ // Search.jsx
447
+ import { useSearch } from "./SearchProvider";
448
+
449
+ // любое число компонентов может читать одно и то же значение
450
+ export function SearchBox() {
451
+ const { query, setQuery } = useSearch();
452
+ return <input value={query} onChange={(event) => setQuery(event.target.value)} />;
453
+ }
454
+
455
+ export function Results() {
456
+ const { items } = useSearch();
457
+ return (
458
+ <ul>
459
+ {items.map((item) => (
460
+ <li key={item.id}>{item.title}</li>
461
+ ))}
462
+ </ul>
463
+ );
464
+ }
465
+ ```
466
+
467
+ Три ограничения:
468
+
469
+ - **Провайдер должен смонтироваться первым.** `useEventState` — не store: шина значений не хранит, поэтому результат, пришедший до монтирования провайдера или после его размонтирования, теряется. Ставьте провайдер в корень. Если значение должно пережить это, держите его в store ниже и записывайте в него из обработчика.
470
+ - **Все потребители перерисовываются** при любом изменении значения контекста. Если это медленно, разделите его на два контекста (`query` и `items`).
471
+ - **Новый объект считается изменением.** Если значение пересоздаётся с тем же содержимым, передайте `equals` третьим аргументом, чтобы задать, когда два значения одинаковы: `useEmitter(profileChanged, profile, { equals: (previous, next) => previous.id === next.id })`. Тот же объект принимает `traceId` и `traceGroupId`, которые называют подписку и поток в [трассировке](tracing.md#какая-подписка-начала-цепочку).
472
+
473
+ `useEmitter` отправляет событие после рендера, а не в момент `setQuery`. Чтобы отправлять на каждый вызов, даже с тем же значением, вызывайте эмиттер из обработчика события (`bus.emitter(action)`, см. [Zustand](#zustand)).
474
+
475
+ #### Значение, которое должен читать обработчик
476
+
477
+ Контекст React не виден обработчику шины. Если обработчику нужно текущее значение, держите его вне React. Это делают около двенадцати строк `useSyncExternalStore`, а `contextPlugin` отдаёт значение каждому обработчику как `ctx.query`:
478
+
479
+ <!-- docs-check: new -->
480
+
481
+ ```jsx
482
+ // value-store.js
483
+ import { useSyncExternalStore } from "react";
484
+
485
+ export function createValue(initial) {
486
+ let value = initial;
487
+ const listeners = new Set();
488
+
489
+ return {
490
+ get: () => value,
491
+ set(next) {
492
+ if (Object.is(next, value)) {
493
+ return; // нет изменения — нет уведомления: это же останавливает циклы
494
+ }
495
+ value = next;
496
+ for (const listener of listeners) {
497
+ listener();
498
+ }
499
+ },
500
+ subscribe(listener) {
501
+ listeners.add(listener);
502
+ return () => listeners.delete(listener);
503
+ },
504
+ };
505
+ }
506
+
507
+ export const useValue = (store) => useSyncExternalStore(store.subscribe, store.get);
508
+ ```
509
+
510
+ ```js
511
+ // query.js
512
+ import { createValue } from "./value-store";
513
+
514
+ export const query = createValue("");
515
+ ```
516
+
517
+ ```js
518
+ // bus.js
519
+ import { createBus } from "nanoeffects";
520
+ import { emitterPlugin } from "nanoeffects/actions";
521
+ import { contextPlugin } from "nanoeffects/context";
522
+ import { query } from "./query";
523
+
524
+ // emitterPlugin добавляет bus.emitter; contextPlugin кладёт `query` на каждую задачу как ctx.query
525
+ export const bus = createBus().use(emitterPlugin()).use(contextPlugin({ query }));
526
+ ```
527
+
528
+ ```js
529
+ // search.js
530
+ import { createAction } from "nanoeffects/actions";
531
+
532
+ export const queryChanged = createAction("query/changed");
533
+ export const refreshed = createAction("search/refreshed");
534
+ ```
535
+
536
+ ```js
537
+ // search-handlers.js
538
+ import { bus } from "./bus";
539
+ import { query } from "./query";
540
+ import { queryChanged, refreshed } from "./search";
541
+
542
+ // store → шина: bus.emitter превращает action в callback; одно место, любое число сеттеров
543
+ const emitQueryChanged = bus.emitter(queryChanged);
544
+ query.subscribe(() => emitQueryChanged(query.get()));
545
+
546
+ // обработчик читает текущее значение из ctx, кто бы его ни изменил
547
+ bus.on(refreshed, async (_payload, ctx) => {
548
+ const items = await api.search(ctx.query.get(), { signal: ctx.signal });
549
+ console.log(items.length);
550
+ });
551
+ ```
552
+
553
+ ```jsx
554
+ // SearchBox.jsx
555
+ import { query } from "./query";
556
+ import { useValue } from "./value-store";
557
+
558
+ // любое число компонентов читает и пишет одно и то же значение
559
+ export function SearchBox() {
560
+ const text = useValue(query);
561
+ return <input value={text} onChange={(event) => query.set(event.target.value)} />;
562
+ }
563
+ ```
564
+
565
+ #### Библиотека state
566
+
567
+ С Nanostores, Jotai или Zustand значение уже живёт вне React и имеет подписку. Подключите его к шине один раз, как в [Nanostores](#nanostores), и читайте в компонентах собственным хуком библиотеки:
568
+
569
+ <!-- docs-check: new -->
570
+
571
+ ```js
572
+ // bus.js
573
+ import { createBus } from "nanoeffects";
574
+ import { emitterPlugin } from "nanoeffects/actions";
575
+
576
+ export const bus = createBus().use(emitterPlugin()); // добавляет bus.emitter
577
+ ```
578
+
579
+ ```js
580
+ // search.js
581
+ import { createAction } from "nanoeffects/actions";
582
+
583
+ export const queryChanged = createAction("query/changed");
584
+ ```
585
+
586
+ ```jsx
587
+ // SearchBox.jsx
588
+ import { atom } from "nanostores";
589
+ import { useStore } from "@nanostores/react";
590
+ import { bus } from "./bus";
591
+ import { queryChanged } from "./search";
592
+
593
+ const $query = atom("");
594
+
595
+ // listen, а не subscribe: listen пропускает вызов с текущим значением при старте
596
+ $query.listen(bus.emitter(queryChanged));
597
+
598
+ export function SearchBox() {
599
+ const query = useStore($query); // хук Nanostores; перерисовывает при каждом изменении
600
+ return <input value={query} onChange={(event) => $query.set(event.target.value)} />;
601
+ }
602
+ ```
603
+
604
+ #### Не отправляйте события внутри редьюсера `useReducer`
605
+
606
+ React может запустить редьюсер больше одного раза на один dispatch (дважды в StrictMode), так что `bus.emit` внутри него сработает дважды. Сначала вызывайте `dispatch` редьюсера, затем `emit`, из одного помощника. Либо держите state в редьюсере и передавайте его в `useEmitter`, как любое другое значение.
607
+
608
+ <!-- docs-check: new -->
609
+
610
+ ```js
611
+ // bus.js
612
+ import { createBus } from "nanoeffects";
613
+
614
+ export const bus = createBus(); // шина: создаётся один раз
615
+ ```
616
+
617
+ ```js
618
+ // search.js
619
+ import { createAction } from "nanoeffects/actions";
620
+
621
+ export const queryChanged = createAction("query/changed");
622
+ export const queryCleared = createAction("query/cleared");
623
+ ```
624
+
625
+ ```jsx
626
+ // Search.jsx
627
+ import { useReducer } from "react";
628
+ import { bus } from "./bus";
629
+ import { queryChanged, queryCleared } from "./search";
630
+
631
+ function reducer(state, action) {
632
+ if (queryChanged.match(action)) {
633
+ return action.payload;
634
+ }
635
+ if (queryCleared.match(action)) {
636
+ return "";
637
+ }
638
+ return state;
639
+ }
640
+
641
+ export function Search() {
642
+ const [query, dispatch] = useReducer(reducer, "");
643
+
644
+ // send: сначала редьюсер, затем шина (bus.emit принимает action)
645
+ function send(action) {
646
+ dispatch(action);
647
+ bus.emit(action);
648
+ }
649
+
650
+ return (
651
+ <div>
652
+ <input value={query} onChange={(event) => send(queryChanged(event.target.value))} />
653
+ <button type="button" onClick={() => send(queryCleared())}>
654
+ Clear
655
+ </button>
656
+ </div>
657
+ );
658
+ }
659
+ ```
660
+
661
+ Обычный `dispatch` обходит шину, поэтому везде вызывайте `send`.
662
+
663
+ ### Callbacks на каждое изменение
664
+
665
+ Stores выше вызывают callback, только когда значение изменилось, поэтому эмиттеру не нужно сравнивать значения. Для store, который вызывает callback на каждое изменение, сначала выберите меньшее значение, как это делает Zustand выше.
666
+
667
+ ### Остановка слежения за store
668
+
669
+ `connect.js` хранит функцию, которую вернул `onQueryChanged` (`stopQuery`). Вызовите её сами или передайте дальше: `onAbort(ctx, stopQuery)` внутри задачи или `m.signal.addEventListener("abort", stopQuery)` в модуле. Чтобы остановить обратное направление, снимите обработчик `searchLoaded` через `bus.off(searchLoaded, handler)` или зарегистрируйте его в модуле, который снимет его при отключении.
670
+
671
+ ### Изменение store из любого обработчика
672
+
673
+ Обработчик берёт store из `ctx.store` и вызывает мутацию, что бы он ни обрабатывал: не только результат поиска. Правило то же: записывайте поле, за которым не следите. Для `flow` в MobX `onAbort(ctx, () => promise.cancel())` отменяет его вместе с задачей.