まだredux-saga使ってないの?
February 28th, 2020
Introduction
煽りタイトルすいません。。。 React Retuxを使うとだれしもが一度は副作用(非同期通信)で悩むと思います。 ReactのuseEffect内でfetchしたり、useStateやReduxのmiddlewareライブラリで対応したり、やり方はいろいろあるかと思います。 今回は最近ずっと僕がはまってる、redux-sagaについて書いていきたいと思います。
Reduxにおける副作用
Reduxで副作用を扱おうと思ったら大抵出てくるのは
- redux-thunk
- redux-saga
ら辺かと思います。 他には先述の通り、そもそもReduxのmiddlewareで解決せずにReact側で副作用を解決しちゃう方法とかもありますが、Reactのライフサイクルを完全に意識する必要があるので、あまりぼくはおすすめしません。
上記2つはどちらも副作用を扱うのは変わらないのですが、結構やり方は両極端な感じです。
redux-thunk
そもそもthunkとは、関数型のテクニックのひとつで、引数なしの関数などを返し手続き的な関数を得ることで好きなタイミングでその関数を実行できるものです。
const consoleThunk = (text: string) => () => {
console.log(text)
}
ではreduxにおけるthunkとはなんでしょう? そう、actionです。redux-thankは副作用をactionで管理します。 actionのpayloadは基本的に値が入ってますが、redux-sagaはpayloadを関数にしちゃおう、という話です。
// action
const incrementAsync = () => (dispatch) => {
setTimeout(() => {
dispatch(increment())
}, 1000)
}
上記のように、action自体を関数にして非同期処理をその中で行えばaction発行後、どういう依存関係のあるactionが発行されるかみやすいよね!っていうのがthunkのアプローチですね。 thunkのアプローチはシンプルなので、万人受けしやすいんですが、action自体が副作用を持ってしまうために非同期処理が複数になったり複雑化するとカオス化しやすいので、注意が必要です。
まぁそもそもフロント側でネットワークを挟んで複雑なことをすること自体がアンチパターン感はありますが、現実はそううまくはいかないものですすよね…。 ということでそういった複雑化にも耐えうるライブラリとしておすすめなのが、redux-sagaです。
redux-saga
redux-sagaは副作用を並列で走らせたり、直列で走らせたりするのを独自のtaskという概念で表現しています。 taskはactionを待ったり(take)、副作用を実行したり(call)、他のactionを発行したり(put)、はたまたtask同士の排他制御をしたり(takeLatest,join)できます。 この独自のアプローチは敷居が高くなり敬遠される原因になりがちではあるんですが、このアプローチはReduxの世界にプロセス制御のような世界観を与え、結果的に副作用同士の関係性を宣言的に表すことを可能にします。
今回はこのsagaの使い方を深掘って行こうと思います。
redux-sagaの導入
taskの作成
redux-sagaは先述の通り、taskという概念に副作用を閉じ込めるので、taskの作成からやってみましょう。 taskは実態としては実はただのジェネレーターです。 ただし、ジェネレーターの中で行う副作用をうまくあつかうためにsagaが存在します。 ここでは例として、ユーザーがログインしてなかったらfetchを行うtaskをみてみましょう。
// type
type State = {
user: {
isLogin: boolean
userId: string
password: string
}
}
// selector
const userSelector = (state: State) => state.user
// task
import { select, call, put } from '@redux-saga/core/effects'
function* tryLogin() {
const user: ReturnType<typeof userSelector> = yield select(userSelector)
if (user.isLogin) {
yield put(alreadyLoginAction())
} else {
try {
const payload = yield call(fetchUser, user.userId, user.password)
yield put(loginAction(payload))
} catch (e) {
alert('ログイン情報の取得に失敗しました。')
}
}
}
selectorは馴染みがあまりない方のために説明すると、stateから関心のあるところだけを引き抜く関数をよくselectorと呼んでいます。
そしてtaskとなるジェネレーターの中では1行目から、sagaのcore effectのselectを使用していますね。
これらのeffectを利用している箇所を見るとわかるのですが、全てに対しyield
がかかってます。
この辺がsagaのちょっと面白いところで、副作用などの呼び出しであるeffectを全てyield
することで同期的にプログラムを書いてるような錯覚を覚えさせます。
(まぁこれはsagaの特性というか、ジェネレーターではあるんですが、、、)
それでは上記で使用したeffectについてみていきましょう。
selectは現在のstateから情報を引き抜くための関数で、selectorを渡すとstateから情報を引き出してくれます。現在のStateを元にfetchしたりするのに便利です。 次に出てくるeffectはputですね。 putは引数に渡したactionをdispatchしてくれます。 redux-thunkだとdispatchを受け取ってやってたところの代替ですね。 そして最後にでてくるのがcall、Promiseを返す関数の呼び出しです。 ここが面白いところで、「普通に関数呼び出せばいいじゃん」と思った方も多いのではないでしょうか。 実はここ、普通にyieldして関数を呼び出せば全然動作としては問題なく動作します。 ただわざわざcallという関数を通して呼び出しているのは、テストのしやすさを考慮してのことです。
というのも、Jestとかで非同期処理を含むテストを書いたことのある方ならわかると思いますが、非同期処理を呼び出す方の関数っていちいちMockしないといけないんですよね。 これが増えてくるとMock地獄になって地味に面倒になってきます。 この辺は後述で詳細書こうかと思います。
taskの起動とmiddlewareの登録
taskを作成したら、今度はタスクの開始を行わなければいけません。 taskはただのジェネレーターなので、呼び出さなければただ定義しただけになってしまいます。 ではこのtaskはどうやって開始すれば良いでしょう?
taskの開始はfork
というeffectによって開始できます。
今回の例だと、ログインを試みる部分の副作用をまとめたのでtryLogin
というactionが発行されたら毎回上記taskを開始する、としたいところですね。
これをforkとあるactionを待機するtake
で記述してみると以下のような感じになります。
import { select, call, put } from '@redux-saga/core/effects'
function* tryLogin() {
while (true) {
yield take(TRY_LOGIN) // action type
const user: ReturnType<typeof userSelector> = yield select(userSelector)
if (user.isLogin) {
yield put(alreadyLoginAction())
} else {
try {
const payload = yield call(fetchUser, user.userId, user.password)
yield put(loginAction(payload))
} catch (e) {
alert('ログイン情報の取得に失敗しました。')
}
}
}
}
export function* rootSaga() {
yield fork(tryLogin)
}
このrootSagaもまたtaskで、最終的にはstoreの作成時に
const sagaMiddleware = createSagaMiddleware()
const store = createStore(
combineReducers({
user,
}),
applyMiddleware(sagaMiddleware)
)
sagaMiddleware.run(rootSaga)
というように、rootSagaをrunに渡すことで最初のタスクが起動します。
ただこれだと結構なケースが無限に起動できるようにwhileで括らなくてはならなくなりますよね。
sagaではこのような毎回同じ起動をするような場合のeffectとして、takeEvery
があります。
下記のコードは先述のrootSagaと全く同じ動きをします。
function* tryLogin() {
const user: ReturnType<typeof userSelector> = yield select(userSelector)
if (user.isLogin) {
yield put(alreadyLoginAction())
} else {
try {
const payload = yield call(fetchUser, user.userId, user.password)
yield put(loginAction(payload))
} catch (e) {
alert('ログイン情報の取得に失敗しました。')
}
}
}
export function* rootSaga() {
yield takeEvery(TRY_LOGIN, tryLogin)
}
他にも似たようなeffectでtakeLatest
やtakeLeading
などもあり、先勝ちで処理をしたい際など自前で排他制御を実装せずに実現することもできます。
この辺はthunkだとやりずらそうなところですね。
redux-sagaのテスト
さて、実際にsagaを実装してみたんですが、割と独自の世界観で成り立っているのは伝わりましたかね? 「癖強いなー」という印象を持ったようであれば、ぼくも同感ですw ただsagaのすごいところは、この癖の強めなコードのテストがめちゃくちゃ書きやすいことです。 ぼくがsagaのいちばんん好きなところと言っても過言ではないです。
sagaのテストにはredux-saga-test-plan
というのを使用するととても楽です。
import { expectSaga } from 'redux-saga-test-plan'
import { call, select } from 'redux-saga-test-plan/matchers'
import { rootSaga } from './sagas'
import { userSelector } from './selectors'
test(`[rootSaga]`, () => {
const payload = { sessionId: 'sessionId' }
return expectSaga(rootSaga)
.provide([
[select(userSelector), {
isLogin: false,
userId: 'userId',
password: 'password',
}],
[call(fetchUser, 'userId', 'password'), payload]
])
.dispatch(tryLogin())
.take(TRY_LOGIN)
.call(fetchUser, 'userId', 'password')
.put(loginAction(payload))
.silentRun()
})
ざっくりですが説明すると、expectSagaに渡すと全てのeffectを擬似的にMockできるというか、呼ばれた順にどんな値で呼ばれたかなどをテストしていけます。 ここでMockしたい関数などが出てきたら、最初にprovideメソッドで返却する値を指定することができます。 これにより、stateがどんな状態なのか、関数がどんな値を返すのかなどをjestのMockなどを1個も使わずに書くことができます。
上記の例だとtryLoginしてから実際にloginするまでのシナリオが描かれていますね。 このテストのしやすさも、sagaの強みの一つです。
まとめ
sagaにはraceやdebounce、joinなどユニークな関数がまだまだたくさんあり、まるでJS以外のスケジューリング可能な言語を書いているような感覚にさせてくれる面白いライブラリです。 確かに導入ハードルはthunkなどより高いかもしれませんが、thunkより記述量や抽象実装はしやすいので、thunkで疲れた人にはぜひおすすめです!