Операторы
Операторы — это middleware в use: [], оборачивающее запуск query/mutation. Они складываются как луковица: первый оператор в use — самый внешний. Операторы стадии scheduler (concurrency, debounce) всегда оборачивают операторы стадии executor (retry, timeout, fallback), независимо от порядка в массиве.
query({
handler,
use: [
concurrency({ strategy: "takeLatest" }), // внешний: решает, какой запуск победит
retry({ times: 3, delay: 300 }), // внутренний: восстанавливает каждый запуск
],
});concurrency
Стратегия взятия результата — как ведут себя пересекающиеся запуски. Состояние хранится по скоупу и по дорожке, через собственные abort-контроллеры оператора, поэтому новый запуск отменяет только старый (никогда не весь эффект).
| Стратегия | Поведение |
|---|---|
takeLatest (по умолчанию) | отменить предыдущий запуск, оставить новейший. Идеально для поиска по мере ввода. |
takeFirst | пока запуск идёт, новый вызов разделяет его результат (дедуп). |
takeEvery | без координации; каждый вызов выполняется независимо. |
queue | сериализация — новый запуск ждёт завершения текущего. |
key: (params) => … разбивает запуски на независимые дорожки (concurrency по id).
retry
Перезапускает хэндлер при ошибке, соблюдая сигнал отмены. Скипы и отмены не ретраятся, а состояние загрузки между попытками остаётся pending.
retry({ times: 3, delay: (attempt) => 2 ** attempt * 100, when: (e) => isNetworkError(e) });times — максимум ретраев после первой ошибки (по умолчанию 3). delay может быть бэкоффом (attempt, error) => ms. when(error, attempt) отменяет ретрай.
timeout
Прерывает запуск, превысивший ms, и реджектится с TimeoutError. Он гонится с дедлайном, поэтому срабатывает даже если хэндлер игнорирует сигнал — и при этом прерывает контроллер запуска, так что signal-aware хэндлер реально отменяет свой fetch.
use: [timeout(5000)]; // или timeout({ ms: 5000 })debounce
Ждёт wait мс перед запуском. Сам по себе просто задерживает; в паре с takeLatest становится настоящим debounce — новый запуск прерывает предыдущий, пока тот ещё ждёт.
use: [concurrency({ strategy: "takeLatest" }), debounce({ wait: 300 })];fallback
Восстанавливает упавший запуск, резолвясь запасным значением вместо ошибки. Скипы и отмены проходят насквозь. Ставьте его перед retry в use, чтобы он ловил только после исчерпания ретраев.
use: [fallback([]), retry({ times: 2 })]; // статичное значение
use: [fallback((error, params) => cache.get(params.id))]; // вычисляется из error + paramstap
Наблюдает за запуском, не меняя результат — аналитика, логирование, devtools.
use: [
tap({
onStart: (params) => log("start", params),
onSuccess: (data, params) => track("loaded", params),
onError: (error, params) => report(error),
onSettled: (params) => {},
}),
];Колбэки — сайд-эффекты; чтобы записать стор из колбэка, оберните запись в scoped(ctx.scope, …).
Композиция
use читается снаружи→внутрь. Полезный полный стек:
use: [
concurrency({ strategy: "takeLatest" }), // scheduler
debounce({ wait: 300 }), // scheduler
fallback(() => cached), // executor (внешний — ловит после retry)
retry({ times: 3 }), // executor
timeout(8000), // executor (внутренний — ограничивает каждую попытку)
];Новый запуск takeLatest прерывает старый вместе с его циклом ретраев и ожиданием debounce. Поскольку retry/timeout внутренние, все попытки — это один логический запуск для concurrency.
Скипы
Когда оператор отклоняет запуск (дедуп takeFirst, вытесненный takeLatest), он делает это без поддельной ошибки. Вытесненный запуск реджектится с SkipSignal, распознаваемым через isSkip(error) — полезно, когда вы подписаны на failData и хотите игнорировать отмены. Триггеры и refresh глотают их автоматически.
Ещё операторы в планах
cache() (stale-while-revalidate) и гейт barrier запланированы. Другие естественные кандидаты — throttle, poll/keepFresh (перезапрос по интервалу), contract/validate (проверка формы ответа) и circuitBreaker — можно построить на том же контракте Operator.