redux-thunk、redux-saga、redux-observable は、Redux アプリケーションで非同期処理やサイドエフェクトを管理するためのミドルウェアです。redux-thunk は関数を返す関数を使う最もシンプルで標準的なアプローチです。redux-saga はジェネレーター関数を使い、複雑なフローやキャンセル処理を宣言的に記述できます。redux-observable は RxJS をベースにし、ストリームベースのイベント処理や高度な合成を可能にします。これらはそれぞれ学習コストと表現力が異なり、プロジェクトの規模や要件に合わせて選択する必要があります。
Redux で非同期処理やサイドエフェクトを扱う際、redux-thunk、redux-saga、redux-observable の 3 つが主要な選択肢です。これらはすべて Redux のアクションフローに介入し、外部 API 呼び出しや複雑なロジックを処理しますが、そのアプローチと適性は大きく異なります。現場での実装経験に基づき、技術的な違いと選定基準を解説します。
最も基本的な「API からデータを取得してストアに保存する」処理を、各ミドルウェアでどのように記述するか比較します。
redux-thunk は、アクションクリエイターが関数を返すようにします。この関数は dispatch と getState を引数に取れます。
// redux-thunk
export const fetchUser = (userId) => {
return async (dispatch, getState) => {
dispatch({ type: 'FETCH_USER_REQUEST' });
try {
const response = await api.getUser(userId);
dispatch({ type: 'FETCH_USER_SUCCESS', payload: response });
} catch (error) {
dispatch({ type: 'FETCH_USER_FAILURE', error });
}
};
};
redux-saga は、ジェネレーター関数(function*)を使います。takeLatest などのヘルパーを使うと、並列処理の制御が容易です。
// redux-saga
import { call, put, takeLatest } from 'redux-saga/effects';
function* fetchUserWorker(action) {
try {
const user = yield call(api.getUser, action.payload);
yield put({ type: 'FETCH_USER_SUCCESS', payload: user });
} catch (error) {
yield put({ type: 'FETCH_USER_FAILURE', error });
}
}
export function* watchFetchUser() {
yield takeLatest('FETCH_USER_REQUEST', fetchUserWorker);
}
redux-observable は、RxJS の Observable を使います。アクションのストリームを監視し、新しいストリームを返すエピックを定義します。
// redux-observable
import { ofType } from 'redux-observable';
import { mergeMap, map, catchError } from 'rxjs/operators';
import { of } from 'rxjs';
const fetchUserEpic = (action$, state$) =>
action$.pipe(
ofType('FETCH_USER_REQUEST'),
mergeMap(action =>
api.getUser(action.payload).pipe(
map(user => ({ type: 'FETCH_USER_SUCCESS', payload: user })),
catchError(error => of({ type: 'FETCH_USER_FAILURE', error }))
)
)
);
ユーザーが素早く入力する検索ボックスなどで、古いリクエストをキャンセルしたり、最新のレスポンスのみを処理したりするケースは頻出します。
redux-thunk では、キャンセル処理を自前で実装する必要があります。AbortController を使うなどが一般的ですが、ミドルウェアレベルの支援はありません。
// redux-thunk
export const searchItems = (query) => {
return async (dispatch, getState) => {
const controller = new AbortController();
// キャンセルトークンの管理を自前で行う必要がある
dispatch({ type: 'SEARCH_REQUEST', meta: { controller } });
try {
const result = await api.search(query, { signal: controller.signal });
dispatch({ type: 'SEARCH_SUCCESS', payload: result });
} catch (error) {
if (error.name !== 'AbortError') {
dispatch({ type: 'SEARCH_FAILURE', error });
}
}
};
};
redux-saga は、キャンセル処理が組み込まれています。takeLatest を使えば、新しいアクションが発生した時点で進行中のタスクを自動的にキャンセルします。
// redux-saga
import { takeLatest } from 'redux-saga/effects';
// 最新の search リクエストのみを処理し、以前のリクエストは自動キャンセル
export function* watchSearch() {
yield takeLatest('SEARCH_REQUEST', searchWorker);
}
redux-observable は、RxJS の演算子を使って柔軟に制御できます。switchMap を使うと、新しいソース値が来た時点で前の内部 Observable をキャンセルします。
// redux-observable
import { switchMap } from 'rxjs/operators';
const searchEpic = (action$) =>
action$.pipe(
ofType('SEARCH_REQUEST'),
// 新しい検索リクエストが来たら、前のリクエストをキャンセル
switchMap(action =>
api.search(action.payload).pipe(
map(result => ({ type: 'SEARCH_SUCCESS', payload: result }))
)
)
);
ミドルウェアのロジックをユニットテストする方法も選定に影響します。
redux-thunk は通常の関数なので、モックした dispatch と getState を渡してテストします。非同期は async/await で待ちます。
// redux-thunk test
it('dispatches success on fetch', async () => {
const dispatch = jest.fn();
const getState = () => ({});
await fetchUser(1)(dispatch, getState);
expect(dispatch).toHaveBeenCalledWith({ type: 'FETCH_USER_SUCCESS', ... });
});
redux-saga はジェネレーターを 1 ステップずつ進めてテストします。yield された効果を断言します。
// redux-saga test
it('calls api and puts success', () => {
const iterator = fetchUserWorker({ payload: 1 });
expect(iterator.next().value).toEqual(call(api.getUser, 1));
expect(iterator.next({ name: 'John' }).value).toEqual(
put({ type: 'FETCH_USER_SUCCESS', payload: { name: 'John' } })
);
});
redux-observable は、テスト用の TestScheduler や、アクションのストリームを流し込んで出力を監視する手法を取ります。
// redux-observable test
it('maps request to success', () => {
const actions = of({ type: 'FETCH_USER_REQUEST', payload: 1 });
const epic = fetchUserEpic(actions);
epic.subscribe(result => {
expect(result.type).toBe('FETCH_USER_SUCCESS');
});
});
各ライブラリが求めるスキルセットとプロジェクトへの影響です。
redux-thunk: 追加の依存関係はほぼありません(Redux Toolkit に含まれています)。JavaScript の非同期関数の知識だけで書けるため、学習コストは最低です。redux-saga: 独自のジェネレーターパターンを学ぶ必要があります。call、put、take などのエフェクトを理解する必要があり、初期の学習コストは中程度です。redux-observable: RxJS の知識が必須です。Observable、Operator、Subject などの概念を理解していないと、コードを読むことも書くことも困難です。学習コストは最も高いです。| 特徴 | redux-thunk | redux-saga | redux-observable |
|---|---|---|---|
| 基本構文 | 関数(async/await) | ジェネレーター関数 | RxJS Observable |
| 学習コスト | 🟢 低い | 🟡 中程度 | 🔴 高い |
| キャンセル処理 | 自前実装が必要 | 🟢 組み込み(takeLatest 等) | 🟢 演算子(switchMap 等) |
| テスト | 通常の非同期テスト | ジェネレーター進行テスト | ストリームテスト |
| 依存関係 | ほぼなし | redux-saga | rxjs |
| 適した規模 | 小〜中規模 | 中〜大規模 | 大規模・複雑なイベント処理 |
プロジェクトの要件とチームのスキルセットに合わせて以下のように選定してください。
redux-thunk は、Redux Toolkit を使っている場合のデファクトスタンダードです。特別な理由がない限り、まずはこれを選ぶべきです。大多数のアプリケーションの非同期要件はこれで十分に満たせます。
redux-saga は、複雑なビジネスロジックや、通信のキャンセル、並列処理の制御など、Thunk では管理が難しくなるケースで力を発揮します。大規模なエンタープライズアプリでよく採用されます。
redux-observable は、すでに RxJS を使っているプロジェクトや、WebSocket やユーザー入力ストリームなど、イベントの流れを細かく制御する必要がある場合に最適です。ただし、チーム全員が RxJS を理解できることが前提となります。
結論として、シンプルさと保守性を求めるなら redux-thunk、複雑なフロー制御が必要なら redux-saga、ストリーム処理が中心なら redux-observable を選定するのが賢明です。
RxJS に習熟しており、イベントストリームの合成やdebounce などの操作を頻繁に行う場合に選択します。リアルタイム性が求められる機能や、複数のアクションストリームを組み合わせる処理に適しています。
複雑な非同期フローや、処理のキャンセル、競合制御が必要な場合に選択します。ジェネレーター関数に慣れているチームや、テスト容易性を重視する大規模アプリケーションに向いています。
シンプルさ最優先の場合に選択します。Redux Toolkit の標準であり、学習コストが最も低いです。単純な API リクエストや、複雑なストリーム処理が必要ないプロジェクトに適しています。