🍷

ReduxToolkit-createSliceを使用したre-ducks風フォルダ構成で自分流ベストプラクティス?

2022/10/30に公開

始めに

Reactの状態管理はReduxを使うことが何かと多いのですが、ReduxtToolkitを使ってみて結構便利だったのですが、createSlice, createAsyncThunk, extraReducersあたりを使用するにあたりフォルダ構成について検討したのでまとめます。
createSlice vs createReducerでどちらが良いか等悩みましたが、createSliceでextraReducerを使用するのが簡単で便利なためcreateSlice推しです。

業務はJavaでバックエンドを密かにいじってます。

前提

今回はAPIをacyncThunkで呼び出し、extraReducer, reducerでAPIステータス・レスポンスを管理するような、よくあるパターンをイメージして実装します。
また、認証状態とAPIの実行状態についてをReduxで管理することを想定し簡易的に実装しています。

フォルダ構成

re-ducksパターンを参考にredux-toolkitでも構成
https://github.com/alexnm/re-ducks

以下#No順に解説します

└ reducks
    ├ hooks.ts  #説明範囲外
    ├ store.ts #説明範囲外
    ├ provider.tsx #説明範囲外
    ├ auth
    │   ├ slice.ts #1
    │   ├ states.ts #2
    │   ├ reducers.ts #3 
    │   ├ asyncThunk.ts #4-3
    │   ├ extraReducers.ts #5
    │   ├ selectors.ts #6
    │   └ index.ts #7
    │
    └common
        └ asyncThunkConfig.ts #4-1
other...
isErrorResponse.ts # 4-2


実装

以下の順番で説明します

  1. slice.ts
  2. states.ts
  3. reducers.ts
  4. asyncThunkConfig.ts, isErrorResponse.ts, asyncThunk.ts
  5. extraReducers.ts
  6. selectors.ts
  7. index.ts

slice.ts

slice.tsはstore.tsでstoreを構築する際に使用します。
今回の構築パターンでは各引数毎に別ファイルで管理しているためimportして必要な引数を設定してsliceを作成、exportすることが役割となります
createSliceはCreateSliceOptionsに基づいて引数を指定しますので必要であれば追加します。

slice.ts
import { createSlice } from "@reduxjs/toolkit";
import { reducers } from "./reducers";
import { initialState } from "./states";
import { extraReducers } from "./extraReducers";

/**
 * authSliceを定義
 */
export const authSlice = createSlice({
  name: "auth",
  initialState,
  reducers,
  extraReducers,
});

states.ts

states.tsはinitialStateはもちろん、それ以外reducer等でも扱うような固定のstateを定義して管理します。(例えばresetState等...)

slice.ts
import { ApiStatus } from "../common/apiStatus";

/**
 * Authステート型
 */
export type AuthStateType = {
  status: "WAITING" | "PENDING" | "SUCCESS" | "FAILED";
  loggedIn: boolean;
  authToken: string | null;
};

/**
 * 初期化ステート
 */
export const initialState: AuthStateType = {
  status: "WAITING"
  loggedIn: false,
  authToken: null,
};

reducers.ts

reducers.tsではcreateSliceの引数に渡すreducersを定義します。Actionに対応したステート更新処理等を実装します。
今回はサインイン処理開始時、成功時、失敗時のステート更新処理を実装しています。

reducers.ts
import { PayloadAction } from "@reduxjs/toolkit";
import { WritableDraft } from "immer/dist/internal";
// 独自実装したErrorResponse, SignResponseの型になります
import { ErrorResponse } from "plugins/axios";
import { SignInResponseType } from "services/models/SignInType";
import { AuthStateType, resetState } from "./states";

// Actionを指定して呼び出すことがあるため各reducer毎に生成します
export const ACTION_TYPE = {
  SET_SIGN_IN_PENDING_STATE_ACTION: "SET_SIGN_IN_PENDING_STATE_ACTION",
  SET_SIGN_IN_SUCCESS_STATE_ACTION: "SET_SIGN_IN_SUCCESS_STATE_ACTION",
  SET_SIGN_IN_FAILED_STATE_ACTION: "SET_SIGN_IN_FAILED_STATE_ACTION",
  RESET_STATE_ACTION: "RESET_STATE_ACTION",
};

/**
 * reducersを定義
 */
export const reducers = {
  // サインイン処理中
  setSignInPendingStateAction: (state: WritableDraft<AuthStateType>) => {
    state.status = "PENDING";
  },

  // サインイン成功時
  setSignInSuccessStateAction: (
    state: WritableDraft<AuthStateType>,
    action: PayloadAction<SignInResponseType>
  ) => {
    state.status = "SUCCESS";
    state.authToken = action.payload.auth_token;
    state.loggedIn = true;
  },

  // サインイン失敗時
  setSignInFailedStateAction: (
    state: WritableDraft<AuthStateType>,
    action: PayloadAction<ErrorResponse | undefined>
  ) => {
    state.status = "FAILED";
    state.loggedIn = false;
    state.authToken = null;
  },
};

asyncThunkConfig.ts, isErrorResponse.ts, asyncThunk.ts

asyncThunkConfig.tsではcreateAsyncThunkを型安全に実装するために必要な型情報を定義します。
isErrorResponse.tsではAPIレスポンスは成功時レスポンスとエラーレスポンスのユニオン型のため、エラーレスポンスであるかどうかの変別用の型ガード関数を作成します。今回はプロパティで型ガードを用いて簡易実装しましたが、各々自由に実装してください。共通プロパティ持たせて判別するのが良いと感じている...
asyncThunk.tsではasyncThunkConfig.tsで作成した型情報をもとにcreateAsyncThunkで非同期処理を実装します。

asyncThunkConfig.ts
import { RootState } from "../store";
// 独自定義した型を使用してください
// 名称通りエラーレスポンスの型になります
import { ErrorResponse } from "../../axios/type";

/**
 * AsyncThunk生成時に共通使用する型定義
 */
export type AsyncThunkConfig = {
  state: RootState;
  rejectValue: ErrorResponse;
};
isErrorResponse.ts
import { AxiosResponse } from "axios";
import { ErrorResponse } from "./type";

/**
 * エラーレスポンス判定
 * @param response requestメソッドレスポンス
 * @returns 判定結果
 */
export const isFailedResponse = <T>(
  response: AxiosResponse<T> | ErrorResponse
): response is ErrorResponse =>
  "statusCode" in response && "error_message" in response && "error_detail" in response;
asyncThunk.ts
import { createAsyncThunk } from "@reduxjs/toolkit";
// httpRequest処理をWrapして実装しています
import { request } from "plugins/axios/request";
import { isErrorResponse } from "plugins/axios/utils";
import {
  SignInRequestType,
  SignInResponseType,
} from "services/models/SignInType";
import { AsyncThunkConfig } from "../common/asyncThunkConfig";

/**
 * サインインAPIを非同期実行
 */
export const signInAsyncThunk = createAsyncThunk<
  SignInResponseType,
  SignInRequestType,
  // ここで作成したAsyncThunkConfigを指定しないとrejectWithValueでの
  // エラー時のaction.xxxの型がanyになってしまう。extraReducersでの実装で困る...
  AsyncThunkConfig
>(
  "auth/signIn",
  async ({ email, password }: SignInRequestType, { rejectWithValue }) => {
    const response = await request<
      SignInResponseType,
      unknown,
      SignInRequestType
    >(
      "POST",
      "SIGN_IN",
      undefined,
      {
        email: email,
        password: password,
      },
      undefined,
      {
        message: "サインインに失敗しました",
        detail: "サインインに失敗しました",
      }
    );

    if (isErrorResponse(response)) {
      // エラー時の処理
      return rejectWithValue(response);
    } else {
          // 正常時の処理
      return response.data;
    }
  }
);

extraReducers.ts

extraReducers.tsでは作成したasyncThunkのステータスに応じた処理を定義することができます。後続処理を独立して実装できるので管理上も良い...
ここでは上記であらかじめ定義していたreducerをもとにステートを更新します。

extraReducers.ts
import { ActionReducerMapBuilder } from "@reduxjs/toolkit";
import { signInAsyncThunk } from "./asyncThunks";
import { ACTION_TYPE } from "./reducers";
import { authSlice } from "./slice";
import { AuthStateType } from "./states";

/**
 * ExtraReducersを定義
 */
export const extraReducers = (
  builder: ActionReducerMapBuilder<AuthStateType>
) => {
  // サインイン処理中
  builder.addCase(signInAsyncThunk.pending, (state) => {
    authSlice.caseReducers.setSignInPendingStateAction(state);
  });

  // サインイン処理成功時
  builder.addCase(signInAsyncThunk.fulfilled, (state, action) => {
    authSlice.caseReducers.setSignInSuccessStateAction(state, {
      type: ACTION_TYPE.SET_SIGN_IN_SUCCESS_STATE_ACTION,
      payload: action.payload,
    });
  });

  // サインイン処理失敗時
  // (AsyncThunkConfigで型を指定しないとここの第二引数のコールバック関数のactionの型が不明になる!!!)
  builder.addCase(signInAsyncThunk.rejected, (state, action) => {
    authSlice.caseReducers.setSignInFailedStateAction(state, {
      type: ACTION_TYPE.SET_SIGN_IN_FAILED_STATE_ACTION,
      payload: action.payload,
    });
  });
};

selectors.ts

selectors.tsではComponent側でステートを取得する関数を定義します。
全体取得、それぞれのプロパティを取得するselectorを定義しています。

selectors.ts
import { createSelector } from "reselect";
import { RootState } from "../store";
import { AuthStateType } from "./states";

/**
 * authを全て取得する
 * @param state state
 * @returns auth
 */
export const authSelector = (state: RootState): AuthStateType => state.auth;

/**
 * トークンを取得する
 */
export const authTokenSelector = createSelector(authSelector, (auth) => {
  return auth.authToken;
});

/**
 * ログインフラグを取得する
 */
export const loggedInSelector = createSelector(
  authSelector,
  (auth): boolean => {
    return auth.loggedIn;
  }
);

/**
 * サインインAPIステータスを取得する
 */
export const signInApiStatusSelector = createSelector(authSelector, (auth) => {
  return auth.status;
});

index.ts

最後ですがindex.tsではComponent側でインポートする際のエントリポイントとして定義します。
ここでexportしたものをComponent側でimportして使用します。

indext.ts
import { authSlice } from "./slice";

export * as authAsyncThunks from "./asyncThunks";
export * as authSelectors from "./selectors";
export const authActions = authSlice.actions;
export const authReducer = authSlice.reducer;

Component側での実装例
非同期処理を呼び出して、APIのstatusを監視しています。

component.tsx
import { authSelectors, authAsyncThunks } from "plugins/reduxToolkit/auth";

export const SignInComponent: React.FC = () => {
    const dispatch = useAppDispatch();
    const signInApiStatus = useAppSelector(authSelectors.signInApiStatusSelector);
    
    return (
      <div>
        <p>status-{signInApiStatus}</p>
        <Button
	    onClick={() => dispatch(authAsyncThunks.signInAsyncThunk({
	        email: xxx@example.com,
		password: xxxxxxxxxx
	    }))}
	/>
      </div>
    )
}

最後に

実装初期段階で検討したのでまだ使用していませんが、createEntityAdapterなる素晴らしく便利なものがありそうなのでそちらも使いたいところ。
https://redux-toolkit.js.org/api/createEntityAdapter

そして、この構成は意外と気に入っています。
別ファイルで記述すると型を毎回書くのがめんどくさいので、型定義使うとか、もう一枚レイヤ挟んだりしてうまくできないかとは思っていますが、、、記述量そこまで変わらないので一旦スルー。

フロントまだまだなので色々なライブラリ触っていじり倒したいところ...

以上

Discussion