はじめに
Reduxで非同期処理を書くとき、「APIの呼び出しはコンポーネントの中でawaitすればいいのか、 それともthunk関数に押し込むべきなのか」で迷ったことはないでしょうか。さらに「連打された リクエストは最後の1件だけ処理したい」「複数の非同期処理を並行させて、片方が失敗したら もう片方もキャンセルしたい」といった要件が絡んでくると、Promiseベースの実装はあっという間に 複雑になっていきます。
Redux-Sagaは、この非同期処理の複雑さをジェネレータ関数という仕組みで解きほぐすライブラリです。
「何をするか」を表すプレーンなオブジェクト(Effect)をyieldで返すだけなので、実際の副作用の
実行はRedux-Sagaのミドルウェアが担当します。テスト時はモックを差し込む必要すらなく、
返ってきたオブジェクトを比較するだけで非同期ロジックを検証できるのが大きな特徴です。
とはいえ、読むより触った方が早いと思います。ジェネレータ関数がどう動くのか、 まずは挙動を見たい方はこちらからどうぞ。
Redux-Sagaとは
Redux-Sagaは、Reduxアプリケーションにおける副作用(API通信、タイマー、ブラウザAPIの呼び出し
など)を管理するためのミドルウェアです。「saga」と呼ばれるジェネレータ関数の中で、
callやputといったEffectオブジェクトをyieldしていくことで、非同期の処理フローを
まるで同期処理のように記述できます。
主な特徴
- 宣言的なEffect -
call(api, args)のように「何を呼び出すか」を表すオブジェクトを返すだけで、実際の実行はミドルウェアに任せる - テストのしやすさ - ジェネレータの
next()を呼んで返り値のEffectオブジェクトを検証すればよく、モックやスタブが基本的に不要 - 並行処理の制御 -
takeEvery(全件処理)・takeLatest(最新のみ処理)・race(早い者勝ち)など、非同期処理のパターンに応じたヘルパーが揃っている - キャンセルとエラーハンドリング -
try/catchでエラーを捕捉でき、タスクのキャンセルもcancelEffectで明示的に扱える
インストール
Redux本体と組み合わせて使うため、reduxもあわせて導入します。
npm install redux-saga redux
yarn add redux-saga redux
pnpm add redux-saga redux
Redux-Sagaのサンプルを動かす
以下は、ボタンを押すとtakeEveryで監視しているactionFETCH_USERがディスパッチされ、
saga内でcallによって非同期処理(ここでは擬似的なAPI呼び出し)が実行されるサンプルです。
putで結果をstoreに書き戻すところまでを1つの流れとして確認できます。
要点だけを抜き出すと、次のようになります。
import { call, put, takeEvery } from 'redux-saga/effects'
// callで関数呼び出しを表すEffectを作る(実際の呼び出しはミドルウェアが行う)
function* fetchUserSaga(action) {
try {
const user = yield call(fetchUserApi, action.payload)
yield put({ type: 'user/fetchSucceeded', payload: user })
} catch (e) {
yield put({ type: 'user/fetchFailed', payload: e.message })
}
}
// takeEveryはFETCH_USERが来るたびfetchUserSagaを新規起動する
function* rootSaga() {
yield takeEvery('user/fetchRequested', fetchUserSaga)
}
実際にブラウザ上で動かして確かめられるのが次のサンプルです。
call(fakeFetchUser, action.payload)は「この関数をこの引数で呼ぶ」という指示をただの
オブジェクトとして表現しているだけで、実際に関数を呼び出しているのはRedux-Sagaの
ミドルウェアです。この間接性のおかげで、テストコードではfakeFetchUser自体をモックせずに
「正しいEffectがyieldされたか」だけを検証できます。試しにfakeFetchUserの中で
Promise.reject('取得エラー')を返すよう書き換えてみてください。try/catchが
user/fetchFailedをputし、画面の表示が失敗状態に切り替わることが確認できます。
基本的な使い方
Redux-Sagaを導入する最小構成は、createSagaMiddlewareでミドルウェアを作り、
createStoreに組み込んだあとでrunにsagaを渡す、という流れです。
import { createStore, applyMiddleware } from 'redux'
import createSagaMiddleware from 'redux-saga'
import rootSaga from './sagas'
import rootReducer from './reducers'
const sagaMiddleware = createSagaMiddleware()
const store = createStore(
rootReducer,
applyMiddleware(sagaMiddleware)
)
sagaMiddleware.run(rootSaga)
sagaの中では、redux-saga/effectsからインポートしたヘルパー関数をyieldすることで
様々な処理を表現します。
import { call, put, takeEvery, delay } from 'redux-saga/effects'
function* mySaga() {
yield delay(1000) // 1秒待つ
const result = yield call(api.fetch) // 関数呼び出し
yield put({ type: 'DONE', payload: result }) // actionのdispatch
}
実践的なユースケース
takeLatestで連打を防ぐ
検索フォームの入力のように、短時間に何度もactionが発火する場面では、takeEveryだと
古いリクエストの結果が後から返ってきて画面を上書きしてしまう競合が起こり得ます。
takeLatestを使うと、新しいactionが来た時点で実行中の前回のタスクを自動的にキャンセルし、
常に最新のリクエストの結果だけを反映できます。
takeLatest('search/requested', searchSaga)は、内部的には新しいaction受信時に
古いsearchSagaのタスクへcancel Effectを送っています。試しにfakeSearch内の
遅延時間を300 + Math.random() * 700からMath.random() * 100のように短くしてみてください。
レスポンスが速くなっても、入力を連打したときに古い検索結果が後から画面を上書きすることが
無くなっている(常に最後に入力した内容の結果だけが表示される)のが分かります。
allとcallで複数の非同期処理を並行実行する
複数のAPIを同時に呼び出して、すべて揃ってから画面を更新したいケースでは、allに
複数のcall Effectを配列で渡すことで並行実行できます。Promise.allと似た感覚で使えますが、
Effectオブジェクトとして表現されているため、テスト時にも同じ書きやすさが保たれます。
fetchProfileは500ms、fetchPostsは800ms待ちますが、allで並行実行しているため
合計の待ち時間は800ms程度で済み、直列にcallした場合の1300msより短くなります。
試しにfetchPostsの遅延を800から2000に伸ばしてみてください。dashboard/loadedが
put されるまでの時間がfetchProfileではなくfetchPosts側、つまり一番遅い処理に
引っ張られることが確認できます。
raceでタイムアウト処理を書く
「一定時間経ってもレスポンスがなければ諦める」というタイムアウト処理は、raceを使うと
簡潔に書けます。raceは複数のEffectを競わせ、最初に完了したものの結果だけを受け取り、
残りは自動的にキャンセルします。
raceに渡したresponse(2秒かかるAPI呼び出し)とtimeout(1秒のdelay)のうち、
先に完了したtimeout側の結果だけが{ timeout: undefined }ではなく{ timeout: true }
として返り、responseは自動的にキャンセルされます。試しにdelay(1000)をdelay(3000)に
伸ばしてみてください。今度はslowApiの2秒の方が先に終わるため、
task/succeededが発火してAPIの応答がそのまま表示されるようになります。
まとめ
Redux-Sagaは、Effectオブジェクトをyieldするというジェネレータ関数ならではの仕組みによって、
非同期処理を「宣言的に書けて、テストしやすい」形に落とし込むライブラリです。
takeEveryとtakeLatestの使い分け、allによる並行実行、raceによるタイムアウト処理など、
Promiseだけでは煩雑になりがちな非同期パターンをEffectの組み合わせで表現できるのが強みです。
ジェネレータ構文に最初は戸惑うかもしれませんが、複雑な非同期フローを持つRedux製アプリでは、
一度導入する価値のある選択肢だと言えます。