30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

Redux Middleware の仕組みと作り方

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を使ってactionreducerに渡し、その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> }
};

storecreateStoreで作ったstoreだが、それそのものではなく、持っているメソッドはgetStatedispatchのみ。
nextは、Middleware を適用する前の、本来のdispatch。Middleware を複数使う時は必ずしもそうはならないのだが、 Middleware が一つのときは必ずdispatchになる。
actionは、myStore.dispatch(action)dispatchに渡されたaction

nextactionが重要で、next(action)とすると、本来のdispatch(action)を呼び出すことになる。
つまり、actionreducerに渡す。

これにより、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 の処理をチェーンのように連続でつないでいくことが可能になる。

これも先に例を示す。
addOnedoubleという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 に渡されたstoredispatchメソッドを持っているが、これは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')
);

参考資料