reactjs
redux

Redux Middleware in Depth

More than 1 year has passed since last update.

Middleware使ってますか? 以前 Reduxのmiddlewareを積極的に使っていく という記事でMiddlewareの使いどころについて具体的な利用例を挙げました。本稿ではMiddlewareを書く上で役に立つ、もうちょっと掘り下げたTipsを紹介していきます。

ActionがMiddlewareを通り抜ける順番

middlewares.js
export const m1 = store => next => action => {
  console.log('m1', action.type);
  return next(action);
};

export const m2 = store => next => action => {
  console.log('m2', action.type);
  return next(action);
};

export const m3 = store => next => action => {
  console.log('m3', action.type);
  return next(action);
};
store.js
import { createStore, applyMiddleware } from 'redux';
import reducer from './reducers';
import { m1, m2, m3 } from './middlewares';

export default createStore(
  reducer,
  applyMiddleware(m1, m2, m3)
);

抜粋ですが上記の構成で何か適当なAction、ここでは HOGE Actionをdispatchすると以下の出力を得ます。

m1 HOGE
m2 HOGE
m3 HOGE

素直に applyMiddleware で指定した順番にActionが通り抜けています。
強いて言えばMiddlewareによってはどこに置くかが大事なものがあるのでそこは注意すべきです。

例えば redux-loggerREADME.md にも書いてありますが 原則的に一番最後 に置きます。

store.js
import logger from 'redux-logger';

export default createStore(
  reducer,
  applyMiddleware(your, awesome, middleware, logger())
);

redux-logger 自体はただのロガーなのでどこに置いても動作しますが、最後に置くのはログ出力されるActionと実際にStoreに送り込まれる(その結果としてStateを変化させる)Actionに食い違いが出て混乱するからです。以下のようにわざと途中に redux-logger を置いたMiddleware構成を想定してみます。

applyMiddleware(m1, logger(), m2, m3)

ここで m2FOO Actionを潰して、m3 が別の BAR Actionを生成する場合、FOO Actionと HELLO Actionを送り込むと redux-logger のログ出力には FOO Actionと HELLO Actionが表示されますが、実際にStoreに到達するActionは HELLO Actionと BAR Actionになります。

[M1] -> [Logger] -> [M2] -> [M3] -> [Store]

[FOO] --------------> X
[HELLO] ------------------------------->
                            [BAR] ----->

同様にMiddlewareを置く位置が大事なものとして redux-thunk があります。redux-thunk はActionオブジェクトの代わりに関数オブジェクトをdispatchすると、Storeの dispatch 関数と状態取得のための getState 関数を渡されるのであとは煮るなり焼くなりしてくれ、という良く言えば 強力な、悪く言えば GOTO文のような Middlewareです。こちらは redux-logger と逆で 一番最初に置く 必要があります。

store.js
import thunk from 'redux-thunk';

export default createStore(
  reducer,
  applyMiddleware(thunk, your, awesome, middleware)
);

理由は単純で、多くのMiddlewareはActionとして関数オブジェクトがやって来ることは想定していないので、すべてのMiddlewareに先んじて関数を実行してActionオブジェクトを得てから後続のMiddlewareに流してあげる必要があるためです。

        [Thunk] -> [M1] -> [M2] -> [Store]

[Func] --> X ----> [A1] -------------->

redux-api-middleware とかも同じで最初に置く必要があります。

では redux-thunkredux-api-middleware はどっちを先にするべきでしょうか?

答えは 基本的にはどっちでもいい です。redux-thunkredux-api-middlwareCALL_API Actionを無視してくれますし、redux-api-middlwareredux-thunk で処理される関数オブエジェクトを無視するからです。

redux-api-middlewaretypes に関数オブジェクトを渡して redux-thunk に実行させるような 変態的 特殊なことをやりたくても、現状では redux-api-middleware のバリデーションによってはじかれるのでできません。残念。もし将来 types に関数オブジェクトを渡せるようになったら redux-api-middleware を先に置く 必要があります。逆にすると関数オブジェクトは実行されずに後続のMiddlewareまたはStoreでエラーが発生します。これは redux-api-middleware がAPI呼び出しの状況や結果を伝えるActionを投げるときに store.dispatch を使わずに next を使っている ことに起因します。次のセクションではこの違いが何を意味しているのか考えていきます。

MiddlewareからActionを投げる方法は2つある

Middlewareはだいたい以下のような形式で書きます。

hoge.js
export const hoge = store => next => action => {
  // ...
};

Middlewareによっては一番外側の関数をarrow functionではなく普通に関数として定義してたり、引数を受け取るとき (store) の代わりに ({ getState }) と書いてStoreに対しては状態の取得しかしないよ!と明示するものもありますね。

さて、Middlewareでは新しくActionオブジェクトを生成してStoreにdispatchすることができますが、その方法は2つあることはご存知でしょうか? 例えば FOO Actionが来たら BAR Actionを新しく作って投げるMiddlewareを考えてみます。

m1.js
export const m1 = store => next => action => {
  if (action.type === 'FOO') {
    next({ type: 'BAR' });
  }
  return next(action);
};

特別なことはありません。それではもう1つの方法を以下に示します。

m2.js
export const m2 = store => next => action => {
  if (action.type === 'FOO') {
    store.dispatch({ type: 'BAR' });
  }
  return next(action);
};

このようにStoreの dispatch 関数を直接呼び出すこともできます。言われてみるとなんてことはないんですけどね・・・。

では nextstore.dispatch の違いは異なる結果を生み出すのでしょうか?

Middlewareが1つだけのときに限って NO ですが、 基本的にはYES なので注意が必要です。具体的にどういう違いがあるのか詳しく見ていきます。次のコードは冒頭の3つのMiddlewareのサンプルをベースに、真ん中の m2 の代わりに next を使う m2astore.dispatch を使う m2b に書き換えました。動作としてはどちらも FOO Actionが来たら BAR Actionを生成して投げます。

middlewares.js
export const m1 = store => next => action => {
  console.log('m1', action.type);
  return next(action);
};

export const m2a = store => next => action => {
  console.log('m2a', action.type);
  if (action.type === 'FOO') {
    next({ type: 'BAR' });
  }
  return next(action);
};

export const m2b = store => next => action => {
  console.log('m2b', action.type);
  if (action.type === 'FOO') {
    store.dispatch({ type: 'BAR' });
  }
  return next(action);
};

export const m3 = store => next => action => {
  console.log('m3', action.type);
  return next(action);
};

上記Middlewareを使ってまずは m2a を使った場合、applyMiddleware(m1, m2a, m3) の出力を見てみます。

m1 FOO
m2 FOO
m3 BAR
m3 FOO

次に m2b を使った場合、applyMiddleware(m1, m2b, m3) の出力です。

m1 FOO
m2 FOO
m1 BAR
m2 BAR
m3 BAR
m3 FOO

これで違いは明らかですね。 nextは後続のMiddlewareのみ通過するのに対し、store.dispatchはMiddlewareチェーンの最初から実行されます。 どちらを使うべきかは何をしたいのかに依存します。例えば redux-thunk では store.dispatch を使うことを強制されますが、もし next を使えてしまうと redux-thunk をMiddlewareチェーンのどこに置いたかによって適用されるMiddlewareが変わってしまい混乱の元になります。さらに store.dispatch を使うことによって関数オブジェクトがさらに関数オブジェクトをdispatchするような状況でも再度 redux-thunk が実行してくれます。このように redux-thunk では store.dispatch を使わなければならない強い理由があります。

一方で新しいActionを投げるのに絶対に next を使わなければならない状況というのはそんなに多くありません。 だからといって安易にstore.dispatchを使うのは危険です。注意して作らないとMiddlewareがActionをdispatchすることで再帰的にMiddleware自身が呼び出されてしまう 無限ループ に陥ります。

例えば上記のサンプルでは FOO Actionのときのみ BAR Actionをdispatchしていますが、その条件をはずすと当然無限ループになります。redux-thunk も関数オブジェクトであれば問答無用で実行するので、次のような意地悪なActionを投げると Uncaught RangeError: Maximum call stack size exceeded エラーが発生します。

const recurring = dispatch => dispatch(recurring);
this.props.dispatch(recurring);

MiddlewareからActionを投げるタイミングは2つある

Middleware内からActionを投げるのに2つの方法があることを知りましたが、まだ安心できません。 どのタイミングで投げるのか によって結果が変わってきます。以下のMiddlewareがどのような挙動になるのか想像してみてください。

function isTarget(action) {
  return action.type === 'FOO';
}

export const m1 = store => next => action => {
  if (isTarget(action)) {
    store.dispatch({ type: 'BAR1' });
  }
  const ret = next(action);
  if (isTarget(action)) {
    store.dispatch({ type: 'BAR2' });
  }
  return ret;
};

オリジナルのActionを next で送り出す 前に 新しいActionをdispatchするのか、 後に dispatchするのかで変わってきます。これをうまく利用しているのが redux-logger です。redux-logger はStoreにdispatchされたActionを表示するだけでなく、dispatch前後のStateも出力してくれます。Storeの getState 関数を next の前後でそれぞれ呼び出すことで実現しています。

まとめ

3行でまとめると、

  • applyMiddleware で指定するMiddlewareの位置に気をつける
  • Actionを投げる方法は nextstore.dispatch の2つがある
  • オリジナルのActionを next で呼び出す前後でStateが変化する

おまけ

複雑なMiddlewareチェーンを組んでいるとどこでActionが生成されたのか、改変されたのか、潰されたのかわからなくなることがあります(怖い)。そんなとき手っ取り早くprintデバッグする方法は redux-logger をひたすらMiddlewareの間に差し込みます。

store.js
import logger from 'redux-logger';

export default createStore(
  reducer,
  applyMiddleware(logger(), your, logger(), awesome, logger(), middleware, logger())
);

うわぁひどい・・・。けど、 惨状 状況は一応つかめます。ただこれもMiddlewareの数が多くなると挿入するのが面倒になるので、お手軽にやるには redux-middleware-logger が便利です。使い方は簡単でMiddlewareのリストを渡します。

store.js
import withLogger from 'redux-middleware-logger';

export default createStore(
  reducer,
  applyMiddleware(...withLogger(your, awesome, middleware))
);

Screen Shot 0028-03-21 at 02.51.26.png

あまりインストールされていませんね。みなさんそんなに困っていないようで安心しました。