[Redux] リクエストの送信状態に応じてUIを変更する実装の例

## 概要

JavaScriptでHTTPリクエストを送信する際に、リクエスト送信中のローディング表示など、リクエストの送信状況に応じてUIを変更したい場合がある。
React, Redux, redux-saga を使用し始めた際にこれをどのように実装すれば良いか迷ったので、他の初学者の参考となるように最終的に実装した方法をメモしておく。

## 環境

- React: ^15.3.1
- Redux: ^4.4.5
- redux-saga: ^0.11.1

## 実装方針

リクエストの送信は一つのコンポーネントに閉じた操作ではないため、UIを更新するための情報をStoreに格納する。
[The 5 Types Of React Application State](http://jamesknelson.com/5-types-react-application-state/) では、Reactが扱う状態を5種類に分類しており、この中のCommunication stateがリクエストの送信状況を格納する箇所に該当する。
以下のような値を持つオブジェクトをCommunication stateとしてStoreに格納する。
(以降では、このオブジェクトを "Command" と呼ぶ)

- name: API名称
- state: リクエストの送信状況
- data: 送信するデータ(post, put, patchリクエストの場合)
- error: リクエスト失敗時のエラー情報

Container Component がCommandの値をUI切り替え用のpropsに適宜変換することで、UIの切り替えを行う。

## 実装例（概要）

例としてモーダルダイアログ上で必要な情報を入力してリクエストを送信する場合を考える。
Commandのstate値は、以下のような値を取りうるものとする。

- PREPARED: data設定中の状態
- REQUESTED: リクエスト送信中の状態
- SUCCEEDED: リクエスト送信成功時の状態
- FAILED: リクエスト送信失敗時の状態

Commandとモーダルダイアログは以下のような流れで連携する。

1. モーダルダイアログを起動する
  - { state: 'PREPARED', data: <データのデフォルト値> } とし、それに応じてモーダルダイアログを表示する
  - dataの値のコピーを、モーダルダイアログのComponentのstateに格納する
2. リクエストを送信する
  - { state: 'REQUESTED', data: <モーダル上で更新したデータ> } とし、それに応じてモーダルダイアログ上でローディング表示を出す
3. レスポンスを受信する
  - 送信成功: { state: 'SUCCEEDED' } とし、モーダルダイアログ上でリクエストが成功した旨を表示する
  - 送信失敗: { state: 'FAILED', error: <エラー情報> } とし、モーダルダイアログ上でエラー表示を行う
4. モーダルダイアログを閉じる
  - Commandを初期化する

## 実装例（詳細）

Commandを操作するために必要なモジュールの実装例を示す(Componentの実装は省略)。
以下のモジュールを作成する。

- consts/command: Commandに用いられる定数(name, stateの値など)を管理する
- actions/command: Commandに対するactionを定義する
- reducers/command: Commandのreducerを定義する
- sagas/command/index: REQUESTEDに遷移したCommandを受け取り、APIを処理するSagaに受け渡す
- sagas/command/target: APIを処理するSaga

### consts/command

```js
export const COMMAND_STATE = {
  PREPARED: 'PREPARED',
  REQUESTED: 'REQUESTED',
  SUCCEEDED: 'SUCCEEDED',
  FAILED: 'FAILED',
};

export const API_NAME = {
  ADD_TARGET: 'ADD_TARGET',
};
```

### actions/command

```js
export const PREPARE_COMMAND = 'PREPARE_COMMAND';
export const REQUEST_COMMAND = 'REQUEST_COMMAND';
export const SUCCESS_COMMAND = 'SUCCESS_COMMAND';
export const FAIL_COMMAND = 'FAIL_COMMAND';
export const INITIALIZE_COMMAND = 'INITIALIZE_COMMAND';

export const prepareCommand = (name, data) => ({
  type: PREPARE_COMMAND,
  name,
  data,
});

export const requestCommand = (name, data) => ({
  type: REQUEST_COMMAND,
  name,
  data,
});

export const successCommand = (name) => ({
  type: SUCCESS_COMMAND,
  name,
});

export const failCommand = (name, error) => ({
  type: FAIL_COMMAND,
  name,
  error,
});

// success/fail から初期状態に戻す
export const initializeCommand = (name) => ({
  type: INITIALIZE_COMMAND,
  name,
});
```

### reducers/command

```js
import {
  PREPARE_COMMAND,
  REQUEST_COMMAND,
  SUCCESS_COMMAND,
  FAIL_COMMAND,
  INITIALIZE_COMMAND,
} from '../actions/command';

import { COMMAND_STATE } from '../consts/command';

// 全APIの状態をこの中に入れ、API名をキーとして情報を取得できるようにしている。
// (同一のAPIを並列で呼び出す必要がある場合はリクエスト毎に固有のキーを
//  用いるように修正する必要がある)
const command = (state = {}, action) => {
  switch (action.type) {
    case PREPARE_COMMAND:
      return Object.assign({}, state, {
        [action.name]: { state: COMMAND_STATE.PREPARED, data: action.data },
      });
    case REQUEST_COMMAND:
      return Object.assign({}, state, {
        [action.name]: { state: COMMAND_STATE.REQUESTED, data: action.data },
      });
    case SUCCESS_COMMAND:
      return Object.assign({}, state, {
        [action.name]: { state: COMMAND_STATE.SUCCEEDED },
      });
    case FAIL_COMMAND:
      return Object.assign({}, state, {
        [action.name]: { state: COMMAND_STATE.FAILED, error: action.error },
      });
    case INITIALIZE_COMMAND:
      // 厳密には初期状態(API名のキーすらない)とは異なる
      return Object.assign({}, state, {
        [action.name]: null,
      });
    default:
      return state;
  }
};
```

### sagas/command/index

```js
import { takeEvery } from 'redux-saga';
import { fork } from 'redux-saga/effects';

import { REQUEST_COMMAND } from '../../actions/command';
import { API_NAME } from '../../consts/command';
import { addTargetSaga } from '../../sagas/usecase';

const sagaMap = {
  [API_NAME.ADD_TARGET]: addTargetSaga,
};

function* requestCommand(action) {
  // 実際の処理はAPIごとに定義したモジュールに委ねる
  yield sagaMap[action.name](action.name, action.params);
}

export default function* () {
  // すべてのCommandに対するREQUESTをここで取得する
  yield fork(takeEvery, REQUEST_COMMAND, requestCommand);
}
```

### sagas/command/target

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

import { successCommand, failCommand } from '../../actions/command';
import { addTarget } from '../../actions/targets';
import Api from '../../apis/target';

export function* addTargetSaga(name, { target }) {
  const response = yield call(Api.add, target);
  if (response.status >= 200 && response.status < 300) {
    // リクエスト成功時、更新した情報のStoreへの格納とCommandの状態更新を行う
    yield put(addTarget(response.data));
    yield put(successCommand(name));
  } else {
    // リクエスト失敗時、エラー情報を格納する
    // エラー情報はSagaの中でレスポンスに応じたエラー値を設定する方法と
    // レスポンスオブジェクト自体を格納してComponent側で処理する方法のいずれかを取る
    yield put(failCommand(name, 'Adding new target has been failed.'));
  }
}
```

## 備考

- 'PREPARED' の状態はAPIにとっては不要である(例ではモーダルの表示切替に使いたかったのでこの状態を定義している)
- HTTPメソッド(GET/POST/PUT/PATCH/DELETE)でactionをさらに分割しても良い