redux-sagaでバックエンドと繋ぐためのプラクティス


React + Reduxで非同期処理を行えるライブラリとしてはredux-sagaが有名ですが、実際にバックエンドとReduxを接続する場合にどうするのがベストなのか迷ったので、一つの解を載せておきます。
これがベストプラクティスという訳ではないと思いますが、参考になれば。

前提

  • フロントエンドでReact + Reduxを使用してアプリを作っている
  • Reduxのstateをバックエンドと連携して永続化したい

即時処理の場合

画面を読み込んだタイミングでデータを取得したり、ボタンを押したタイミングでデータを更新したりといったように、動作の結果を即時に反映したい場合は、以下のような流れになると思います。

  1. イベント実行のタイミングでActionを発行
  2. sagaでActionを受け取る
  3. APIにリクエストしてデータを更新
  4. sagaから、更新したデータでActionを発行
  5. ReduxでActionを受け取り、stateを更新

また、データ更新中は画面表示を別にしたい(ローディング中であることを表示したい等)場合は、データ更新中であることのフラグをRedux上に置いておいて、redux-saga(以下sagaと表記します)でAPIリクエスト中かどうかに応じてフラグを更新するのがよいと思います。
これらの動作の流れをシーケンス図にすると、以下のようになります。

ここで、Action BrokerはdispatchされたActionをReduxやsagaに届けるための仮想的な存在と考えてください(図を描きやすくするために導入しています)。
Reactコンポーネントの責務は、ボタンクリックイベント等でUSER_API_REQUESTEDActionを発行するだけです。このActionをReduxとsagaはそれぞれ受信し、以下の処理を行います。

  • Redux:APIリクエスト中フラグを立てる
  • saga:APIリクエストを行う

ちなみに、sagaに直接axios等でリクエストを書くのではなく、API呼び出しのラッパー関数を別で用意してそちらに処理を委譲する方がよいです(API呼び出し関数をReduxやsagaに依存させないようにできるので、将来的にリファクタリングをしなければならない際も扱いやすいです)。

sagaはバックエンドからデータを取得したら、データ取得成功の旨のUSER_API_SUCCEEDEDActionを発行します。ReduxはこのActionを受信したらAPIリクエスト中フラグをfalseにして、stateを更新します。
もしデータ取得が失敗したらUSER_API_FAILEDActionを発行して、Reduxにエラーメッセージを伝えるのがよいです。

sagaでActionを受信するのにはtakeEverytakeLatestが使用できます。takeEveryの場合はActionを受信する度に処理を実行、takeLatestの場合はActionを受信した際に処理中のリクエストがあったらキャンセルして最新の処理のみ実行します。
redux-sagaのGitHubに使い方が載っているので参考にしてみてください。

遅延処理(データの自動保存等)の場合

最近のMicrosoft Office製品やGoogle Form等では、保存ボタンを押さなくても自動でデータを保存してくれます。
また、検索ボックスに文字を入れると、手を止めたタイミングで自動的に検索結果を表示してくれるインクリメンタルサーチ機能も最近はよく見ます。

これらの機能を実装する際は、redux-sagaのthrottledebounceが有効です。
Lodash等でも同様な関数があるため見たこともある人もいるかもしれませんが、それぞれ以下のような挙動をします。

  • throttle:Actionを間引きして、一定時間に1回しか処理を実行しないようにする。自動でのデータ保存等に有効。
  • debounce:最後にActionを受信してから一定時間Actionを受信しなかった場合処理を実行する。インクリメンタルサーチ等に有効。

これらを利用して、以下のような流れで処理を実装できます。

  1. Reactコンポーネントはデータが変わる度にActionを発行(例:onChangeイベント)
  2. ReduxでActionを受け取り、データが変わる度にstateを更新
  3. sagaでActionを受け取り、throttledebounceを起動
  4. state更新に遅延してsagaの処理が実行
  5. sagaはReduxから現在のstateを取得
  6. APIにリクエストしてデータを更新
  7. sagaから、更新したデータでActionを発行

シーケンス図にすると以下のようになります。

先ほどより少し複雑になりました。
大きな変化としては、先ほどは「先にsagaを実行してからstateが更新される」のに対して、今回は「先にstateを更新してからsagaが実行される」ようになりました。
stateはonChange等が呼ばれるたびにどんどん更新されるので、stateが更新する度にAPIリクエストを送るわけにはいきません。なので、throttledebounceを利用してAPIへのリクエストを間引きしています。
また、先ほどと同じくReactコンポーネントの責務はActionを発行するだけなので、疎結合が実現できています。

また、今回はsagaの実行時にstateから現在の状態を取得してくる必要がありますが、これはselectを使用すれば実装できます(参考:redux-saga での stateの取り方 備忘録)。以下に実装例を示します。

mySaga.js
import { call, put, debounce, select } from 'redux-saga/effects'
import Api from './path/to/my/api/wrapper'

// バックエンドへリクエストを行う
function* updateUser(action) {
   try {
      // 現在のstateを取得する
      const user = yield select(state => state.users[action.payload.userId])
      // APIリクエスト開始する旨のActionを発行する
      yield put({type: 'USER_UPDATE_REQUESTED', user: user})
      // APIリクエストを行う
      yield call(Api.updateUser, user)
      // 更新成功
      yield put({type: 'USER_UPDATE_SUCCEEDED', user: user})
   } catch (e) {
      // 更新失敗
      yield put({type: 'USER_UPDATE_FAILED', message: e.message})
   }
}

/*
  USER_NAME_CHANGED, USER_AGE_CHANGED Action は値が変わる度にReact Componentから発行される
  手を止めるとActionが発行されなくなり、updateUserが実行される
*/
function* mySaga() {
  yield debounce(500, ['USER_NAME_CHANGED', 'USER_AGE_CHANGED'], updateUser)
}

export default mySaga

まとめ

redux-sagaを利用してバックエンドとReduxを繋ぐためのプラクティスを紹介しました。
即時処理と遅延処理どちらも割と綺麗に記述できるのはredux-sagaの良いところですね。
ただ、データの流れが複雑になってきて、かつReduxはデータの流れを追いづらいので、どのように処理が実行されるかしっかりと認識しておく必要はありそうです。