redux-observable と redux-saga は、Redux アプリケーションにおける複雑な非同期ロジック(API リクエスト、WebSocket、タイマーなど)を管理するための主要なミドルウェアです。
redux-observable は、関数型リアクティブプログラミングのパラダイムである RxJS をベースにしています。Action のストリームを入力とし、新しい Action のストリームを出力する「Epic」と呼ばれるユニットでロジックを構成します。ストリームの変換、結合、キャンセル処理を宣言的に記述できるのが特徴です。
redux-saga は、ES6 の Generator 関数を利用して、非同期処理を同期コードのように記述できるように設計されています。「Saga」と呼ばれる独立したタスクでロジックを実行し、Redux Store から Action を「take」して、Effect を「put」する命令的なスタイルを取ります。テストのしやすさと、複雑なフロー制御(競合処理やデバウンスなど)への対応に強みを持ちます。
Redux アプリケーションにおいて、API コールやユーザーインタラクションに伴う非同期処理をどう管理するかは、プロジェクトの拡張性を左右する重要な決定です。redux-observable と redux-saga はどちらもこの課題に対する強力な解決策ですが、そのアプローチと思考モデルは根本的に異なります。ここでは、実務的な観点から両者の技術的な違いを比較します。
redux-observable は、すべての Action を時間の流れに伴う「ストリーム」として捉えます。ロジックは、入力ストリームを変換して出力ストリームを生成するパイプラインとして記述されます。
// redux-observable: Action ストリームの変換
import { ofType } from 'redux-observable';
import { mergeMap, map } from 'rxjs/operators';
import { ajax } from 'rxjs/ajax';
const fetchUserEpic = (action$, state$) =>
action$.pipe(
ofType('FETCH_USER_REQUESTED'),
mergeMap(action =>
ajax.getJSON(`/api/users/${action.payload.id}`).pipe(
map(response => ({ type: 'FETCH_USER_FULFILLED', payload: response }))
)
)
);
redux-saga は、非同期処理を独立した「タスク(Saga)」として実行します。Generator 関数を用いて、Action が発生するのを待ち(take)、処理を行い、新しい Action を送信(put)するという流れを、まるで同期コードのように記述します。
// redux-saga: タスクとしての実行フロー
import { takeEvery, put, call } from 'redux-saga/effects';
import api from './api';
function* fetchUserSaga(action) {
try {
const user = yield call(api.fetchUser, action.payload.id);
yield put({ type: 'FETCH_USER_FULFILLED', payload: user });
} catch (error) {
yield put({ type: 'FETCH_USER_REJECTED', payload: error });
}
}
export function* watchFetchUser() {
yield takeEvery('FETCH_USER_REQUESTED', fetchUserSaga);
}
ユーザーが素早く入力する検索ボックスなどで、直前のリクエストをキャンセルして最新の結果のみを表示したいケースは頻繁にあります。
redux-observable では、RxJS の操作符(switchMap など)を使うことで、新しいストリームが来た時点で以前の内部サブスクリプションを自動的にキャンセルできます。これが非常に簡潔に記述できます。
// redux-observable: switchMap で自動キャンセル
const searchEpic = (action$, state$) =>
action$.pipe(
ofType('SEARCH_QUERY_CHANGED'),
debounceTime(300),
switchMap(action => // ここで以前のリクエストを自動キャンセル
ajax.getJSON(`/api/search?q=${action.payload}`).pipe(
map(response => ({ type: 'SEARCH_RESULTS', payload: response }))
)
)
);
redux-saga では、takeLatest ヘルパーを使うことで同様の効果を得られます。これは、最新のタスク以外は自動的にキャンセルされる仕組みを提供します。
// redux-saga: takeLatest で自動キャンセル
import { takeLatest } from 'redux-saga/effects';
function* handleSearch(action) {
const results = yield call(api.search, action.payload);
yield put({ type: 'SEARCH_RESULTS', payload: results });
}
export function* watchSearch() {
yield takeLatest('SEARCH_QUERY_CHANGED', handleSearch);
}
両者はテスト戦略も異なります。
redux-observable のテストは、RxJS の TestScheduler を使用して、仮想時間上で Action のストリームをシミュレートします。入力ストリームを与えて、出力ストリームが期待通りになるかを確認します。
// redux-observable のテスト例
import { TestScheduler } from 'rxjs/testing';
const scheduler = new TestScheduler((actual, expected) => {
expect(actual).toEqual(expected);
});
scheduler.run(({ cold, expectObservable }) => {
const action$ = cold('a', { a: { type: 'FETCH_USER_REQUESTED', payload: 1 } });
const expected = cold('b', { b: { type: 'FETCH_USER_FULFILLED', payload: { id: 1 } } });
expectObservable(fetchUserEpic(action$)).toBe(expected);
});
redux-saga のテストは、Generator 関数の特性を活かし、next() メソッドを呼び出してyield された値(Effect)を一つずつ検証します。これにより、フローの各ステップが正しい Effect を生成しているかを細かく確認できます。
// redux-saga のテスト例
import { call, put } from 'redux-saga/effects';
const iterator = fetchUserSaga({ payload: { id: 1 } });
// 最初の yield: API コール
expect(iterator.next().value).toEqual(call(api.fetchUser, 1));
// 2 回目の yield: 成功時の Action 送信
expect(iterator.next({ id: 1, name: 'Alice' }).value).toEqual(
put({ type: 'FETCH_USER_FULFILLED', payload: { id: 1, name: 'Alice' } })
);
redux-observable では、現在の Redux ステートにアクセスするために state$ という第二の引数が Epic に渡されます。これもまたストリームであり、必要に応じて組み合わせることができます。
// redux-observable: 状態ストリームとの結合
const epic = (action$, state$) =>
action$.pipe(
ofType('SPECIAL_ACTION'),
withLatestFrom(state$),
map(([action, state]) => {
// ここで state を参照してロジックを分岐
if (state.user.isLoggedIn) { /* ... */ }
})
);
redux-saga では、select Effect を使用してストアから状態を取得します。これも非同期フローの一部として yield されます。
// redux-saga: select による状態取得
import { select } from 'redux-saga/effects';
function* saga() {
const isLoggedIn = yield select(state => state.user.isLoggedIn);
if (isLoggedIn) {
yield put({ type: 'PROCEED' });
}
}
両ミドルウェアとも、Redux の基本原則である「単一ソースオブ真理」や「Action による状態変更」を尊重しています。どちらも Action Creator や Reducer の書き方を変える必要はなく、既存の Redux コードベースにシームレスに統合できます。
どちらも特定の Action タイプをトリガーとしてロジックを開始します。
// 共通のトリガー概念
// dispatch({ type: 'LOAD_DATA' }) -> 両ミドルウェアがこれを検知して処理開始
処理結果は最終的に Action として Redux に戻され、Reducer が状態を更新します。
// 共通の結果出力
// redux-observable: map で Action オブジェクトを返す
// redux-saga: put で Action オブジェクトを送信
// どちらも reducer は同じように処理する: { type: 'LOAD_SUCCESS', payload: data }
どちらも applyMiddleware を使って Redux ストア作成時に適用します。
// 共通のセットアップ
import { createStore, applyMiddleware } from 'redux';
// import { epicMiddleware } from './epics';
// import { sagaMiddleware } from './sagas';
const store = createStore(
reducer,
applyMiddleware(/* epicMiddleware または sagaMiddleware */)
);
| 特徴 | redux-observable | redux-saga |
|---|---|---|
| ベース技術 | RxJS (Observables) | ES6 Generators |
| コードスタイル | 宣言的 (関数型) | 命令的 (同期コード風) |
| 学習曲線 | 高い (RxJS 操作符の知識が必要) | 中程度 (Generator の理解が必要) |
| キャンセル処理 | switchMap 等で非常に柔軟 | takeLatest, cancel 等で明確 |
| テスト | 仮想時間テスト (Marble testing) | ジェネレータの step-by-step 検証 |
| 適したケース | 複雑なストリーム変換、リアルタイム通信 | 複雑なフロー制御、ビジネスロジック |
redux-observable は、イベントストリームの操作に習熟しているチームや、WebSocket やマウスイベントなど「流れ」を扱う必要があるアプリケーションに最適です。RxJS の強力な操作符を使えば、複雑な非同期ロジックも数行で記述できることがあります。
redux-saga は、複雑なビジネスロジックを「タスク」として明確に分割したい場合に適しています。コードの流れが上から下へと読みやすく、デバッグやテストが直感的であるため、大規模なアプリケーションでロジックの複雑さが増す場合に維持しやすい傾向があります。
どちらを選んでも、Redux の非同期処理の問題を解決できます。重要なのは、チームのスキルセットと、プロジェクトが直面する非同期パターンの種類(ストリーム変換重視か、フロー制御重視か)に合わせて選ぶことです。
チームが RxJS やストリームベースのプログラミングに精通しており、イベントストリームの高度な変換(結合、マージ、時間操作など)を頻繁に行う必要がある場合に選択してください。特に、WebSocket やブラウザのイベントなど、継続的なデータストリームを扱うリアルタイムアプリケーションや、複雑なイベント連鎖を宣言的に記述したいプロジェクトに適しています。ただし、RxJS の学習コストが高い点は考慮する必要があります。
非同期処理のフローを直感的で読みやすいコードで記述したい場合や、チームが async/await や Generator 関数に慣れている場合に選択してください。API リクエストのキャンセル、デバウンス、スロットリング、あるいは複数の並行タスクの管理など、複雑な制御ロジックを impérative(命令的)に記述する必要があるプロジェクトで真価を発揮します。また、ビジネスロジックを独立したタスクとしてテストしやすい環境を求めている場合にも最適です。