Перейти к содержимому
GitHubXDiscord

От Redux Saga

Саги были ответом на “Redux thunks не композируются для сложных потоков” — генераторные функции, оркеструющие эффекты через рантайм. Триггеры заменяют слой оркестрации; остальная часть твоего Redux-стора может остаться на месте.

Поставляемого codemod-скрипта пока нет. migrate-from-saga намечен в roadmap на 1.0 — а до того момента эта страница является руководством по ручной конверсии.

redux-sagaTriggery
takeLatest(type, worker)concurrency: 'take-latest' (по умолчанию)
takeEvery(type, worker)concurrency: 'take-every'
takeLeading(type, worker)concurrency: 'take-first'
throttle(ms, type, worker)actions.throttle(ms).foo() для выходов или внешний throttling
debounce(ms, type, worker)actions.debounce(ms).foo()
function* watcher() { yield takeLatest(…) }createTrigger({ events: [type], concurrency, handler })
yield put(action)actions.someAction?.(payload)
yield select(selector)conditions.someName — регистрируется через useReduxCondition
yield call(api, …)Просто await api(…) в async handler
yield race({ task, timeout })Promise.race, с signal, кооперирующим отмену
yield all([…])Promise.all(…) внутри обработчика
cancel(task)Автоматически под take-latestsignal.aborted переключается
Before
function* loadDetailsSaga(action) {
  const settings = yield select(state => state.settings);
  if (!settings.notifications) return;
  const data = yield call(api.fetchDetails, action.payload.id);
  yield put(detailsLoaded(data));
}
function* rootSaga() {
  yield takeLatest('chat/openConversation', loadDetailsSaga);
}
After
export const detailsTrigger = createTrigger<{
  events:     { 'chat/openConversation': { id: string } };
  conditions: { settings: Settings };
  actions:    { storeDetails: Details };
}>({
  id: 'details-loader',
  events: ['chat/openConversation'],
  required: ['settings'],
  concurrency: 'take-latest',        // default; here for clarity
  async handler({ event, signal, check, actions }) {
    if (!check.is('settings', s => s.notifications)) return;
    const data = await api.fetchDetails(event.payload.id, { signal });
    if (signal.aborted) return;
    actions.storeDetails?.(data);
  },
});

yield select становится условием; yield put становится действием; неявная отмена takeLatest становится явным signal — но с той же семантикой по умолчанию.

Before
yield takeEvery('analytics/track', function* (action) {
  yield call(analytics.send, action.payload);
});
After
createTrigger<{ events: { 'analytics/track': Event } }>({
  id: 'analytics',
  events: ['analytics/track'],
  concurrency: 'take-every',
  async handler({ event, signal }) {
    await analytics.send(event.payload, { signal });
  },
});

take-every запускается конкурентно, как takeEvery. Ни один из запусков не прерывает другие.

race и all не имеют прямого примитива в Triggery — их пишешь в обработчике стандартными Promise.race / Promise.all. signal служит шиной отмены для обоих:

async handler({ event, signal, actions }) {
  const result = await Promise.race([
    api.fetchSlow(event.payload, { signal }),
    new Promise<'timeout'>(r => setTimeout(() => r('timeout'), 5000)),
  ]);
  if (signal.aborted) return;
  if (result === 'timeout') actions.showError?.('timed out');
  else                      actions.storeResult?.(result);
}

Саги, оборачивающие не-Redux источник событий (WebSocket, кастомный emitter) через eventChannel, чисто ложатся в триггеры + адаптер-продюсер:

Before (sketch)
function* watchSocket() {
  const channel = yield call(createSocketChannel, socket);
  while (true) {
    const msg = yield take(channel);
    yield call(handle, msg);
  }
}
After
// in a component:
const fireMessage = useEvent(messageTrigger, 'new-message');
useSocketIoEvent(socket, 'new-message', fireMessage);

@triggery/socket убирает шаблонный код с каналом.

Генераторы — это до сих пор самый чистый способ выразить долгоживущие многошаговые потоки с явными точками отмены (yield call, yield take, yield cancel). Если у тебя есть сага, напоминающая конечный автомат, рассмотри перенос FSM в XState и запуск её из обработчика Triggery — см. сравнение с XState.

Codemod-скрипта migrate-from-saga пока нет — саги переносятся вручную по соответствию выше. Codemod-скрипты extract-trigger и migrate-from-listener-middleware, которые поставляются уже сейчас, покрывают пути useEffect и RTK listenerMiddleware. Поддержка саг отслеживается в GitHub Project board.

Даже когда codemod появится, он будет трогать только простые формы watcher-ов takeLatest / takeEvery / takeLeading — генераторы с несколькими вызовами yield take всегда требуют человеческой ревизии.