Redux の機能を拡張し、非同期処理などを書きやすくする仕組みである Redux Middleware について書いていく。
この記事で使っているライブラリのバージョンは以下。
- redux@3.7.2
- react-redux@5.0.7
- react@16.2.0
- react-dom@16.2.0
Redux の基本については、以前書いた。
numb86-tech.hatenablog.com
最近読んだこの記事も分かりやすかった。
Vanilla JSで学ぶRedux - Qiita
reducer はプレーンなオブジェクトのみを受け付ける
Middleware の役割を理解するためにはまず、 Redux の仕組みを理解しないといけない。
dispatch
を使ってaction
をreducer
に渡し、そのreducer
が渡されたaction
の内容に応じて新しくstate
を生成し、それをstore
が記録する。
というのが、Reduxの基本的な流れである。
そしてaction
は、type
プロパティを持ったプレーンなオブジェクトでなければならない。
ここでいう「プレーンなオブジェクト」は、Redux内部のisPlainObject
という関数で判定している。
ここで問題になるのが、promiseオブジェクト。
isPlainObject
でエラーになる。
そのため、APIなどの非同期処理の結果をreducer
に渡そうとすると、エラーになってしまう。
import {createStore} from 'redux'; const ADD_COUNTER = 'ADD_COUNTER'; const addCounter = value => Promise.resolve(value).then(res => ({type: ADD_COUNTER, value: res})); const myReducer = (state = 0, action) => { const {type, value} = action; switch (type) { case ADD_COUNTER: return state + value; default: return state; } }; const myStore = createStore(myReducer); const action = addCounter(1); myStore.dispatch(action); // Error: Actions must be plain objects. Use custom middleware for async actions.
このエラー処理はdispatch
の内部で行っており、reducer
に渡す前にエラーになっている。
そのため例えば、dispatch
を拡張してpromiseオブジェクトをハンドリングしてあげれば、非同期処理の結果をreducer
に渡すことが出来る。
dispatch の拡張
以下は、dispatch
を上書きして、本来のdispatch
の処理の前に非同期処理の結果をハンドリングしている。
こうすることで、reducer
にはプレーンなオブジェクトが渡されるようになる。
const originalDispatch = myStore.dispatch; myStore.dispatch = action => { action.then(res => { console.log(res); // { type: 'ADD_COUNTER', value: 1 } originalDispatch(res); console.log(myStore.getState()); // 1 }); }; const action = addCounter(1); myStore.dispatch(action);
だが既存のdispatch
を上書きしてしまうこの方法は、もちろん望ましくない。
Reduxには、dispatch
を拡張するための方法が予め用意されている。
それが Middleware である。
Middleware を使うことで、dispatch
の機能を拡張してreducer
の前後に任意の処理を追加できるようになる。
applyMiddleware
Middleware を使うには、createStore
の第二引数でapplyMiddleware
を呼び出す。
import {createStore, applyMiddleware} from 'redux'; const myStore = createStore(myReducer, applyMiddleware(myMiddleware));
applyMiddleware
の引数に、適用する Middleware を入れる。
後で改めて説明するが、applyMiddleware
を使用すると、dispatch
が実行された際に、dispatch
ではなく指定した Middleware が呼び出されるようになる。
Middleware は複数指定することが出来るが、まずは理解しやすくするために一つだけ使ってみる。
先程の非同期処理の例を解決するには、次のように書けばよい。
import {createStore, applyMiddleware} from 'redux'; const ADD_COUNTER = 'ADD_COUNTER'; const addCounter = value => Promise.resolve(value).then(res => ({type: ADD_COUNTER, value: res})); const myReducer = (state = 0, action) => { const {type, value} = action; switch (type) { case ADD_COUNTER: return state + value; default: return state; } }; const myMiddleware = store => next => action => { action.then(res => { console.log(res); // { type: 'ADD_COUNTER', value: 1 } next(res); console.log(store.getState()); // 1 }); }; const myStore = createStore(myReducer, applyMiddleware(myMiddleware)); const action = addCounter(1); myStore.dispatch(action);
非同期処理のハンドリングを、myMiddleware
で行っている。
次は、このmyMiddleware
を例にして、Middleware がどのように動いているのかを見ていく。
Middleware の構造と引数について
Middleware でまず目を引くのは、store => next => action =>
という連続したアロー関数。
最初に見た時は戸惑ったが、これは関数を返す関数、という構造が入れ子になっているだけで、複雑なものではない。
例えば先程のmyMiddleware
は、以下のように書くことも出来る。
function myMiddleware(store) { return function(next) { return function(action) { action.then(res => { console.log(res); // { type: 'ADD_COUNTER', value: 1 } next(res); console.log(store.getState()); // 1 }); }; }; }
先述のように、 Middleware はdispatch
したときに呼ばれる。
つまりmyStore.dispatch(action)
としたときに、以下が実行される。
myMiddleware(store)(next)(action)
もちろんこれは単純化したもので、実際にはこんなシンプルな実装ではないだろうが。
次に、Middleware に渡される3つの引数について見ていく。
const myMiddleware = store => next => action => { console.log(store); // { getState: [Function: getState], dispatch: [Function: dispatch] } console.log(next); // [Function: dispatch] console.log(action); // Promise { <pending> } };
store
はcreateStore
で作ったstore
だが、それそのものではなく、持っているメソッドはgetState
とdispatch
のみ。
next
は、Middleware を適用する前の、本来のdispatch
。Middleware を複数使う時は必ずしもそうはならないのだが、 Middleware が一つのときは必ずdispatch
になる。
action
は、myStore.dispatch(action)
でdispatch
に渡されたaction
。
next
とaction
が重要で、next(action)
とすると、本来のdispatch(action)
を呼び出すことになる。
つまり、action
をreducer
に渡す。
これにより、reducer
の前後に任意の処理を追加して、promiseオブジェクトをハンドリングしたり、reducer
によって更新されたあとのstate
を取得したりすることが出来るのである。
const myMiddleware = store => next => action => { action.then(res => { // reducer に渡す前にpromiseオブジェクトを処理 next(res); // ここで、dispatch して reducer に res を渡している console.log(store.getState()); // reducer 実行後の state を取得 }); };
Middleware のなかでnext
を呼ばない、ということも可能ではある。
その場合、dispatch
せず、つまりreducer
を実行せずに、処理が終わる。
const myMiddleware = store => next => action => { console.log('myMiddleware は実行されるが next は呼ばない'); }; const myStore = createStore(myReducer, applyMiddleware(myMiddleware)); myStore.dispatch({type: ADD_COUNTER, value: 1}); console.log(myStore.getState()); // myMiddleware は実行されるが next は呼ばない // 0
Middleware のチェーン
ここまでは、 Middleware が一つだけのパターンを見てきた。
では、applyMiddleware(middleware1, middleware2)
のように複数の Middleware を使うとどうなるのか。
これが Middleware の大きな特徴の一つだと思うが、 Middleware の処理をチェーンのように連続でつないでいくことが可能になる。
これも先に例を示す。
addOne
とdouble
という2つの Middleware を使っている。
それによるstate
の変化も載せておく。
import {createStore, applyMiddleware} from 'redux'; const ADD_COUNTER = 'ADD_COUNTER'; const myReducer = (state = 0, action) => { const {type, value} = action; switch (type) { case ADD_COUNTER: return state + value; default: return state; } }; const addOne = store => next => action => { next(action + 1); }; const double = store => next => action => { next({type: ADD_COUNTER, value: action * 2}); }; const myStore = createStore(myReducer, applyMiddleware(addOne, double)); console.log(myStore.getState()); // 0 myStore.dispatch(3); console.log(myStore.getState()); // 8
applyMiddleware(addOne, double)
としてmyStore.dispatch
を実行するとまず、addOne
が呼ばれる。
action
にはmyStore.dispatch
に渡した値が入っており、これは既に説明した内容と同じ。
変わるのはnext
。Middleware が一つのときは本来のdispatch
が渡されていたが、今回のケースでは、double
を指す。
そのため以下のようになる。
const addOne = store => next => action => { console.log(action); // 3 next(action + 1); // double を実行 };
そして呼び出されたdouble
。
action
には、先程のnext
に渡した値(action + 1
)が入っている。
そしてdouble
におけるnext
は、本来のdispatch
を指す。
const double = store => next => action => { console.log(action); // 4 next({type: ADD_COUNTER, value: action * 2}); // dispatch を実行して reducer に引数を渡す };
const myReducer = (state = 0, action) => { const {type, value} = action; switch (type) { case ADD_COUNTER: console.log(action); // { type: 'ADD_COUNTER', value: 8 } return state + value; default: return state; } };
このようにnext()
を使うことで、applyMiddleware
に渡した順番で Middleware を実行していくことになる。
その際、next()
の引数に値を渡すことで、任意の値をaction
として渡すことが出来る。
そして最後尾の Middleware がnext
を実行したときに、dispatch
が呼び出され、reducer
が実行される。
そのため、最後尾のnext()
の引数は、必ずプレーンなオブジェクトでないといけない。
途中の過程ではnext()
の引数はどんなものでも構わないが、最後尾のnext()
はそのままdispatch()
を呼び出すため、プレーンなオブジェクト以外を渡すとエラーになってしまう。
const double = store => next => action => { console.log(action); // 4 next(action * 2); // ここでエラーになる };
next の複数回使用、store.dispatch の使用
応用と言うほどの内容ではないが、こういう書き方も出来るという例を、復習も兼ねて書いておく。
next
は、一つの Middleware のなかで複数回使うことが出来る。
const twice = store => next => action => { next(action); next(action); }; const myStore = createStore(myReducer, applyMiddleware(twice)); console.log(myStore.getState()); // 0 myStore.dispatch({type: ADD_COUNTER, value: 5}); console.log(myStore.getState()); // 10
Middleware に渡されたstore
はdispatch
メソッドを持っているが、これはmyStore.dispatch
と同じであり、これを呼び出すと再び先頭の Middleware から実行することになる。
const first = store => next => action => { console.log('first', action); next(action); }; const second = store => next => action => { console.log('second', action); if (action < 3) { store.dispatch(action + 1); return; } next({type: ADD_COUNTER, value: action}); }; const myStore = createStore(myReducer, applyMiddleware(first, second)); console.log(myStore.getState()); myStore.dispatch(1); console.log(myStore.getState()); // 0 // first 1 // second 1 // first 2 // second 2 // first 3 // second 3 // 3
Reactとの連携
最後に、ReactアプリでAPIを叩く例を書いてみる。
ReactとReduxの接続については、冒頭に貼った記事などを参照。
import React from 'react'; import ReactDOM from 'react-dom'; import {createStore, applyMiddleware} from 'redux'; import {Provider, connect} from 'react-redux'; const API_CALL = 'API_CALL'; const REFRESH_STATUS = 'REFRESH_STATUS'; function App({status, myActionCreateor}) { return ( <div> <p>通信状態:{status}</p> <button onClick={() => { myActionCreateor(true); }} > 通信(成功する) </button> <button onClick={() => { myActionCreateor(false); }} > 通信(失敗する) </button> </div> ); } const myReducer = (state = {status: '通信前'}, action) => { switch (action.type) { case API_CALL: return Object.assign({}, state, { status: '通信中……', }); case REFRESH_STATUS: return Object.assign({}, state, { status: action.status, }); default: return state; } }; const fetchApi = bool => new Promise((resolve, reject) => { setTimeout(() => { if (bool) { resolve(); } else { reject(); } }, 3000); }); const myActionCreateor = bool => ({type: API_CALL, result: bool}); const apiHandler = store => next => action => { next(action); if (action.type !== API_CALL) return; fetchApi(action.result) .then(() => { next({type: REFRESH_STATUS, status: '通信成功'}); }) .catch(() => { next({type: REFRESH_STATUS, status: '通信失敗'}); }); }; const myStore = createStore(myReducer, applyMiddleware(apiHandler)); const mapStateToProps = state => ({status: state.status}); const mapDispatchToProps = {myActionCreateor}; const ConnectedApp = connect(mapStateToProps, mapDispatchToProps)(App); ReactDOM.render( <Provider store={myStore}> <ConnectedApp /> </Provider>, document.querySelector('#app') );