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,122 @@
|
|
|
1
|
+
# Защита от повторной отправки
|
|
2
|
+
|
|
3
|
+
> **[Уровень B](README.md#виды-проектов)**: SPA с async UI. Сначала прочитайте: [Политики](policies.md).
|
|
4
|
+
|
|
5
|
+
Второй клик по «Оплатить», «Зарегистрироваться» или «Сохранить» отправляет запрос дважды. Как игнорировать второй клик, пока идёт первый запрос, как делать это для каждого элемента отдельно и когда лучше поставить вызовы в очередь.
|
|
6
|
+
|
|
7
|
+
## Проблема
|
|
8
|
+
|
|
9
|
+
Пользователь кликает дважды. Оба клика запускают обработчик, и backend получает два запроса.
|
|
10
|
+
|
|
11
|
+
```js
|
|
12
|
+
import { createBus } from "nanoeffects";
|
|
13
|
+
|
|
14
|
+
const bus = createBus(); // шина: события и обработчики
|
|
15
|
+
|
|
16
|
+
// Без политики каждый "submitted" запускает свой запрос.
|
|
17
|
+
bus.on("submitted", async (form) => {
|
|
18
|
+
const order = await api.pay(form);
|
|
19
|
+
bus.emit("paid", order);
|
|
20
|
+
});
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Кнопка `disabled` помогает только до следующего рендера. Флаг (`if (busy) return`) приходится сбрасывать на каждом пути ошибки и после прерывания.
|
|
24
|
+
|
|
25
|
+
## Игнорирование кликов, пока идёт запрос
|
|
26
|
+
|
|
27
|
+
Оберните обработчик в `leading`. Пока идёт один запуск, новые вызовы отбрасываются. Когда запуск заканчивается, следующий клик работает.
|
|
28
|
+
|
|
29
|
+
```js
|
|
30
|
+
import { createBus } from "nanoeffects";
|
|
31
|
+
import { leading } from "nanoeffects/policies";
|
|
32
|
+
|
|
33
|
+
const bus = createBus(); // шина: события и обработчики
|
|
34
|
+
|
|
35
|
+
// leading: вызов отбрасывается, пока предыдущий запуск ещё идёт
|
|
36
|
+
bus.on(
|
|
37
|
+
"submitted",
|
|
38
|
+
leading(async (form, ctx) => {
|
|
39
|
+
const order = await api.pay(form, { signal: ctx.signal });
|
|
40
|
+
ctx.emit("paid", order);
|
|
41
|
+
}),
|
|
42
|
+
);
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Что заменить: `api.pay` — ваша функция запроса.
|
|
46
|
+
|
|
47
|
+
Результат: два `emit("submitted", form)` подряд отправляют один запрос. Второй вызов сразу возвращается и ничего не делает.
|
|
48
|
+
|
|
49
|
+
`leading` снимает блокировку, когда задача завершается или прерывается. Если обработчик выбросил ошибку, следующий клик снова работает, и сбрасывать флаг не нужно.
|
|
50
|
+
|
|
51
|
+
## Один запуск на элемент
|
|
52
|
+
|
|
53
|
+
Список строк с кнопкой «Удалить» не должен блокироваться целиком. Дайте каждому элементу своё state через `key`.
|
|
54
|
+
|
|
55
|
+
```js
|
|
56
|
+
import { createBus } from "nanoeffects";
|
|
57
|
+
import { leading } from "nanoeffects/policies";
|
|
58
|
+
|
|
59
|
+
const bus = createBus(); // шина: события и обработчики
|
|
60
|
+
|
|
61
|
+
// leading с key: один выполняющийся вызов на id строки, другие строки не блокируются
|
|
62
|
+
bus.on(
|
|
63
|
+
"row/deleted",
|
|
64
|
+
leading(
|
|
65
|
+
async (row, ctx) => {
|
|
66
|
+
await api.deleteRow(row.id, { signal: ctx.signal });
|
|
67
|
+
ctx.emit("row/removed", row.id);
|
|
68
|
+
},
|
|
69
|
+
{ key: (row) => row.id },
|
|
70
|
+
),
|
|
71
|
+
);
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Повтор только при сбое сети
|
|
75
|
+
|
|
76
|
+
Повтор неудавшегося платежа может списать деньги дважды, если backend всё же выполнил первый запрос. Повторяйте только при ошибках, когда запрос не дошёл до backend, и сделайте так, чтобы backend принимал ключ идемпотентности.
|
|
77
|
+
|
|
78
|
+
```js
|
|
79
|
+
import { createBus } from "nanoeffects";
|
|
80
|
+
import { leading } from "nanoeffects/policies";
|
|
81
|
+
import { retry } from "nanoeffects/effects";
|
|
82
|
+
|
|
83
|
+
const bus = createBus(); // шина: события и обработчики
|
|
84
|
+
|
|
85
|
+
// fetch отклоняется с TypeError при сбое сети; HTTP-ошибка — обычный Error
|
|
86
|
+
const isNetworkError = (error) => error instanceof TypeError;
|
|
87
|
+
|
|
88
|
+
bus.on(
|
|
89
|
+
"submitted",
|
|
90
|
+
leading(async (form, ctx) => {
|
|
91
|
+
const pay = () => api.pay(form, { signal: ctx.signal });
|
|
92
|
+
// retry: до 3 попыток с паузой 500 мс, только если упала сеть
|
|
93
|
+
const order = await retry(ctx, pay, { times: 3, delay: 500, shouldRetry: isNetworkError });
|
|
94
|
+
ctx.emit("paid", order);
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Растущая пауза между попытками: [`backoff`](effects.md#backoff).
|
|
100
|
+
|
|
101
|
+
## Защита обработчика без шины
|
|
102
|
+
|
|
103
|
+
`withPolicy` превращает политику в обычную функцию, так что обработчику клика шина не нужна.
|
|
104
|
+
|
|
105
|
+
```js
|
|
106
|
+
import { leading, withPolicy } from "nanoeffects/policies";
|
|
107
|
+
|
|
108
|
+
// leading: игнорировать новые вызовы, пока идёт запуск
|
|
109
|
+
const policy = leading(async (form, { signal }) => {
|
|
110
|
+
await api.save(form, { signal });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// withPolicy: обычная функция, которую можно вызывать из onSubmit
|
|
114
|
+
const submit = withPolicy(policy);
|
|
115
|
+
|
|
116
|
+
submit({ name: "Ada" }); // выполняется
|
|
117
|
+
submit({ name: "Ada" }); // игнорируется, пока идёт первый; завершается значением undefined
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
В компоненте React берите [`usePolicy`](react.md#обработчик-с-debounce-между-рендерами).
|
|
121
|
+
|
|
122
|
+
Если клики терять нельзя, берите `queue`, если нужен только новейший — `latest`: [Политики](policies.md).
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
# nanoeffects с React Query, SWR и RTK Query
|
|
2
|
+
|
|
3
|
+
> **[Уровень E](README.md#виды-проектов)**: готовое приложение на Redux, Zustand, MobX или Pinia, либо библиотека данных. Сначала прочитайте: [Руководство](guide.md#1-создание-шины-событий).
|
|
4
|
+
|
|
5
|
+
Данные приходят через [React Query](https://tanstack.com/query) (TanStack Query), [SWR](https://swr.vercel.app) или [RTK Query](https://redux-toolkit.js.org/rtk-query/overview), а `retry`, `timeout` и прерывание из nanoeffects должны работать внутри запроса. Сначала один action, который вызывают эти библиотеки, затем три рецепта: клиент, который останавливается вместе с запросом, живой поток в кэш и повторный запрос по событию шины.
|
|
6
|
+
|
|
7
|
+
Нужны проект на React, `nanoeffects` и одна из библиотек. `api` ниже — ваш клиент: замените его своими запросами.
|
|
8
|
+
|
|
9
|
+
## На этой странице
|
|
10
|
+
|
|
11
|
+
- [Разделение обязанностей](#разделение-обязанностей)
|
|
12
|
+
- [Action для запроса](#action-для-запроса)
|
|
13
|
+
- [Вызов из React Query](#вызов-из-react-query)
|
|
14
|
+
- [Вызов из SWR](#вызов-из-swr)
|
|
15
|
+
- [Результат для шины](#результат-для-шины)
|
|
16
|
+
- [Вызов из RTK Query](#вызов-из-rtk-query)
|
|
17
|
+
- [Автоматическая передача сигнала клиенту](#автоматическая-передача-сигнала-клиенту)
|
|
18
|
+
- [Обновление кэша данными из сокета](#обновление-кэша-данными-из-сокета)
|
|
19
|
+
- [Обновление списков после сохранения](#обновление-списков-после-сохранения)
|
|
20
|
+
- [Ограничения](#ограничения)
|
|
21
|
+
|
|
22
|
+
## Разделение обязанностей
|
|
23
|
+
|
|
24
|
+
| React Query или SWR | nanoeffects |
|
|
25
|
+
| ------------------- | ----------- |
|
|
26
|
+
| `isLoading`, `data`, `error` | `retry`, `timeout`, `latest` и другие политики |
|
|
27
|
+
| кэш, один запрос на один ключ | прерывание, которое доходит до запроса |
|
|
28
|
+
| повторный запрос по фокусу, по переподключению, по интервалу | обработчик: один и тот же код для теста, воркера и UI |
|
|
29
|
+
|
|
30
|
+
`isLoading` и `data` берутся из библиотеки, а не из `emit`. `await bus.emit(...)` завершается один раз, с результатом, поэтому не может сообщить, что запрос ещё идёт.
|
|
31
|
+
|
|
32
|
+
## Action для запроса
|
|
33
|
+
|
|
34
|
+
Action с обработчиком и есть сам запрос. Он живёт в файлах, которые ничего не знают о React: шина, action с обработчиком и компонент.
|
|
35
|
+
|
|
36
|
+
```js
|
|
37
|
+
// bus.js
|
|
38
|
+
import { createBus } from "nanoeffects";
|
|
39
|
+
import { signalPlugin } from "nanoeffects/signal";
|
|
40
|
+
|
|
41
|
+
// createBus — шина, общая для приложения
|
|
42
|
+
// signalPlugin — позволяет emit принимать { signal }, сигнал библиотеки
|
|
43
|
+
export const bus = createBus().use(signalPlugin());
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Обработчик делает до трёх попыток, каждая прерывается через пять секунд. `ctx.signal` — сигнал задачи: он прерывается, когда библиотека прерывает запрос.
|
|
47
|
+
|
|
48
|
+
```js
|
|
49
|
+
// user.js
|
|
50
|
+
import { createAction } from "nanoeffects/actions";
|
|
51
|
+
import { retry, timeout } from "nanoeffects/effects";
|
|
52
|
+
|
|
53
|
+
// createAction с обработчиком — emit возвращает то, что вернул обработчик
|
|
54
|
+
export const getUser = createAction("user/get", async (id, ctx) => {
|
|
55
|
+
// одна попытка: запрос с сигналом timeout, чтобы тот мог его остановить
|
|
56
|
+
const load = ({ signal }) => api.getUser(id, { signal });
|
|
57
|
+
// timeout — провалить попытку через 5 с
|
|
58
|
+
const attempt = () => timeout(ctx, 5000, load);
|
|
59
|
+
// retry — до 3 попыток с паузой 500 мс
|
|
60
|
+
return retry(ctx, attempt, { times: 3, delay: 500 });
|
|
61
|
+
});
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Вызов из React Query
|
|
65
|
+
|
|
66
|
+
<!-- docs-check: continue -->
|
|
67
|
+
|
|
68
|
+
React Query передаёт `queryFn` сигнал `signal` и прерывает его, когда запрос становится неактивным или устаревшим. Передайте его в `emit`. Задача прерывается вместе с ним, поэтому `timeout` и `retry` останавливаются, а с ними и запрос.
|
|
69
|
+
|
|
70
|
+
```jsx
|
|
71
|
+
// Profile.jsx
|
|
72
|
+
import { useQuery } from "@tanstack/react-query";
|
|
73
|
+
import { bus } from "./bus";
|
|
74
|
+
import { getUser } from "./user";
|
|
75
|
+
|
|
76
|
+
export function Profile({ id }) {
|
|
77
|
+
const { isLoading, data, error } = useQuery({
|
|
78
|
+
queryKey: ["user", id],
|
|
79
|
+
// emit — запускает action; { signal } привязывает его задачу к сигналу React Query
|
|
80
|
+
queryFn: ({ signal }) => bus.emit(getUser(id), { signal }),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
if (isLoading) return <p>Loading…</p>;
|
|
84
|
+
if (error) return <p>{error.message}</p>;
|
|
85
|
+
return <p>{data.name}</p>;
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Action отклоняется, когда падают все попытки, так что React Query показывает `error`. React Query считает и свой `retry`: задайте `retry: false` в `useQuery`, когда обработчик уже повторяет, иначе попытки перемножаются.
|
|
90
|
+
|
|
91
|
+
## Вызов из SWR
|
|
92
|
+
|
|
93
|
+
<!-- docs-check: continue -->
|
|
94
|
+
|
|
95
|
+
SWR не передаёт сигнал в fetcher, так что отдавать в `emit` нечего. Action всё равно даёт `retry` и `timeout`.
|
|
96
|
+
|
|
97
|
+
```jsx
|
|
98
|
+
// Profile.jsx
|
|
99
|
+
import useSWR from "swr";
|
|
100
|
+
import { bus } from "./bus";
|
|
101
|
+
import { getUser } from "./user";
|
|
102
|
+
|
|
103
|
+
// fetcher получает ключ (здесь id) и возвращает Promise
|
|
104
|
+
const fetcher = (id) => bus.emit(getUser(id));
|
|
105
|
+
|
|
106
|
+
export function Profile({ id }) {
|
|
107
|
+
const { isLoading, data, error } = useSWR(id, fetcher);
|
|
108
|
+
|
|
109
|
+
if (isLoading) return <p>Loading…</p>;
|
|
110
|
+
if (error) return <p>{error.message}</p>;
|
|
111
|
+
return <p>{data.name}</p>;
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Результат для шины
|
|
116
|
+
|
|
117
|
+
<!-- docs-check: continue -->
|
|
118
|
+
|
|
119
|
+
Action с обработчиком возвращает результат только вызывающему. Если store или другой модуль должен отреагировать на загруженного пользователя, обработчик может сообщить об этом:
|
|
120
|
+
|
|
121
|
+
```js
|
|
122
|
+
// user-events.js
|
|
123
|
+
import { createAction } from "nanoeffects/actions";
|
|
124
|
+
|
|
125
|
+
// createAction — событие с payload
|
|
126
|
+
export const userLoaded = createAction("user/loaded");
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Отправьте его из обработчика через `ctx.emit(userLoaded(user))`. Не заменяйте здесь action на `createJob`: job сам обрабатывает ошибку и завершается значением `undefined`, поэтому React Query никогда не увидел бы сбоя. Берите job, когда никто не ждёт результат, а приложение показывает state загрузки по шине, см. [State работы job](actions.md#state-работы-job).
|
|
130
|
+
|
|
131
|
+
## Вызов из RTK Query
|
|
132
|
+
|
|
133
|
+
<!-- docs-check: new -->
|
|
134
|
+
|
|
135
|
+
RTK Query передаёт `queryFn` эндпоинта `signal` вторым аргументом, так что вызов тот же, что и с React Query: передайте его в `emit`. Эта страница не настраивает store; для этого см. [State managers](stores.md).
|
|
136
|
+
|
|
137
|
+
```js
|
|
138
|
+
// bus.js
|
|
139
|
+
import { createBus } from "nanoeffects";
|
|
140
|
+
import { signalPlugin } from "nanoeffects/signal";
|
|
141
|
+
|
|
142
|
+
// signalPlugin — позволяет emit принимать { signal }
|
|
143
|
+
export const bus = createBus().use(signalPlugin());
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
```js
|
|
147
|
+
// user.js
|
|
148
|
+
import { createAction } from "nanoeffects/actions";
|
|
149
|
+
|
|
150
|
+
// createAction с обработчиком — emit возвращает то, что вернул обработчик
|
|
151
|
+
export const getUser = createAction("user/get", (id, { signal }) =>
|
|
152
|
+
api.getUser(id, { signal }),
|
|
153
|
+
);
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
```js
|
|
157
|
+
// api-slice.js
|
|
158
|
+
import { createApi, fakeBaseQuery } from "@reduxjs/toolkit/query/react";
|
|
159
|
+
import { bus } from "./bus";
|
|
160
|
+
import { getUser } from "./user";
|
|
161
|
+
|
|
162
|
+
export const userApi = createApi({
|
|
163
|
+
reducerPath: "userApi",
|
|
164
|
+
baseQuery: fakeBaseQuery(), // RTK Query — каждый эндпоинт приносит свой queryFn
|
|
165
|
+
endpoints: (build) => ({
|
|
166
|
+
getUser: build.query({
|
|
167
|
+
// emit — запускает action; { signal } привязывает его задачу к запросу RTK Query
|
|
168
|
+
queryFn: async (id, { signal }) => {
|
|
169
|
+
const user = await bus.emit(getUser(id), { signal });
|
|
170
|
+
return { data: user };
|
|
171
|
+
},
|
|
172
|
+
}),
|
|
173
|
+
}),
|
|
174
|
+
});
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## Автоматическая передача сигнала клиенту
|
|
178
|
+
|
|
179
|
+
<!-- docs-check: new -->
|
|
180
|
+
|
|
181
|
+
Проблема: action передаёт `signal` в каждый вызов `api`, и один забытый `{ signal }` оставляет запрос работающим, когда он уже не нужен. `fromSignal` создаёт клиента из сигнала каждой задачи, поэтому обработчик не может забыть сигнал. Этот способ подходит, когда одним клиентом пользуются многие actions. Для одного action проще способ из раздела [Action для запроса](#action-для-запроса).
|
|
182
|
+
|
|
183
|
+
`fromSignal` помечает поле `contextPlugin`. Клиент создаётся, когда обработчик впервые читает `ctx.http`, из `ctx.signal` этой задачи. Подробнее в [Полях контекста](context.md#поле-построенное-из-сигнала-задачи).
|
|
184
|
+
|
|
185
|
+
```js
|
|
186
|
+
// bus.js
|
|
187
|
+
import axios from "axios";
|
|
188
|
+
import { createBus } from "nanoeffects";
|
|
189
|
+
import { contextPlugin } from "nanoeffects/context";
|
|
190
|
+
import { fromSignal, signalPlugin } from "nanoeffects/signal";
|
|
191
|
+
|
|
192
|
+
// fromSignal — http это новый клиент axios на каждую задачу, привязанный к сигналу этой задачи
|
|
193
|
+
const http = fromSignal((signal) => axios.create({ baseURL: "/api", signal }));
|
|
194
|
+
|
|
195
|
+
// contextPlugin — каждый обработчик получает ctx.http
|
|
196
|
+
const context = contextPlugin({ http });
|
|
197
|
+
|
|
198
|
+
// signalPlugin — emit принимает { signal }, так что сигнал запроса доходит до задачи
|
|
199
|
+
export const bus = createBus().use(signalPlugin()).use(context);
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
```js
|
|
203
|
+
// user.js
|
|
204
|
+
import { createAction } from "nanoeffects/actions";
|
|
205
|
+
|
|
206
|
+
// обработчик никогда не видит сигнал: ctx.http останавливается, когда задача прерывается
|
|
207
|
+
export const getUser = createAction("user/get", async (id, { http }) => {
|
|
208
|
+
const response = await http.get(`/users/${id}`);
|
|
209
|
+
return response.data;
|
|
210
|
+
});
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
```jsx
|
|
214
|
+
// Profile.jsx
|
|
215
|
+
import { useQuery } from "@tanstack/react-query";
|
|
216
|
+
import { bus } from "./bus";
|
|
217
|
+
import { getUser } from "./user";
|
|
218
|
+
|
|
219
|
+
export function Profile({ id }) {
|
|
220
|
+
const { data } = useQuery({
|
|
221
|
+
queryKey: ["user", id],
|
|
222
|
+
// emit — сигнал React Query прерывает задачу, а с ней и запрос ctx.http
|
|
223
|
+
queryFn: ({ signal }) => bus.emit(getUser(id), { signal }),
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
return <p>{data?.name}</p>;
|
|
227
|
+
}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
## Обновление кэша данными из сокета
|
|
231
|
+
|
|
232
|
+
<!-- docs-check: new -->
|
|
233
|
+
|
|
234
|
+
Проблема: цены приходят из сокета, и компонент должен показывать самую свежую цену без повторных запросов. Код сокета отправляет на шину action `priceChanged` на каждую новую цену. Компонент читает эти actions через `events` и записывает каждую цену в кэш. В React Query для этого есть `setQueryData`, в SWR — `useSWRSubscription`.
|
|
235
|
+
|
|
236
|
+
`events` передаёт payload в цикл `for await`. `buffer: 1` хранит только новейшую цену, пока идёт запись. Прерывание в очистке завершает цикл и отписывает обработчик. Подробнее в [Чтение события циклом `for await`](observable.md#чтение-события-циклом-for-await).
|
|
237
|
+
|
|
238
|
+
```js
|
|
239
|
+
// bus.js
|
|
240
|
+
import { createBus } from "nanoeffects";
|
|
241
|
+
|
|
242
|
+
// createBus — шина, общая для кода сокета и компонентов
|
|
243
|
+
export const bus = createBus();
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
```js
|
|
247
|
+
// prices.js
|
|
248
|
+
import { createAction } from "nanoeffects/actions";
|
|
249
|
+
|
|
250
|
+
// createAction — событие, которое код сокета отправляет на каждую новую цену
|
|
251
|
+
export const priceChanged = createAction("price/changed");
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
```jsx
|
|
255
|
+
// Price.jsx
|
|
256
|
+
import { useEffect } from "react";
|
|
257
|
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
258
|
+
import { events } from "nanoeffects/observable";
|
|
259
|
+
import { bus } from "./bus";
|
|
260
|
+
import { priceChanged } from "./prices";
|
|
261
|
+
|
|
262
|
+
export function Price({ symbol }) {
|
|
263
|
+
const queryClient = useQueryClient();
|
|
264
|
+
const { data } = useQuery({
|
|
265
|
+
queryKey: ["price", symbol],
|
|
266
|
+
queryFn: () => api.getPrice(symbol), // первое значение; поток поддерживает его свежим
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
useEffect(() => {
|
|
270
|
+
const controller = new AbortController();
|
|
271
|
+
// events — событие с новейшей ценой, по одному
|
|
272
|
+
const prices = events(bus, priceChanged, {
|
|
273
|
+
signal: controller.signal,
|
|
274
|
+
buffer: 1,
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
async function follow() {
|
|
278
|
+
for await (const price of prices) {
|
|
279
|
+
if (price.symbol === symbol) {
|
|
280
|
+
queryClient.setQueryData(["price", symbol], price.value); // записать в кэш
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
follow().catch((error) => {
|
|
286
|
+
if (!controller.signal.aborted) throw error; // прерывание завершает цикл; всё остальное — ошибка
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
return () => controller.abort(); // компонент ушёл: цикл заканчивается, подписка снимается
|
|
290
|
+
}, [queryClient, symbol]);
|
|
291
|
+
|
|
292
|
+
return <p>{data}</p>;
|
|
293
|
+
}
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
SWR делает то же через `useSWRSubscription` из `swr/subscription`. Его функция подписки получает `next`, чтобы протолкнуть значение, и возвращает очистку:
|
|
297
|
+
|
|
298
|
+
```jsx
|
|
299
|
+
// Price.jsx
|
|
300
|
+
import useSWRSubscription from "swr/subscription";
|
|
301
|
+
import { events } from "nanoeffects/observable";
|
|
302
|
+
import { bus } from "./bus";
|
|
303
|
+
import { priceChanged } from "./prices";
|
|
304
|
+
|
|
305
|
+
export function Price({ symbol }) {
|
|
306
|
+
const { data } = useSWRSubscription(["price", symbol], (_key, { next }) => {
|
|
307
|
+
const controller = new AbortController();
|
|
308
|
+
// events — событие с новейшей ценой, по одному
|
|
309
|
+
const prices = events(bus, priceChanged, {
|
|
310
|
+
signal: controller.signal,
|
|
311
|
+
buffer: 1,
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
async function follow() {
|
|
315
|
+
for await (const price of prices) {
|
|
316
|
+
if (price.symbol === symbol) {
|
|
317
|
+
next(null, price.value); // SWR — передать значение хуку
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
follow().catch((error) => {
|
|
323
|
+
if (!controller.signal.aborted) next(error);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
return () => controller.abort(); // SWR вызывает это, когда уходит последний компонент с этим ключом
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
return <p>{data}</p>;
|
|
330
|
+
}
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
## Обновление списков после сохранения
|
|
334
|
+
|
|
335
|
+
<!-- docs-check: new -->
|
|
336
|
+
|
|
337
|
+
Проблема: сохранение в одной части приложения должно обновить списки в другой. Код сохранения отправляет action `userSaved`, а компонент со списком слушает его и просит библиотеку запросов обновить данные (`invalidateQueries`). Чтобы не отписываться от каждого action по отдельности, компонент создаёт один `AbortController` и передаёт его сигнал во все подписки: когда компонент уходит, отписываются все сразу. Для одной подписки короче `return bus.on(…)` из эффекта.
|
|
338
|
+
|
|
339
|
+
Для этого нужен `signalPlugin`, потому что `{ signal }` в параметрах `on` читает плагин. Без него подписки остаются после ухода компонента.
|
|
340
|
+
|
|
341
|
+
```js
|
|
342
|
+
// bus.js
|
|
343
|
+
import { createBus } from "nanoeffects";
|
|
344
|
+
import { signalPlugin } from "nanoeffects/signal";
|
|
345
|
+
|
|
346
|
+
// signalPlugin — on принимает { signal } и снимает подписку, когда сигнал прерывается
|
|
347
|
+
export const bus = createBus().use(signalPlugin());
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
```js
|
|
351
|
+
// user-events.js
|
|
352
|
+
import { createAction } from "nanoeffects/actions";
|
|
353
|
+
|
|
354
|
+
// createAction — события, которые отправляет код сохранения
|
|
355
|
+
export const userSaved = createAction("user/saved");
|
|
356
|
+
export const userDeleted = createAction("user/deleted");
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
```jsx
|
|
360
|
+
// UserList.jsx
|
|
361
|
+
import { useEffect } from "react";
|
|
362
|
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
363
|
+
import { bus } from "./bus";
|
|
364
|
+
import { userDeleted, userSaved } from "./user-events";
|
|
365
|
+
|
|
366
|
+
export function UserList() {
|
|
367
|
+
const queryClient = useQueryClient();
|
|
368
|
+
const { data } = useQuery({ queryKey: ["users"], queryFn: () => api.listUsers() });
|
|
369
|
+
|
|
370
|
+
useEffect(() => {
|
|
371
|
+
const controller = new AbortController();
|
|
372
|
+
const { signal } = controller;
|
|
373
|
+
const refresh = () => queryClient.invalidateQueries({ queryKey: ["users"] });
|
|
374
|
+
|
|
375
|
+
// on с { signal } — обе подписки снимаются, когда компонент уходит
|
|
376
|
+
bus.on(userSaved, refresh, { signal });
|
|
377
|
+
bus.on(userDeleted, refresh, { signal });
|
|
378
|
+
|
|
379
|
+
return () => controller.abort();
|
|
380
|
+
}, [queryClient]);
|
|
381
|
+
|
|
382
|
+
return <ul>{data?.map((user) => <li key={user.id}>{user.name}</li>)}</ul>;
|
|
383
|
+
}
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
## Ограничения
|
|
387
|
+
|
|
388
|
+
- **Без плагина `{ signal }` игнорируется** — и в `emit`, и в параметрах `on`. Action выполняется, но прерывание до него не доходит. Вызовите `bus.use(signalPlugin())` один раз, там где создаётся шина.
|
|
389
|
+
- **Его принимают только формы вызова.** `bus.emit(getUser(id), { signal })` работает; `bus.emit("user/get", id, { signal })` — нет.
|
|
390
|
+
- **Обработчик, который возвращается без Promise, заканчивается вместе с вызовом**, так что сигнал, прерывающийся позже, до него не доходит. Actions, которые делают запросы, асинхронны, поэтому это важно только для синхронного обработчика, который настраивает callback.
|
|
391
|
+
- **Давайте каждому запросу свой сигнал для события.** Для action с обработчиком или задачи связь заканчивается, когда они завершаются. Для `bus.emit(action, { signal })` она остаётся, пока сигнал не прервётся.
|
|
392
|
+
- **Сигнал доходит до задач, которые запускает этот `emit`**, то есть до подписчиков этого action и дочерних задач `ctx.run`. Отдельный `bus.emit`, который делает обработчик, не связан: передайте ему `{ signal: ctx.signal }`.
|