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,109 @@
1
+ # Обрыв цикла через `loopGuardPlugin`
2
+
3
+ > **[Уровень F](README.md#виды-проектов)**: платформа, SDK или долгоживущее приложение. Сначала прочитайте: [Плагины](plugins.md#подключение-плагина). Чтобы найти цикл до его обрыва, см. [Поиск и обрыв циклов событий](debugging-loops.md).
4
+
5
+ Два обработчика, которые отправляют события друг друга, не остановятся сами. Цикл из `await` и таймеров тратит процессор и сеть. Цикл из микрозадач вешает страницу: браузеру не достаётся ход, чтобы её отрисовать. Отчёт не остановит ни один из них. `loopGuardPlugin` остановит: он следит за цепочкой emit и отклоняет тот emit, который превысит предел.
6
+
7
+ Используйте его как страховку, в том числе в production. Трассировщик ему не нужен, и пока цепочка укладывается в пределы, он ничего не меняет. Плагин подключается по желанию: шина без него работает как раньше.
8
+
9
+ ## На этой странице
10
+
11
+ - [Обрыв цикла](#обрыв-цикла)
12
+ - [Выбор действия на пределе](#выбор-действия-на-пределе)
13
+ - [Ограничение emit извне](#ограничение-emit-извне)
14
+ - [Исключение рекурсии](#исключение-рекурсии)
15
+ - [Чего плагин не делает](#чего-плагин-не-делает)
16
+
17
+ ## Обрыв цикла
18
+
19
+ Плагин считает, сколько раз одно и то же событие стоит в цепочке emit, которая привела к этому emit. Цепочка идёт по `ctx.emit`, в том числе через `await`, таймеры и микрозадачи. Emit, который превысил бы предел, отклоняется, и цикл на этом заканчивается. Другие цепочки продолжают работу, а следующий emit извне начинает цепочку с нуля.
20
+
21
+ ```js
22
+ // bus.js
23
+ import { createBus } from "nanoeffects";
24
+ import { loopGuardPlugin } from "nanoeffects/guard";
25
+
26
+ // loopGuardPlugin — отклоняет emit, который превысил бы предел цепочки
27
+ export const bus = createBus().use(
28
+ loopGuardPlugin({
29
+ maxRepeats: 50, // одно событие может стоять в цепочке 50 раз
30
+ maxDepth: 1000, // цепочка может быть длиной 1000 emit, какие бы имена в ней ни были
31
+ }),
32
+ );
33
+ ```
34
+
35
+ | Параметр | По умолчанию | Что обрывает |
36
+ | -------- | ------------ | ------------ |
37
+ | `maxRepeats` | 50 | Одно событие слишком часто в одной цепочке: цикл. |
38
+ | `maxDepth` | 1000 | Слишком длинную цепочку, в том числе когда у каждого события новое имя (`step/${n}`). |
39
+ | `maxPerSecond` | нет | Emit одного события извне, вне обработчиков, которые идут слишком быстро ([ниже](#ограничение-emit-извне)). |
40
+ | `ignore` | нет | Имена событий, которые счётчик повторов пропускает ([ниже](#исключение-рекурсии)). |
41
+
42
+ ## Выбор действия на пределе
43
+
44
+ `onLimit` говорит, что делает отклонённый emit. В обоих случаях плагин сообщает о находке один раз для каждого пути. В отчёте есть путь цикла и никогда нет payload.
45
+
46
+ ```js
47
+ // bus.js
48
+ import { createBus } from "nanoeffects";
49
+ import { LoopError, loopGuardPlugin } from "nanoeffects/guard";
50
+
51
+ export const bus = createBus().use(
52
+ loopGuardPlugin({
53
+ onLimit: "drop", // "drop" (по умолчанию): emit не отправляется; "throw": он бросает LoopError
54
+ // report — заменяет console.error по умолчанию
55
+ report: (finding) => console.error(finding.limit, finding.path.join(" → ")),
56
+ }),
57
+ );
58
+
59
+ // при onLimit: "throw" отклонённый emit бросает ошибку, а в ней указан путь
60
+ try {
61
+ bus.emit("cart/changed");
62
+ } catch (error) {
63
+ if (error instanceof LoopError) {
64
+ console.log(error.limit, error.path);
65
+ }
66
+ }
67
+ ```
68
+
69
+ - **`"drop"`** — по умолчанию. Emit возвращается так, будто никто не слушал, поэтому в асинхронном обработчике не появляется новых ошибок.
70
+ - **`"throw"`** показывает цикл со стеком. В асинхронном обработчике ошибка превращается в rejection: обработайте её через `onError`.
71
+
72
+ Оборванный цикл может оставить состояние приложения недоделанным: обработчики, которые уже сработали, сделали свою часть, а те, что шли после обрыва, нет. Исправьте цикл, а плагин оставьте как страховку.
73
+
74
+ ## Ограничение emit извне
75
+
76
+ У компонента, который отправляет событие на каждом рендере, цепочки нет: каждый emit приходит извне, вне обработчика. `maxPerSecond` считает такие emit для каждого события.
77
+
78
+ ```js
79
+ // bus.js
80
+ import { createBus } from "nanoeffects";
81
+ import { loopGuardPlugin } from "nanoeffects/guard";
82
+
83
+ // maxPerSecond — emit одного события извне сверх 100 в секунду отклоняются
84
+ export const bus = createBus().use(loopGuardPlugin({ maxPerSecond: 100 }));
85
+ ```
86
+
87
+ По умолчанию параметр выключен: всплеск из многих emit бывает нормальным (поток сообщений).
88
+
89
+ ## Исключение рекурсии
90
+
91
+ Цепочка, которая повторяется намеренно, тоже обрывается. Обработчик, который отправляет `tree/visit` на каждом уровне дерева, или тот, который снова отправляет `tick` после таймера часами, превысит `maxRepeats`. Укажите его событие в `ignore`. `maxDepth` для него по-прежнему действует.
92
+
93
+ ```js
94
+ // bus.js
95
+ import { createBus } from "nanoeffects";
96
+ import { loopGuardPlugin } from "nanoeffects/guard";
97
+
98
+ export const bus = createBus().use(
99
+ loopGuardPlugin({
100
+ ignore: ["tree/visit"], // имена или функция от имени
101
+ }),
102
+ );
103
+ ```
104
+
105
+ ## Чего плагин не делает
106
+
107
+ - **Он не прерывает цепочку.** Он отклоняет один emit, на ребре, где превышен предел. Обработчики, которые уже работают, дорабатывают до конца.
108
+ - **Он следует за `ctx.emit`.** `bus.emit`, вызванный внутри обработчика, не входит в цепочку: он начинает новую со счётчиками с нуля. Внутри обработчиков используйте `ctx.emit`.
109
+ - **Он не ищет цикл.** Чтобы увидеть путь цикла и циклы, которые идут через компоненты, используйте `graphLogger` ([Поиск и обрыв циклов событий](debugging-loops.md)).
@@ -0,0 +1,339 @@
1
+ # Переход с Effector
2
+
3
+ > **[Уровень E](README.md#виды-проектов)**: для тех, кто знает Effector. Сначала прочитайте: [Руководство](guide.md#1-создание-шины-событий).
4
+
5
+ Библиотеки пересекаются в одном: события, запускающие эффекты. Effector ещё и хранит state, а nanoeffects — нет. Имена сопоставлены, один эффект показан в обоих вариантах, перечислено то, у чего аналога нет.
6
+
7
+ ## На этой странице
8
+
9
+ - [Разделение работы](#разделение-работы)
10
+ - [Соответствие](#соответствие)
11
+ - [Переписанный эффект](#переписанный-эффект)
12
+ - [State в Zustand](#state-в-zustand)
13
+ - [Переписанная форма](#переписанная-форма)
14
+ - [Запрос вместо лога](#запрос-вместо-лога)
15
+ - [Совместное использование в одном приложении](#совместное-использование-в-одном-приложении)
16
+ - [Отличия](#отличия)
17
+
18
+ ## Разделение работы
19
+
20
+ | Задача | Использовать |
21
+ | ------ | ------------ |
22
+ | Хранить state (`createStore`, `combine`, `restore`) | Effector или любой store: [State managers](stores.md) |
23
+ | Выводить state из событий (`.on`, `sample` в store) | Effector или любой store |
24
+ | Запускать запрос, когда происходит событие, отменять, повторять, ограничивать по времени, трассировать | nanoeffects |
25
+
26
+ В nanoeffects store нет. Вместо `createStore` возьмите любой store, например Zustand: store и его `set` выполняют работу `createStore` и `.on`. [State managers](stores.md) показывают, как с шиной работают Zustand, Redux, MobX, Jotai и другие. Если вы оставляете Effector, берите nanoeffects только для эффектов, которые запускает `createEffect`, чтобы управлять перекрывающимися вызовами.
27
+
28
+ ## Соответствие
29
+
30
+ | Effector | nanoeffects |
31
+ | -------- | ----------- |
32
+ | `createEvent()` | имя события на шине или [`createAction`](actions.md) |
33
+ | вызов события `loaded(user)` | `bus.emit("loaded", user)` |
34
+ | `createEffect(handler)` | обработчик: `bus.on(event, handler)` |
35
+ | эффект с `done` и `fail` | [`createJob`](actions.md): отправляет actions успеха и ошибки |
36
+ | `effect.pending` | `job.pending`, `job.success`, `job.fail` и `job.cancel`: выставляйте и сбрасывайте флаг в своём store ([Покажите state загрузки](actions.md#state-работы-job)) |
37
+ | `sample({ clock, fn, target })` | `bus.on(clock, handler)` и `ctx.emit(target, …)` |
38
+ | `sample({ source: $store, clock })` | прочитать store из [поля контекста](context.md) в обработчике |
39
+ | `sample({ filter })` | `if` в обработчике |
40
+ | `debounce`, `throttle` из patronum | [`debounce`, `throttle`](policies.md) (те же правила, что у lodash, проверены в тестах) |
41
+ | брать только последний вызов эффекта | [`latest`](policies.md) |
42
+ | `attach` | [поле контекста](context.md): обработчик читает его из `ctx` |
43
+ | `fork()` и scope | один `createBus()` на scope |
44
+ | `createStore`, `combine`, `restore` | любой store, например Zustand, Redux, MobX или Jotai: см. [State managers](stores.md) |
45
+ | `watch`, `createWatch` | `bus.on(event, handler)` |
46
+ | `event.prepend(fn)` | функция, которую пишете вы, `(event) => ({ key, value })`; шине ничего не нужно |
47
+ | `useStoreMap({ store, keys, fn })` | селектор вашего store; с `useState` значение хранит компонент |
48
+ | `event.watch((e) => e.preventDefault())` | вызовите `event.preventDefault()` в обработчике `onSubmit` |
49
+
50
+ ## Переписанный эффект
51
+
52
+ Поиск, который запускается на каждый запрос и реагирует на результат:
53
+
54
+ <!-- docs-check: skip - нужен effector, от которого проект не зависит -->
55
+ ```js
56
+ // Effector
57
+ import { createEffect, createEvent, createStore, sample } from "effector";
58
+
59
+ const queryChanged = createEvent();
60
+ const searchFx = createEffect((query) => api.search(query));
61
+ const $items = createStore([]).on(searchFx.doneData, (_, items) => items);
62
+
63
+ sample({ clock: queryChanged, target: searchFx });
64
+ ```
65
+
66
+ Если пользователь печатает быстро, несколько вызовов `searchFx` пересекаются, а старые не отменяются. Та же задача в nanoeffects отменяет устаревший запрос:
67
+
68
+ ```js
69
+ import { createBus } from "nanoeffects";
70
+ import { debounce, latest } from "nanoeffects/policies";
71
+
72
+ const bus = createBus(); // шина: события и обработчики
73
+
74
+ // latest: новый запрос прерывает запрос в полёте
75
+ const search = latest(async (query, { signal }) => {
76
+ const items = await api.search(query, { signal });
77
+ bus.emit("found", items);
78
+ });
79
+
80
+ bus.on("query-changed", debounce(300, search)); // debounce: запуск после 300 мс тишины
81
+ ```
82
+
83
+ Store (`$items`) здесь нет. Оставьте его в Effector и обновляйте из события `found` или возьмите другой store. Следующий пример использует Zustand.
84
+
85
+ ### State в Zustand
86
+
87
+ Без Effector `$items` нужно хранить в другом месте. Для этого подходит store Zustand: `create` заменяет `createStore`, а `setState` — `.on(searchFx.doneData, …)`. Обработчик пишет результат прямо в store, так что событие `found` не нужно. Компонент читает store селектором, как раньше `useUnit($items)`.
88
+
89
+ ```js
90
+ // bus.js
91
+ import { createBus } from "nanoeffects";
92
+
93
+ export const bus = createBus(); // шина: только шина
94
+ ```
95
+
96
+ ```js
97
+ // search-store.js
98
+ import { create } from "zustand";
99
+
100
+ // create — Zustand: store заменяет $items
101
+ export const useSearchStore = create(() => ({ items: [] }));
102
+ ```
103
+
104
+ ```js
105
+ // search-handlers.js
106
+ import { debounce, latest } from "nanoeffects/policies";
107
+ import { bus } from "./bus";
108
+ import { useSearchStore } from "./search-store";
109
+
110
+ // latest — новый запрос прерывает запрос в полёте; debounce — подождать 300 мс тишины
111
+ const search = latest(async (query, { signal }) => {
112
+ const items = await api.search(query, { signal });
113
+ useSearchStore.setState({ items }); // Zustand: заменяет .on(searchFx.doneData, …)
114
+ });
115
+
116
+ bus.on("query-changed", debounce(300, search));
117
+ ```
118
+
119
+ ```jsx
120
+ // Search.jsx
121
+ import { bus } from "./bus";
122
+ import { useSearchStore } from "./search-store";
123
+
124
+ export function Search() {
125
+ const items = useSearchStore((state) => state.items); // Zustand: селектор читает items
126
+
127
+ return (
128
+ <div>
129
+ <input onChange={(event) => bus.emit("query-changed", event.target.value)} />
130
+ <ul>
131
+ {items.map((item) => (
132
+ <li key={item.id}>{item.title}</li>
133
+ ))}
134
+ </ul>
135
+ </div>
136
+ );
137
+ }
138
+ ```
139
+
140
+ `bus.emit("query-changed", …)` в `onChange` делает работу вызова события `queryChanged`. Чтобы запускать задачу ещё и при изменении store, см. [Zustand в «Stores»](stores.md#zustand).
141
+
142
+ ## Переписанная форма
143
+
144
+ Форма из [рецепта по формам в документации Effector](https://effector.dev/en/recipes/react/forms/) состоит из пяти частей: события изменения поля (`fieldUpdate`), store значений (`$form`), события отправки (`formSubmitted`), эффекта, который их отправляет (`sendFormFx`), и `sample`, который читает store при отправке формы. Четыре из них хранят state или соединяют юниты. Аналог в nanoeffects есть только у эффекта.
145
+
146
+ <!-- docs-check: skip - нужен effector, от которого проект не зависит -->
147
+ ```jsx
148
+ // Effector (адаптировано из документации Effector)
149
+ import { createEffect, createEvent, createStore, sample } from "effector";
150
+ import { useStoreMap } from "effector-react";
151
+
152
+ const formSubmitted = createEvent();
153
+ const fieldUpdate = createEvent();
154
+
155
+ const sendFormFx = createEffect((params) => {
156
+ console.log(params);
157
+ });
158
+
159
+ const $form = createStore({});
160
+ $form.on(fieldUpdate, (form, { key, value }) => ({ ...form, [key]: value }));
161
+
162
+ // sample: когда форма отправлена, прочитать $form и запустить эффект с ним
163
+ sample({ clock: formSubmitted, source: $form, target: sendFormFx });
164
+
165
+ // prepend: превратить DOM-событие в { key, value }, которое принимает fieldUpdate
166
+ const handleChange = fieldUpdate.prepend((event) => ({
167
+ key: event.target.name,
168
+ value: event.target.value,
169
+ }));
170
+
171
+ const Field = ({ name, type, label }) => {
172
+ const value = useStoreMap({
173
+ store: $form,
174
+ keys: [name],
175
+ fn: (values) => values[name] ?? "",
176
+ });
177
+
178
+ return (
179
+ <div>
180
+ {label} <input name={name} type={type} value={value} onChange={handleChange} />
181
+ </div>
182
+ );
183
+ };
184
+
185
+ const App = () => (
186
+ <form onSubmit={formSubmitted}>
187
+ <Field name="login" label="Login" />
188
+ <Field name="password" type="password" label="Password" />
189
+ <button type="submit">Submit!</button>
190
+ </form>
191
+ );
192
+
193
+ formSubmitted.watch((event) => {
194
+ event.preventDefault();
195
+ });
196
+ ```
197
+
198
+ Та же форма с nanoeffects. Значения живут в state React: nanoeffects их не хранит, и любой store из [Stores](stores.md) работает так же. Эффект становится обработчиком события отправки. Значения едут в событии, так что `sample` и `source` не нужны. Сначала шина и обработчик, каждый в своём файле:
199
+
200
+ ```js
201
+ // bus.js
202
+ import { createBus } from "nanoeffects";
203
+
204
+ export const bus = createBus(); // шина: только шина
205
+ ```
206
+
207
+ ```js
208
+ // form-handlers.js
209
+ import { bus } from "./bus";
210
+
211
+ // bus.on — задача sendFormFx: выполняется, когда форма отправлена
212
+ bus.on("form/submitted", (values) => {
213
+ console.log(values);
214
+ });
215
+ ```
216
+
217
+ Затем форма. `useState` выполняет работу `$form`, `fieldUpdate` и `prepend`; обработчик события выполняет работу `watch`, который вызывал `preventDefault`:
218
+
219
+ ```jsx
220
+ // App.jsx
221
+ import { useState } from "react";
222
+ import { bus } from "./bus"; // шина из предыдущего фрагмента
223
+ import "./form-handlers"; // регистрирует обработчик; приложение делает это один раз, при старте
224
+
225
+ function Field({ name, type, label, value, onChange }) {
226
+ return (
227
+ <div>
228
+ {label} <input name={name} type={type} value={value} onChange={onChange} />
229
+ </div>
230
+ );
231
+ }
232
+
233
+ export function App() {
234
+ const [form, setForm] = useState({}); // значения формы, как их хранил $form
235
+
236
+ // один обработчик на каждое поле, как был handleChange
237
+ function handleChange(event) {
238
+ const { name, value } = event.target;
239
+ setForm((current) => ({ ...current, [name]: value }));
240
+ }
241
+
242
+ function handleSubmit(event) {
243
+ event.preventDefault(); // страница не должна перезагружаться; отдельный watcher не нужен
244
+ bus.emit("form/submitted", form); // bus.emit — значения идут в событии
245
+ }
246
+
247
+ return (
248
+ <form onSubmit={handleSubmit}>
249
+ <Field name="login" label="Login" value={form.login ?? ""} onChange={handleChange} />
250
+ <Field
251
+ name="password"
252
+ type="password"
253
+ label="Password"
254
+ value={form.password ?? ""}
255
+ onChange={handleChange}
256
+ />
257
+ <button type="submit">Submit!</button>
258
+ </form>
259
+ );
260
+ }
261
+ ```
262
+
263
+ `useStoreMap` перерисовывает только то поле, значение которого изменилось. Здесь каждое поле перерисовывается вместе с формой, что нормально для небольшой формы. Если это важно, возьмите селектор вашего store (он есть у Zustand и Jotai).
264
+
265
+ Если значения живут в store вне React, передайте store обработчикам через `createBus({ context: { store } })` ([Поля контекста](context.md)) и читайте его в обработчике, как `sample` читал `$form`.
266
+
267
+ ### Запрос вместо лога
268
+
269
+ `sendFormFx` только логирует. Настоящая регистрация отправляет запрос, и тогда важны двойной клик и сеть. `leading` отбрасывает вторую отправку, пока идёт первая, а `ctx.signal` позволяет отменить запрос:
270
+
271
+ ```js
272
+ import { createBus } from "nanoeffects";
273
+ import { leading } from "nanoeffects/policies";
274
+
275
+ const bus = createBus(); // шина: события и их обработчики
276
+
277
+ // leading — вторая отправка игнорируется, пока идёт запрос
278
+ bus.on(
279
+ "form/submitted",
280
+ leading(async (values, ctx) => {
281
+ // api — ваш клиент; ctx.signal позволяет отменить запрос
282
+ await api.signUp(values, { signal: ctx.signal });
283
+ }),
284
+ );
285
+ ```
286
+
287
+ Повтор упавшего сетевого запроса и отмена при закрытии формы разобраны в [Форме регистрации на React](react.md).
288
+
289
+ ## Совместное использование в одном приложении
290
+
291
+ Оставьте stores Effector и запускайте эффекты на шине. Store обращается к шине через `bus.emitter`, а обработчик возвращает результат событием, которое слушает store.
292
+
293
+ ```js
294
+ // bus.js
295
+ import { createBus } from "nanoeffects";
296
+ import { emitterPlugin } from "nanoeffects/actions";
297
+
298
+ export const bus = createBus().use(emitterPlugin()); // добавляет bus.emitter
299
+ ```
300
+
301
+ ```js
302
+ // search.js
303
+ import { createAction } from "nanoeffects/actions";
304
+
305
+ export const queryChanged = createAction("query/changed"); // обычный action creator
306
+ ```
307
+
308
+ <!-- docs-check: skip - нужен effector, от которого проект не зависит -->
309
+ ```js
310
+ import { createEvent, createStore } from "effector";
311
+ import { latest } from "nanoeffects/policies";
312
+ import { bus } from "./bus";
313
+ import { queryChanged } from "./search";
314
+
315
+ const found = createEvent(); // Effector: результат возвращается как событие
316
+ const $items = createStore([]).on(found, (_, items) => items);
317
+ const $query = createStore("");
318
+
319
+ // watch вызывает callback на каждое изменение store; bus.emitter превращает его в emit
320
+ $query.watch(bus.emitter(queryChanged));
321
+
322
+ bus.on(
323
+ queryChanged,
324
+ latest(async (query, { signal }) => {
325
+ const items = await api.search(query, { signal });
326
+ found(items); // обратно в Effector
327
+ }),
328
+ );
329
+ ```
330
+
331
+ ## Отличия
332
+
333
+ - **Нет state.** Нет `createStore`, `combine`, `restore`, `$store.map`. Шина хранит только подписки. Для state возьмите store вроде Zustand ([State managers](stores.md)).
334
+ - **`pending` — action, а не store.** `createJob` отправляет `pending`, когда стартует обработчик, и `success`, `fail` или `cancel`, когда он заканчивается; флаг вы храните в своём store ([Покажите state загрузки](actions.md#state-работы-job)). Политики сообщают задаче, поэтому флаг следует и за `latest`, `leading`, `debounce`, `throttle` и `queue`; в разделе по ссылке выше сказано, когда вместо флага нужен счётчик. Выполняющуюся задачу видно и через [Трассировку](tracing.md).
335
+ - **Отмена — инструмент по умолчанию.** Эффект Effector выполняет каждый вызов до конца. Здесь `latest` и `ctx.signal` останавливают то, что больше не нужно.
336
+ - **События сопоставляются по имени, а не по идентичности.** Событие — это строка или action creator из `createAction`, а не юнит, который вы импортируете и вызываете. Два модуля договариваются об имени.
337
+ - **Нет графа.** `sample` и `forward` строят статический граф. Шина подписывает обработчики во время выполнения, а порядок вызова — порядок `on`.
338
+ - **Данные передаются в событии.** `sample({ source })` читает store в момент clock. Здесь отправитель кладёт значения в событие (`bus.emit("form/submitted", form)`), либо обработчик читает store из [поля контекста](context.md).
339
+ - **Scope — это шины.** `fork()` превращается в `createBus()` на каждый scope (тест, запрос на backend).
@@ -0,0 +1,99 @@
1
+ # Переход с mitt и nanoevents
2
+
3
+ > **[Уровень A](README.md#виды-проектов)**: для тех, кто знает mitt или nanoevents. Сначала прочитайте: ничего, шина — та же идея.
4
+
5
+ Для приложений с небольшим эмиттером событий (`mitt`, `nanoevents`, `tiny-emitter`), чьи обработчики начали делать async-работу: запросы, таймеры, повторы. Вызовы сопоставлены, показано, что вы выигрываете и что проверить перед переходом.
6
+
7
+ ## Когда переходить
8
+
9
+ Эмиттер вызывает функцию и забывает о ней. Пока обработчики синхронные, этого хватает. Переходите, когда обработчики начали async-работу и вы столкнулись с одной из проблем:
10
+
11
+ - старый запрос заканчивается после нового и затирает его результат;
12
+ - вы вручную храните объекты `AbortController`;
13
+ - вы сами пишете debounce, throttle или retry вокруг обработчиков;
14
+ - вы не можете сказать, какое событие запустило какой запрос.
15
+
16
+ Если обработчики остаются синхронными, оставьте свой эмиттер. nanoeffects весит около 1,4 кБ в gzip (1,3 кБ в brotli) — близко к небольшим эмиттерам.
17
+
18
+ ## Соответствие
19
+
20
+ | mitt / nanoevents | nanoeffects |
21
+ | ----------------- | ----------- |
22
+ | `emitter.on("a", handler)` | `bus.on("a", handler)` |
23
+ | `emitter.off("a", handler)` | вызовите функцию, которую вернул `bus.on`, или `bus.off("a")` для всех обработчиков `a` |
24
+ | `emitter.emit("a", payload)` | `bus.emit("a", payload)` |
25
+ | `emitter.once("a", handler)` | `bus.once("a", handler)` |
26
+ | `on("*", (type, payload) => …)` | `bus.on("*", (payload, ctx) => …)`: имя события — `ctx.type` (см. [Руководство](guide.md)) |
27
+ | `emitter.all.clear()` | `bus.off()` |
28
+
29
+ Два отличия:
30
+
31
+ - `bus.on` возвращает функцию отписки. Хранить обработчик, чтобы его снять, не нужно.
32
+ - Обработчик получает второй аргумент — контекст задачи: `(payload, ctx)`. Его можно игнорировать, пока не понадобится `ctx.signal` или `ctx.emit`.
33
+
34
+ ## Перенос обработчика
35
+
36
+ Было, с `mitt`:
37
+
38
+ <!-- docs-check: skip - нужен mitt, от которого проект не зависит -->
39
+ ```js
40
+ import mitt from "mitt";
41
+
42
+ const emitter = mitt();
43
+
44
+ emitter.on("search", async (query) => {
45
+ const items = await api.search(query);
46
+ emitter.emit("results", items);
47
+ });
48
+
49
+ emitter.emit("search", "abc");
50
+ ```
51
+
52
+ Стало, с nanoeffects. Первый шаг меняет только импорт и конструктор:
53
+
54
+ ```js
55
+ import { createBus } from "nanoeffects";
56
+
57
+ const bus = createBus(); // шина событий; каждый вызов обработчика — задача
58
+
59
+ bus.on("search", async (query) => {
60
+ const items = await api.search(query);
61
+ bus.emit("results", items);
62
+ });
63
+
64
+ bus.emit("search", "abc");
65
+ ```
66
+
67
+ Второй шаг чинит гонку. Оберните обработчик в `latest` и передайте сигнал в запрос:
68
+
69
+ ```js
70
+ import { createBus } from "nanoeffects";
71
+ import { latest } from "nanoeffects/policies";
72
+
73
+ const bus = createBus(); // шина событий; каждый вызов обработчика — задача
74
+
75
+ // latest: новый "search" прерывает старый запуск
76
+ bus.on(
77
+ "search",
78
+ latest(async (query, { signal }) => {
79
+ const items = await api.search(query, { signal }); // signal отменяет запрос
80
+ bus.emit("results", items);
81
+ }),
82
+ );
83
+
84
+ bus.emit("search", "abc");
85
+ ```
86
+
87
+ Другие случаи (пауза, повтор, таймаут): [Отмена устаревших запросов](cancel-stale-requests.md).
88
+
89
+ ## Различия
90
+
91
+ | Тема | Эмиттер | nanoeffects |
92
+ | ---- | ------- | ----------- |
93
+ | Порядок обработчиков | порядок подписки | порядок подписки |
94
+ | Ошибка в обработчике | зависит от библиотеки | первая ошибка останавливает остальных и выбрасывается из `emit`; задайте `onError`, чтобы изолировать обработчики ([Ошибки](errors.md)) |
95
+ | Async-обработчик отклоняется | обычно необработанное отклонение | не обрабатывается без `onError`; задайте `onError`, чтобы его поймать |
96
+ | Событие без подписчика | игнорируется | игнорируется; задайте `onUnhandled`, чтобы узнавать |
97
+ | Типы | карта событий в параметре типа | карта событий в параметре типа ([TypeScript](typescript.md)) |
98
+
99
+ Точные правила `emit`, прерывания и порядка: [Семантика](semantics.md).