BizteX のエンジニアによる技術ブログ

Reduxで非同期処理を扱う

フロントエンドエンジニアの三宅です。

BizteX cobitのフロントエンドは、React + Reduxの組み合わせを採用しています。
ReduxはFluxの思想に基づき、アプリケーションの状態を管理するためのライブラリです。
アプリケーションの状態を変更するトリガとなる action と、現在の状態とアクションから新たな状態を定義する reducer 、それらに基づいて状態を保持する store を構築するというのが、Reduxの基本的な考え方となります。
Reduxの考え方に従うとアプリケーションの状態の変更を一箇所で管理することになるので、テストしやすくバグの少ないコードになります。
そんなReduxですが、 actionreducer による状態の変更は同期処理を前提としており、例えばAPIの呼び出しやファイルの読み込みなど、非同期処理を扱う場合にはなんらかの工夫が必要となります。

もっとも単純な方法は、 component の内部で非同期処理を実行するようにして、開始時や完了時に action を呼び出す方法です。
ただ、この方法だとアプリケーションの規模が大きくなるにつれ、 component 内のイベントハンドラが複雑になったり冗長になりやすい傾向があります。

そのため、規模や複雑度の大きなアプリケーションを開発する場合は、Reduxで非同期処理を扱うためのなんらかのミドルウェアを利用することとなります。

非同期処理ミドルウェアの比較

brillout/awesome-redux Side Effectsに挙げられているミドルウェアGoogle Trendsを比較してみました。
redux-thunkredux-sagaがメジャーで、それらに次いでredux-observableという結果となっており、個人的な感覚とも合致しています。

今回は、この3つのミドルウェアで同じ簡単なアプリケーションを作って、その考え方や違いを比較していきます。
アプリケーションは task1task2 2つのそれぞれ失敗する可能性のある非同期処理を持っています。そして、 task2task1 の結果に依存します。
スタートボタンをクリックすると、 task1task2 の順番に実行し、その結果をリスト化します。
それに加えて、 task1task2 の処理の最新の結果を表示します。

AKIRA-MIYAKE/redux-async-studyに、3つのミドルウェアそれぞれのアプリケーションが含まれています。
componentstorereducer はほぼ同じ実装として、非同期処理のハンドリングの箇所のみをそれぞれのミドルウェアに合わせています。

redux-thunk

cobitで非同期処理のハンドリングに利用しているミドルウェアです。
Reduxを開発しているコミュニティが開発しており、他の2つと比べてシンプルに利用することができます。

src/actions/index.js のコードです。

import { createActions } from 'redux-actions';

import { task1, task2 } from '../async-tasks';

const actions = createActions({
  TASK_1_REQUEST: () => ({}),
  TASK_1_RECEIVE: result => ({ result }),
  TASK_2_REQUEST: () => ({}),
  TASK_2_RECEIVE: result => ({ result })
});

export const execTasks = () => async dispatch => {
  dispatch(actions.task1Request());

  let task1Result;
  try {
    task1Result = await task1();
    dispatch(actions.task1Receive(task1Result));
  } catch(error) {
    dispatch(actions.task1Receive(error));
  }

  if (!task1Result) {
    return;
  }

  dispatch(actions.task2Request());

  try {
    const result = await task2(task1Result);
    dispatch(actions.task2Receive(result));
  } catch(error) {
    dispatch(actions.task2Receive(error));
  }
}

export default actions;

redux-thunkを利用することで、 action に非同期処理を行う関数を設定することができるようになります。
関数は storedispatch()getState() という現在の状態を取得するための引数をとる関数を返す高階関数として、返却される関数内で非同期処理を実行し、任意のタイミングで他のアクションを呼び出します。
component の中で実行していた非同期処理を action に移動させるような形となり、シンプルな記述のまま冗長なコードを減らすことができます。

ただし、 action はプレーンなオブジェクトであるという redux の原則から外れること、非同期のための action が値を返却しないためテストがしずらくなること、また、例えば task1 が単独で実行された際にも、 task2 をその結果を受けて実行したいという要求に対応しずらい、といったいくつかの問題点があります。

redux-saga

Google Trendsではもっとも検索されているミドルウェアです。また、個人的にはよく好んで利用しています。
redux-saga は非同期処理を独立した saga というプロセスで受け持つ形として、 action はプレーンなオブジェクトとするような形となります。

src/actions/index.js は以下のように、 redux の基本的な形式を保ちます。

import { createActions } from 'redux-actions';

export default createActions({
  TASKS_REQUEST: () => ({}),
  TASK_1_REQUEST: () => ({}),
  TASK_1_RECEIVE: result => ({ result }),
  TASK_2_REQUEST: task1Result => ({ params: task1Result }),
  TASK_2_RECEIVE: result => ({ result })
});

src/sagas/index.js で非同期処理を含むタスクのハンドリングを行います。

import { call, put, takeLatest } from 'redux-saga/effects';

import actions from '../actions';

import { task1, task2 } from '../async-tasks';

function* task1Request() {
  try {
    const result = yield call(task1)
    yield put(actions.task1Receive(result));
  } catch(error) {
    yield put(actions.task1Receive(error));
  }
}

function* task1Receive(action) {
  if (!action.error) {
    yield put(actions.task2Request(action.payload.result));
  }
}

function* task2Request({ payload }) {
  try {
    const result = yield call(task2, payload.params);
    yield put(actions.task2Receive(result));
  } catch(error) {
    yield put(actions.task2Receive(error));
  }
}

export default function* () {
  yield takeLatest(actions.task1Request, task1Request);
  yield takeLatest(actions.task1Receive, task1Receive);
  yield takeLatest(actions.task2Request, task2Request);
}

redux-sagaはES6のGenerator関数を使って記述します。
下部の関数で action に対する紐付けを行っており、 TASK_1_REQUESTTASK_1_RECEIVETASK_2_REQUESTactiondispatch された際に対応する関数が実行されます。
イメージとしては、reduxのアクションを監視して、対応する actiondispatch をフックし、 reducer での処理に加え別プロセスで登録された非同期の関数が実行されるような形となります。
先ほどのredux-thunkとの違いは、 task1task2 の実行がそれぞれ独立して action に関連づけられており、かつ task1 完了のアクションをトリガとして、 task2 を実行するために TASK_1_RECEIVE に非同期処理を紐付けていることです。
これにより、どこか別の場所で task1 が実行された際にも、その結果を用いて task2 が実行されるようになります。これは、例えば非同期処理の完了後にページの遷移を行う、といった場合に応用することもできます。
問題点は、ES6のGenerator関数を利用していること、redux-saga/effectsの dispatch された action の監視や非同期処理を実行するための独自のメソッドの挙動を把握する必要があることなど、学習コストが高くなる点です。

redux-observable

Angularが大きく依存している、RxJSを用いて非同期処理を行うミドルウェアとなります。
考え方はほぼredux-sagaと同様で、 saga の代わりに epic が非同期処理を受け持ちます。

src/epics/index.js のコードで、react-sagaで記述した src/sagas/index.js と同じ動作を行うようになっています。

import { Observable } from 'rxjs';
import { combineEpics } from 'redux-observable';

import actions from '../actions';

import { task1, task2 } from '../async-tasks';

const execTask1 = () => Observable.fromPromise(task1());
const execTask2 = task1Result => Observable.fromPromise(task2(task1Result));

const task1RequestEpic = action$ => action$
  .ofType(actions.task1Request)
  .mergeMap(action => execTask1()
    .map(result => actions.task1Receive(result))
    .catch(error => Observable.of(actions.task1Receive(error)))
  );

const task2RequestEpic = action$ => action$
  .ofType(actions.task2Request)
  .mergeMap(action => execTask2(action.payload.params)
    .map(result => actions.task2Receive(result))
    .catch(error => Observable.of(actions.task2Receive(error)))
  );

const task1ReceiveEpic = action$ => action$
  .ofType(actions.task1Receive)
  .map(action => {
    if (!action.error) {
      return actions.task2Request(action.payload.result);
    } else {
      return { type: '' };
    }
  });

export default combineEpics(
  task1RequestEpic,
  task2RequestEpic,
  task1ReceiveEpic
);

redux-sagaがGenerator関数を用いるのに対して、RxJSの Observable を用います。
柔軟な非同期処理のハンドリングを行うことが可能ですが、やはりRxJSについて理解する必要があるという学習コストが問題となります。
その一方でRxJSはイベントの合成や監視などの機能が優れているため、複雑なイベントストリームの制御をRxJSに依存し、その結果を store に反映するといったパターンではスムーズな連携ができると思います。

結論

redux-thunk、redux-saga、redux-observableの3つの非同期処理を扱うミドルウェアについて、比較とそれぞれの考え方の簡単な紹介を行いました。
redux-thunkは簡単な非同期処理をハンドリングする場合には非常にシンプルに記述することができますが、 store の変更の監視、つまり action の発生を監視するという形を取ることが難しいため、特に依存関係のある非同期処理のハンドリングが難しくなります。
redux-sagaもしくはredux-observableはそのような要求に対して大きな威力を発揮しますが、それぞれ利用のための学習コストが大きくなります。
アプリケーションの性質に加えて開発チームのスキルレベルも選定の際に考慮すべき項目となるでしょう。
この記事が、reduxで非同期処理を扱う際のミドルウェア選定の助けになれば幸いです。