flux
reactjs
redux

Reduxのmiddlewareを積極的に使っていく

最初はちょっととっつきにくいけど、責務がはっきり分かれていて比較的コードがごちゃごちゃになりにくい(と思っている)Reduxですが、やはり実戦投入するとどうにも扱いにくい部分が出てきます。

特にそう感じるのは通信系の処理、ユーザーとのインタラクションです。これってつまるところ非同期処理なんですが、処理を依頼する側、つまりActionを投げる側としては「あとのことはまかせた!」と言いたい。Actionを投げる部分ってのはだいたい何かのイベントハンドラだったりしますが、そういう場所に通信やインタラクションの処理をダラダラと書きたくない。

本稿ではそれらの面倒な部分を責務が分離されたメンテナンスのしやすいコードになるようにmiddlewareを活用する例をいくつか紹介します。

その前にmiddlewareについて

Reduxの公式ドキュメントではログ出力を例に取り、アプリケーション本体から分離して段階的に設計を洗練させることで、middlewareによって何が解決されて、内部的にどのような仕組みで動いているのか理解することができます。一読の価値があります。

ActionをDispatchする前にブラウザの確認ダイアログを表示する

問題意識と具体例

入力内容をリセットするボタンのついたウェブフォームを想定して下さい。よくある要件としてリセットボタンを押すと確認ダイアログが表示されて「はい」のときのみフォームがリセットされます。

ひとまずブラウザの確認ダイアログ window.confirm(...) を使用しますが、いずれBootstrapのモーダルダイアログに切り替えることになるかもしれません。そうなるとコード全体に散らばったコード片を書き換えるのはなかなか骨が折れます。それにフォームをリセットするActionを送信するコードの大半にとっては、確認ダイアログを表示すべきかどうか考えたくありません。つまり投げっぱなしにしたいわけです。

Before

Fluxでは何をするにもActionが起点になるので、リセットボタンが押されたら該当するAction、ここでは RESET_FORM がStoreに送られてReducerによってリセット後の新しい状態が生成されます。大まかに書くと以下のようなコードです。

actions.js
import { RESET_FORM } from '...';

export function resetForm() {
  return { type: RESET_FORM };
}
reducers/form.js
import { RESET_FORM } from '...';

const handlers = {
  [RESET_FORM](state, action) {
    return initial;
  }
};

const initial = {
  name: '',
  email: ''
};

export default function formReducer(state = initial, action) {
  const handler = handlers[action.type];
  return handler ? handler(state, action) : state;
}
components/app.js
import { resetForm } from '...';

class App extends React.Component {
  // ...
  // リセットボタンが押されたら呼び出されるハンドラ
  handleReset() {
    if (window.confirm('本当にリセットしてもいいですか?')) {
      // 「はい」のときだけActionを生成してStoreに送信する
      this.props.dispatch(resetForm());
    }
  }
  // ...
}

というわけで確認ダイアログ表示と、結果に応じてActionを送信する部分をmiddlewareとして切り出します。

Middleware

middlewares/confirm.js
export default const confirmMiddleware = store => next => action => {
  if (!action.meta || !action.meta.confirm) {
    return next(action);
  }

  if (window.confirm(action.meta.confirm)) {
    delete action['meta']['confirm'];
    return next(action);
  }
};

confirmMiddleware を組み込みます。

store.js
import { reducer } from '...';

const store = applyMiddleware(
  thunk, confirm
)(createStore)(reducer);

After

先ほどのコードは次のように変わります。

actions.js
import { RESET_FORM } from '...';

export function resetForm() {
  return {
    type: RESET_FORM,
    meta: { confirm: '本当にリセットしてもいいですか?' }
  };
}
components/app.js
import { resetForm } from '...';

class App extends React.Component {
  // ...
  // リセットボタンが押されたら呼び出されるハンドラ
  handleReset() {
    // 「はい」が選択されたら送信される
    this.props.dispatch(resetForm());
  }
  // ...
}

API呼び出しをチェインさせる

問題意識と具体例

APIサーバとの通信処理において、あるデータを取得したらその結果を元にさらに追加のデータを読み込んで、ということがよくあります。・・・本来よくあってはならないことですが、現実そういったAPIを相手にしないといけないことはあります。もしくはログイン処理後に何かデータを読み込む、といった「あるAPIを呼び出したらその次に別のAPIを呼び出したい」状況です。

APIチェインをやろうとしたとき、どういう実装にするのが良いのでしょうか?

いくつか考えられます。API呼び出し完了のActionを処理するReducerで追加のデータをリクエストするActionを送信する方法。rootコンポーネントの componentWillUpdate などで最初のAPI呼び出しによるデータ変更を検出して、それを元に追加のデータをリクエストするActionを送信する方法。後述の redux-api-middleware を使った場合、成功時に呼び出されるコールバックに追加のデータをリクエストするActionを送信するコードを書くこともできます。しかしどれもしっくりきません。

Before

しっくりこないんですが、例示するコードはComponentで変更を検出して追加のデータをリクエストする方法にしてみました。

さて、ReduxにおけるAPI呼び出しにもmiddlewareを使うとコードの見通しが良くなります。redux-api-middleware はまさにそのためのmiddlewareです。プロジェクトごとにAPI呼び出しのActionがバラバラになるのを防ぐために導入しています。

ちょっと解説すると redux-api-middlewareCALL_API という Symbol をキーに持ったActionを検出すると設定にもとづいてリクエストを送信します。types にはAPI呼び出しのライフサイクルにおける各段階に対応したActionを「リクエスト送信」「成功」「失敗」の順に指定します。単純に文字列を渡すとAction Typeとして扱われてActionが送信されます。この場合Actionオブジェクトはtype指定のみになるため、リクエストのレスポンスをActionに含めたい場合は文字列の代わりに、送信して欲しいActionオブジェクトを渡します。payload が関数の場合は呼び出した結果が payload としてセットされます。

actions.js
export function login(payload) {
  return {
    [CALL_API]: {
      endpoint: '/api/users/login',
      method: 'POST',
      body: payload,
      types: [REQUEST_LOGIN, {
        type: SUCCESS_LOGIN,
        payload: (action, state, res) => {
          return res.json().then(payload => payload);
        }
      }, FAILURE_LOGIN],
    }
  };
}

export function fetchPost(id) {
  return {
    [CALL_API]: {
      endpoint: `/api/posts/${id}`,
      method: 'GET',
      types: [REQUEST_FETCH_POST, SUCCESS_FETCH_POST, FAILURE_FETCH_POST]
    }
  };
}
components/app.js
import { login, fetchPost } from '...';

class App extends React.Component {
  // ...
  componentWillUpdate(nextProps) {
    if (this.props.login === false && nextProps.login === true) {
      // ログイン後に記事を読み込む
      nextProps.posts.forEach(id => {
        this.props.dispatch(fetchPost(id));
      });
    }
  }
  // ...
  // ログインボタンのイベントハンドラ
  handleSubmit(username, password) {
    this.props.dispatch(login({ username, password }));
  }
  // ...
}

それではログイン後の記事読み込みの部分をmiddlewareを使って分離します。

Middleware

middlewares/chain.js
import { fetchPost } from '...';

const hooks = {
  [SUCCESS_LOGIN](store, action) {
    // ログイン処理のレスポンスから記事のIDリストを取得
    const ids = getPostIds(action.payload);
    ids.forEach(id => {
      store.dispatch(fetchPost(id));
    });
  }
};

export default const chainMiddleware = store => next => action => {
  const hook = hooks[action.type];
  hook && hook(store, action);
  return next(action);
};

chainMiddleware を組み込みます。

store.js
import { apiMiddleware as api } from 'redux-api-middleware';
import chain from '...';
import { reducer } from '...';

const store = applyMiddleware(
  thunk, api, chain
)(createStore)(reducer);

After

状態変更の検出部分がまるごと消えました。

components/app.js
import { login } from '...';

class App extends React.Component {
  // ...
  // ログインボタンのイベントハンドラ
  handleSubmit(username, password) {
    this.props.dispatch(login({ username, password }));
  }
  // ...
}

おわりに

ちょっと長くなってしまいましたが2つのmiddlewareを通じてどうやって肥大化、複雑化する非同期処理、副作用コードと戦っていくかを示しました。middlewareはStoreに直接アクセスできることから、やろうと思えば何でもできちゃうところが強力でもあり、混乱のもとにもなりえます。幸いなことにmiddlewareを細かく分割しても applyMiddleware で簡単に結合できますし、困ることはありません。またmiddlewareにすると他のプロジェクトでも同じパターンが使えることが多いです。

結論: ComponentとかReducerで変なことするよりmiddlewareでやった方がマシ

さて、素直にその場で書き下せないちょっとした処理をひたすらmiddlewareとして切り出していくとどうなるのか。それって Cycle.js みたいになるんですかね? Cycle.js はまだチュートリアル読んだ程度なのよくわかっていませんが。