Redux Toolkitを利用して非同期処理のステータスをStateで管理する

JavaScript

今回実現すること

AsyncThunkによって生成されたActionCreatorが返すActionTypeに応じてRedux StoreのState(以下State)の値を変更することで、非同期処理の実行状況をReduxで管理できるようにします。

AsyncThunkは非同期処理の実行状況に応じたActionCreatorを生成する関数で、createAsyncThunkを利用して作成します。
createAsyncThunkの詳細解説はRedux ToolkitのcreateAsyncThunkで非同期処理を実装する方法で紹介しています。

AsyncThunkによって生成されたActionCreatorはtypePrefixと呼ばれる文字列を接頭辞に利用し、非同期処理の実行状況に応じて複数のActionTypeを生成します。
たとえばgetTodosというtypePrefixの場合、非同期処理の実行状況に応じて返されるActionTypeは以下の通りです。

  • 実行中(pending)の時: “getTodos/pending”
  • 成功(fulfilled)の時: “getTodos/fulfilled”
  • 失敗(rejected)の時: “getTodos/rejected”

今回実現したいStateのイメージは以下の通りです。

// ActionTypeが『getTodos/pending』の時

{
  entities: {
    todos: {
      allIds: [],
      byId: {}
    }
  },
  requests: {
    getTodos: {
      status: 'REQUEST'
    }
  }
}

// ActionTypeが『getTodos/fulfilled』の時

{
  entities: {
    todos: {
      allIds: [...],
      byId: {...}
    }
  },
  requests: {
    getTodos: {
      status: 'SUCCESS'
    }
  }
}

// ActionTypeが『getTodos/rejected』の時

{
  entities: {
    todos: {
      allIds: [],
      byId: {}
    }
  },
  requests: {
    getTodos: {
      status: 'FAILURE'
    }
  }
}

非同期処理に関するStateの型定義について

今回はActionType(typePrefix)ごとに非同期処理の実行状況をRedux Storeに保存します。
ですので、非同期処理に関するStateの型(以下でいうRequestState)はインデックスシグネチャを利用して以下のように表現できます。

なお、インデックスシグネチャの詳細解説は【TypeScript】インデックスシグネチャ(Index Signatures)の概要と利用方法で紹介しています。

src/types/constants/requestStatusType.ts

export const RequestStatus = {
  Request: "REQUEST",
  Success: "SUCCESS",
  Failure: "FAILURE",
} as const;

src/types/state/requests.ts

import { RequestStatus } from "../constants/requestStatusType";

export type RequestStatusType =
  typeof RequestStatus[keyof typeof RequestStatus];

type Response = {
  status: RequestStatusType;
};

export type RequestState = {
  [typePrefix: string]: Response;
};

非同期処理の実行状況を管理するSliceの実装

上記の型を利用して、非同期処理の実行状況を管理するSliceを実装します。
以下ではSliceの実装について順を追って説明します。

なお、以降のSliceの実装で登場するgetTodosというAsyncThunk関数の定義は以下の通りです。

// createAsyncThunkの第一引数の文字列がtypePrefixになる
export const getTodos = createAsyncThunk<TodoEntity[]>("getTodos", async () => {

  // 外部APIを利用して非同期でToDo一覧を取得する
  const todo = await TodosApiService.getAll();

  return todo;
});

ActionTypeを直接指定する

getTodosというtypePrefixのAsyncThunk関数に関するSliceは以下のようになります。

import { ActionReducerMapBuilder, createSlice } from "@reduxjs/toolkit";
import { RequestStatus } from "../types/constants/requestStatusType";
import { RequestState } from "../types/state/requests";
import { getTodos } from "./todosSlice";

const initialState = {};

export const requestSlice = createSlice({
  name: "request",
  initialState,
  reducers: {},

  // RequestStateはstateの型を意味している
  extraReducers: (builder: ActionReducerMapBuilder<RequestState>) => {

    // getTodos/pending(リクエスト中)の場合
    builder.addCase(getTodos.pending, (state, action) => {
      state["getTodos"] = {
        status: RequestStatus.Request, // "REQUEST"
      };
    });

    // getTodos/fulfilled(リクエスト成功)の場合
    builder.addCase(getTodos.fulfilled, (state, action) => {
      state["getTodos"] = {
        status: RequestStatus.Success, // "SUCCESS"
      };
    });

    // getTodos/rejected(リクエスト失敗)の場合
    builder.addCase(getTodos.rejected, (state, action) => {
      state["getTodos"] = {
        status: RequestStatus.Failure, // "FAILURE"
      };
    });
  },
});

export default requestSlice.reducer;

ActionTypeの前方一致を利用する

AsyncThunk関数が生成するActionType名はすべてtypePrefix名/から始まります。ですので、BuilderのaddMatcherメソッドを利用すると以下のようにSliceを実装できます。

import { ActionReducerMapBuilder, createSlice } from "@reduxjs/toolkit";
import { RequestStatus } from "../types/constants/requestStatusType";
import { RequestState } from "../types/state/requests";
import { getTodos } from "./todosSlice";

const initialState = {};

export const requestSlice = createSlice({
  name: "request",
  initialState,
  reducers: {},
  extraReducers: (builder: ActionReducerMapBuilder<RequestState>) => {
    builder.addMatcher(

      // AsyncThunk関数のtypePrefixプロパティにはcreateAsyncThunkの第一引数で指定した文字列(typePrefix)が保存されている。
      // アクション名が 『getTodos/』から始まる場合
      (action) => action.type.startsWith(`${getTodos.typePrefix}/`),
      (state, action) => {

        // action.metaに保存されている『requestStatus』の値によって場合わけ
        switch (action.meta.requestStatus) {
          case "pending":
            {
              state[getTodos.typePrefix] = {
                status: RequestStatus.Request,
              };
            }
            break;
          case "fulfilled":
            {
              state[getTodos.typePrefix] = {
                status: RequestStatus.Success,
              };
            }
            break;
          case "rejected":
            {
              state[getTodos.typePrefix] = {
                status: RequestStatus.Failure,
              };
            }
            break;
          default: {
            return;
          }
        }
      }
    );
  },
});

export default requestSlice.reducer;

なお、actionの中身はたとえば以下のようになっています。

// 『getTodos/fulfilled』の場合

console.log(action)
// {
//   "type": "getTodos/fulfilled",
//   "payload": [
//     {
//       "id": 1,
//       "content": "go somewhere",
//       "completed": true
//     },
//     {
//       "id": 2,
//       "content": "eat something",
//       "completed": true
//     }
//   ],
//   "meta": {
//     "requestId": "2J0qYBU7W-aOKb_g7LOQn",
//     "requestStatus": "fulfilled"
//   }
// }

AsyncThunk関数を渡して動的にSliceを生成する

任意のAsyncThunk関数に対応できるようにしたSliceの実装は以下の通りです。AsyncThunk関数を渡すことでSliceを動的に作成します。

なお、AsyncThunkの型定義はtype AsyncThunk<Returned, ThunkArg, ThunkApiConfig extends AsyncThunkConfig>です。
任意のAsyncThunk関数を指定できるようにするため、以下の実装では引数のAsyncThunk関数の型をAsyncThunk<any, any, any>としています。

import {
  ActionReducerMapBuilder,
  AsyncThunk,
  createSlice,
} from "@reduxjs/toolkit";
import { RequestStatus } from "../types/constants/requestStatusType";
import { RequestState } from "../types/state/requests";
import { getTodos } from "./todosSlice";

const initialState = {};

// Sliceに渡すAsyncThunk関数一覧
const requestAsyncThunks = [getTodos];

const createRequestAsyncThunkReducer = (
  builder: ActionReducerMapBuilder<RequestState>,

  // 任意のAsyncThunkを引数に指定できるようにするため、anyにしている
  targetRequestThunk: AsyncThunk<any, any, any>
) => {
  builder.addMatcher(
    (action) => action.type.startsWith(`${targetRequestThunk.typePrefix}/`),
    (state, action) => {
      switch (action.meta.requestStatus) {
        case "pending": {
          state[targetRequestThunk.typePrefix] = {
            status: RequestStatus.Request,
          };
          break;
        }
        case "fulfilled": {
          state[targetRequestThunk.typePrefix] = {
            status: RequestStatus.Success,
          };
          break;
        }
        case "rejected": {
          state[targetRequestThunk.typePrefix] = {
            status: RequestStatus.Failure,
          };
          break;
        }
        default: {
          return;
        }
      }
    }
  );
};

export const requestSlice = createSlice({
  name: "request",
  initialState,
  reducers: {},
  extraReducers: (builder) => {
    requestAsyncThunks.forEach((thunk) => {
      createRequestAsyncThunkReducer(builder, thunk);
    });
  },
});

export default requestSlice.reducer;

さいごに

今回作成したStateの活用事例として、非同期通信中にローディング状態を表示する実装をReduxを利用した非同期処理中のローディング機能実装で紹介していますのであわせてご覧になってください。

今回の実装が組み込まれたサンプルコードはnishina555/next-thunk-todoappで公開しています。

Twitter(@nishina555)やってます。フォローしてもらえるとうれしいです!