nanoeffects 0.29.0 → 0.31.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 +6 -5
- package/dist/{action-creator-jeEqIKgK.d.ts → action-creator-6rlBTmH9.d.ts} +27 -2
- package/dist/actions.d.ts +3 -3
- package/dist/actions.js +1 -1
- package/dist/{bus-0uYn_kRj.js → bus-DFlmNJ6h.js} +1 -1
- package/dist/context-B5Z2RpJt.js +1 -0
- package/dist/{context-types-Bme1mzkn.d.ts → context-types-DKNc5mkS.d.ts} +1 -1
- package/dist/context.d.ts +2 -2
- package/dist/dev.d.ts +49 -3
- package/dist/dev.js +2 -1
- package/dist/effects.d.ts +2 -2
- package/dist/effects.js +1 -1
- package/dist/emit-key-C6FL7Ewn.js +1 -0
- package/dist/guard.d.ts +59 -0
- package/dist/guard.js +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/match.d.ts +1 -1
- package/dist/{module-BWMW83qk.d.ts → module-G39MaVOt.d.ts} +2 -2
- package/dist/notrace/{action-creator-xpQ_I2h8.d.ts → action-creator-BrMSO3RP.d.ts} +1 -0
- package/dist/notrace/actions.d.ts +2 -2
- package/dist/notrace/effects.d.ts +2 -2
- package/dist/notrace/index.d.ts +1 -1
- package/dist/notrace/{plugin-6LhNx0th.d.ts → plugin-7GUL2EW5.d.ts} +1 -1
- package/dist/notrace/policies.d.ts +1 -1
- package/dist/notrace/redux.d.ts +1 -1
- package/dist/observable.d.ts +1 -1
- package/dist/oop.d.ts +1 -1
- package/dist/oop.js +1 -1
- package/dist/{plugin-BaErevLy.d.ts → plugin-BWNP23hr.d.ts} +2 -2
- package/dist/policies.d.ts +2 -2
- package/dist/policies.js +1 -1
- package/dist/production/dev.js +1 -1
- package/dist/react.d.ts +2 -2
- package/dist/react.js +1 -1
- package/dist/redux.d.ts +1 -1
- package/dist/scope.d.ts +2 -2
- package/dist/signal.d.ts +22 -2
- package/dist/signal.js +1 -1
- package/dist/site-DLvUoXs3.js +1 -0
- package/dist/svelte.d.ts +1 -1
- package/dist/trace.d.ts +151 -2
- package/dist/trace.js +3 -1
- package/dist/{types-D5xsR37j.d.ts → types-N_cdRr1r.d.ts} +2 -2
- package/dist/vue.d.ts +1 -1
- package/dist/{with-policy-C6BIiPIi.js → with-policy-CeM1rtES.js} +1 -1
- package/dist/{with-policy-BZVRBUOm.d.ts → with-policy-CthiBp_z.d.ts} +1 -1
- package/docs/README.md +62 -31
- package/docs/actions.md +44 -27
- package/docs/cancel-stale-requests.md +2 -0
- package/docs/chat-example.md +3 -1
- package/docs/chat-widget.md +2 -0
- package/docs/context.md +322 -9
- package/docs/debounce-and-abort.md +3 -1
- package/docs/debugging-loops.md +318 -0
- package/docs/effects.md +255 -138
- package/docs/errors.md +4 -2
- package/docs/examples.md +60 -0
- package/docs/guide.md +39 -22
- package/docs/hmr.md +35 -1
- package/docs/loop-guard.md +109 -0
- package/docs/migrating-from-effector.md +4 -2
- package/docs/migrating-from-mitt.md +6 -4
- package/docs/migrating-from-rxjs.md +2 -0
- package/docs/migrating-from-saga.md +2 -0
- package/docs/motivation.md +14 -21
- package/docs/observable.md +2 -0
- package/docs/oop.md +3 -1
- package/docs/package.md +4 -1
- package/docs/pass-the-signal.md +346 -0
- package/docs/plugins.md +22 -5
- package/docs/policies.md +73 -46
- package/docs/prevent-double-submit.md +3 -1
- package/docs/query-libraries.md +3 -1
- package/docs/react.md +42 -13
- package/docs/recipes.md +528 -0
- package/docs/ru/README.md +62 -0
- package/docs/ru/actions.md +412 -0
- package/docs/ru/cancel-stale-requests.md +106 -0
- package/docs/ru/chat-example.md +259 -0
- package/docs/ru/chat-widget.md +131 -0
- package/docs/ru/context.md +402 -0
- package/docs/ru/debounce-and-abort.md +105 -0
- package/docs/ru/debugging-loops.md +318 -0
- package/docs/ru/effects.md +538 -0
- package/docs/ru/errors.md +36 -0
- package/docs/ru/examples.md +60 -0
- package/docs/ru/guide.md +530 -0
- package/docs/ru/hmr.md +83 -0
- package/docs/ru/loop-guard.md +109 -0
- package/docs/ru/migrating-from-effector.md +339 -0
- package/docs/ru/migrating-from-mitt.md +99 -0
- package/docs/ru/migrating-from-rxjs.md +197 -0
- package/docs/ru/migrating-from-saga.md +97 -0
- package/docs/ru/motivation.md +47 -0
- package/docs/ru/observable.md +233 -0
- package/docs/ru/oop.md +431 -0
- package/docs/ru/package.md +42 -0
- package/docs/ru/pass-the-signal.md +346 -0
- package/docs/ru/plugins.md +398 -0
- package/docs/ru/policies.md +201 -0
- package/docs/ru/prevent-double-submit.md +122 -0
- package/docs/ru/query-libraries.md +392 -0
- package/docs/ru/react.md +309 -0
- package/docs/ru/recipes.md +528 -0
- package/docs/ru/semantics.md +19 -0
- package/docs/ru/server.md +204 -0
- package/docs/ru/starter.md +118 -0
- package/docs/ru/stores.md +673 -0
- package/docs/ru/svelte.md +78 -0
- package/docs/ru/tracing.md +466 -0
- package/docs/ru/typescript.md +170 -0
- package/docs/ru/vue.md +92 -0
- package/docs/semantics.md +2 -0
- package/docs/server.md +2 -0
- package/docs/starter.md +2 -0
- package/docs/stores.md +2 -0
- package/docs/svelte.md +3 -1
- package/docs/tracing.md +58 -1
- package/docs/typescript.md +2 -0
- package/docs/vue.md +3 -1
- package/llms-full.txt +2087 -418
- package/llms.txt +11 -8
- package/package.json +23 -6
- package/dist/context-DtUnzC-1.js +0 -1
- package/docs/scope.md +0 -278
- 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())` отменяет его вместе с задачей.
|