読者対象
- React や Redux でバリバリ書ける
- Redux-Saga を使うことの長所や短所を知っている
関連リンク
Redux-Saga のつらいところ
以下のような,誰かのユーザ情報をランダムに取ってくる擬似APIを考えます。一定時間遅延し,一定確率で失敗します。
import { delay } from 'redux-saga'
export const fetchRandomUser = async () => {
await delay((0.3 + Math.random()) * 1000)
if (Math.random() < 0.2) {
const faces = ['xD', ':D', ':(']
throw new Error(`503 Service Unavailable ${faces[Math.floor(Math.random() * faces.length)]}`)
}
const users = [
{ name: 'John' },
{ name: 'Mary' },
{ name: 'Bob' },
{ name: 'Cathy' },
{ name: 'Mike' },
]
return users[Math.floor(Math.random() * users.length)]
}
reducer, saga, actionType および actionCreator を定義するとき,愚直に
src/reducers.js
src/sagas.js
src/actionTypes.js
src/actionCreators.js
このようなディレクトリ構成にしてしまうと,本来密結合であるはずのコードが分散しすぎて読みにくいという問題が発生します。そのため,カテゴリごとに reducer, saga, actionType および actionCreator などを全部1つにまとめてしまう ducks というデザインパターンが知られています。
以下のようなファイルとなりますが,これでもまだまだつらいところは多いです。
import { call, put, takeEvery } from 'redux-saga/effects'
import { fetchRandomUser } from '../api'
// 本当にこんな冗長な actionType 定義必要なの?
const LOAD = 'randomUser/LOAD'
const LOAD_SUCCESS = 'randomUser/LOAD_SUCCESS'
const LOAD_FAILURE = 'randomUser/LOAD_FAILURE'
const CLEAR = 'randomUser/CLEAR'
// いい感じに SCREAMING_SNAKE_CASE から camelCase に変換して actionCreator 作ってくれないの?
// (redux-actions を使えばできますが,それだけでは不十分)
export const load = () => ({ type: LOAD })
const loadSuccess = data => ({ type: LOAD_SUCCESS, payload: data })
const loadFailure = error => ({ type: LOAD_FAILURE, payload: error, error: true })
export const clear = () => ({ type: CLEAR })
const initialState = {
users: [],
errors: [],
pendingCounts: 0,
}
// switch 文ダサくね?
export default (state = initialState, { type, payload }) => {
switch (type) {
case LOAD:
return {
...state,
pendingCounts: state.pendingCounts + 1,
}
case LOAD_SUCCESS:
return {
...state,
users: [ ...state.users, payload.name ],
pendingCounts: state.pendingCounts - 1,
}
case LOAD_FAILURE:
return {
...state,
errors: [ ...state.errors, payload.message ],
pendingCounts: state.pendingCounts - 1,
}
case CLEAR:
return {
...state,
users: [],
errors: [],
}
// default とか書きたくないんだけど?
default:
return state
}
}
export const sagas = {
// 9割ぐらい takeEvery で事足りるのに何で毎回書かせるの?
load: takeEvery(LOAD, function* (action) {
try {
// 成功時とかどうせ最後に put するんだし return だけで済ませてくれない?
yield put(loadSuccess(yield call(fetchRandomUser)))
} catch (e) {
// try ~ catch 書くのだるくね?例外飛んだら勝手にいい感じにしてくれないの?
yield put(loadFailure(e))
}
}),
}
// なんか reducer の LOAD の部分と saga の LOAD の部分離れてて読みづらくね?
…と,コメントで書いたような不満はみなさんよくお持ちだと思います。これを解決するために,知り合い(諸事情により匿名)と @mpyw で協力して,少しでも冗長さを軽減するライブラリを開発しました。
moducks/moducks: Ducks (Redux Reducer Bundles) + Redux-Saga = Moducks
これを使うと,上記のコードは以下のように書けます。
import { createModule } from 'moducks'
import { call } from 'redux-saga/effects'
import { fetchRandomUser } from '../api'
const initialState = {
users: [],
errors: [],
pendingCounts: 0,
}
// randomUser(reducer), sagas, actionCreator各種を, createModule関数の返り値を展開することによって一気に宣言する
const {
randomUser, sagas,
load, loadSuccess, loadFailure, clear,
} = createModule('randomUser', {
// キーに actionType を定義する
// (JavaScriptのオブジェクトリテラルのキーに文字列を直接書けるという長所を生かしている)
LOAD: {
// switch 文を使わずに, LOAD アクションに反応する reducer の一部分のみを定義する
reducer: state => ({
...state,
pendingCounts: state.pendingCounts + 1,
}),
// saga として定義したジェネレータ関数は自動的に takeEvery でラップされる
// return した action は自動で yield put() される
saga: function* (action) {
return loadSuccess(yield call(fetchRandomUser)) // loadSuccess は遅延評価されるので ReferenceError にならない
},
// onError を定義することによって,try ~ catch を省略することができる
// return した action は自動で yield put() される
onError: (e, action) => loadFailure(e), // loadFailure は遅延評価されるので ReferenceError にならない
},
// reducer のみを定義する場合はオブジェクトリテラルを使わずにそのまま渡してよい
LOAD_SUCCESS: (state, { payload: user }) => ({
...state,
users: [ ...state.users, user.name ],
pendingCounts: state.pendingCounts - 1,
}),
LOAD_FAILURE: (state, { payload: e }) => ({
...state,
errors: [ ...state.errors, e.message ],
pendingCounts: state.pendingCounts - 1,
}),
CLEAR: state => ({
...state,
users: [],
errors: [],
}),
}, initialState)
export default randomUser
export { sagas, load, clear }
いかがでしょうか。Redux-Saga の特長であるテスタビリティには影響しない範囲で冗長さの軽減に成功していると考えています。
高度な使い方
(GitHubのほうにだいたい書きましたが一応こちらにも残しておきます)
export const { }
を使ってエクスポートの記述量を減らす
randomUser
や loadSuccess
や loadFailure
など,不必要なものが若干混じることを許容するならば,export const { }
のシンタックスを使うと何度も書かずに済みます。
export const {
randomUser, sagas,
load, loadSuccess, loadFailure, clear,
} = createModule('randomUser', { /* ... */ })
export default randomUser
派生ducksパターンとして考える場合,{ randomUser }
を参照することにして逆に export default randomUser
を省略するのもアリかもしれません。
actionType もエクスポートして外部で使用する
外部の saga や channel でアクションを監視したい場合には,actionType も一緒にエクスポートすることで解決できます。
const {
randomUser, sagas,
load, loadSuccess, loadFailure, clear,
LOAD, LOAD_SUCCESS, LOAD_FAILURE,
} = createModule('randomUser', { /* ... */ })
export default randomUser
export { sagas, load, clear, LOAD, LOAD_SUCCESS, LOAD_FAILURE }
現状,別の ducks で定義されている actionType に reducer で反応することはできませんが,これはアンチパターンなので対応する必要が無いと考えています。外部の reducer に反応させたいのであれば,その reducer の actionCreator をこちらの saga から実行すべきです。
takeEvery
以外のケースに対応する
takeEvery
以外にも柔軟に対応できるようになっています。ジェネレータ関数そのものの代わりに,以下のプロパティを持つオブジェクトを引数に取って,fork 系作用またはジェネレータ関数を返す thunk を定義することができます。
プロパティ | 説明 |
---|---|
type |
当該箇所で定義される actionType です。const { /* ... */ } の部分で定義しなくてもいいようになっています。 |
takeEvery takeLatest throttle fork spawn
|
fork 系作用を生成する関数について,自動 yield put() 機能や自動 try ~ catch 機能を追加したものです。 |
enhance |
自作の fork 系作用関数を使いたいときには,自動 yield put() 機能や自動 try ~ catch 機能を追加するために,ジェネレータ関数をこれでラップしてください。 |
記述例を示します。
export const { /* ... */ } = {
LOAD: {
/* ... */
saga: ({ type, throttle }) => throttle(1000, type, function* (action) {
return loadSuccess(yield call(fetchRandomUser))
}),
onError: (e) => loadFailure(e),
/* ... */
},
/* ... */
}
export const { /* ... */ } = {
LOAD: {
/* ... */
saga: ({ type, fork }) => function* () {
while (true) {
const action = yield take(type)
yield fork(function* () {
return loadSuccess(yield call(fetchRandomUser))
})
}
},
onError: (e) => loadFailure(e),
/* ... */
},
/* ... */
}