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') );