🙈

Redux Toolkit の非同期処理の変遷を試してみる

11 min read

はじめに

Reduxにはながーい歴史の中、非同期処理についてはいろいろと語られてきましたが、、🙈
Redux Toolkit v1.5.0からは新たな非同期処理であるRTK Queryが組み込まれました。

RTK Queryはswrやreact-queryと同じようなキャッシュ戦略を駆使したRedux Toolkitチーム謹製のQueryツールとなっています。

https://redux-toolkit.js.org/rtk-query/overview

いくつかのプロジェクトでも使い始めていたところですが、ちょうどRedux Thunk -> createSyncThunk -> RTK Queryの書き方の変遷を書いている記事を見つけたので、動作の参考になるサンプルプロジェクトを作成してみました。

https://medium.com/innoventes/rtk-query-redux-redefined-9a9621fbcee

あ、でもRedux Thunkは省略です🧟‍♂️

21.08.14 記事内のサンプルコードにローディングやエラーの分岐を追加しました

https://github.com/himorishige/rtk-sample

お試しプロジェクト作成

React環境をcreate-react-appのredux-typescriptテンプレートから作成します。

$ yarn create react-app rtk-query-sample --template redux-typescript
$ cd rtk-query-sample
$ yarn start

モックAPI接続用にaxiosとMock Service Workerをインストールします。
RTK Queryにはaxiosやfetchは不要ですが、createAsyncThunkで利用します。

$ yarn add axios msw

モックAPIの用意

今回はダミーのAPIがあれば十分なので、テストでも大活躍なMock Service Workerを使ってモックAPIを作成します。

ユーザーログインを想定したモックAPIを用意したいため、/loginに対してPOSTするとユーザー名とトークンを返すという想定です。

まず最初にSevice Workerを利用するためのService Workerファイルを設置するためにCLIを利用してファイルを生成します。

https://mswjs.io/docs/cli/init
$ npx msw init ./public --save

/public/mockServiceWorker.jsが生成され、package.jsonファイルに下記が追記されます。

package.json
  "msw": {
    "workerDirectory": "public"
  }

APIの動作とレスポンスを登録します。
create-react-appで作成したファイルに追記していきます。

https://mswjs.io/docs/api/setup-worker
/src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import { store } from './app/store';
import { Provider } from 'react-redux';
import * as serviceWorker from './serviceWorker';
import { rest, setupWorker } from 'msw';  // 追加

// モックAPIサーバーを設定します。
// userNameにjohnが入っているか入っていないかだけの簡単な分岐ロジックです。
const mockServer = setupWorker(
  rest.post('/login', (req, res, ctx) => {
    const { userName }: any = req.body;
    if (userName === 'john') {
      return res(
        ctx.delay(2000),
        ctx.status(200),
        ctx.json({
          userName: 'john',
          token: 'token1234',
        }),
      );
    }
    return res(
      ctx.delay(2000),
      ctx.status(401),
      ctx.json({
        message: 'unauthorized',
      }),
    );
  }),
);

mockServer.start();

ReactDOM.render(
  <React.StrictMode>
    <Provider store={store}>
      <App />
    </Provider>
  </React.StrictMode>,
  document.getElementById('root'),
);

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();

createAsyncThunkの用意

createAsyncThunkを利用したSliceをauthSlice.tsとして用意します。

/src/features/auth/authSlice.ts
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
import { RootState } from '../../app/store';
import axios, { AxiosRequestConfig } from 'axios';

interface LoginForm {
  userName: string;
  password: string;
}

interface UserState {
  userName: string | undefined;
  token: string | undefined;
}

export interface AuthState {
  user: UserState | undefined;
  status: 'idle' | 'loading' | 'failed';
}

const initialState: AuthState = {
  user: undefined,
  status: 'idle',
};

export const loginAsync = createAsyncThunk(
  'auth/login',
  async (loginForm: LoginForm, { rejectWithValue }) => {
    const config: AxiosRequestConfig = {
      headers: {
        'Content-type': 'application/json',
      },
    };

    try {
      const result = await axios.post('/login', loginForm, config);

      return result.data;
    } catch (error: any) {
      if (!error.response) {
        throw error;
      }
      return rejectWithValue(error.response.data);
    } finally {
    }
  },
);

export const authSlice = createSlice({
  name: 'auth',
  initialState,
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(loginAsync.pending, (state) => {
        state.status = 'loading';
      })
      .addCase(loginAsync.fulfilled, (state, action: PayloadAction<UserState>) => {
        state.status = 'idle';
        state.user = action.payload;
      })
      .addCase(loginAsync.rejected, (state) => {
        state.status = 'idle';
      });
  },
});

export const selectLoadingStatus = (state: RootState) => state.auth.status;

export default authSlice.reducer;

RTK Queryの用意

RTK Queryを利用したSliceをrtkAuthSlice.tsとして用意します。

/src/features/rtk-auth/rtkAuthSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

interface LoginForm {
  userName: string;
  password: string;
}

interface UserState {
  userName: string | undefined;
  token: string | undefined;
}

export interface AuthState {
  user: UserState | undefined;
  // ローディング状態はRTK Queryのカスタムフックで管理
  // status: 'idle' | 'loading' | 'failed';
}

const initialState: AuthState = {
  user: undefined,
  // ローディング状態はRTK Queryのカスタムフックで管理
  // status: 'idle',
};

export const authApi = createApi({
  baseQuery: fetchBaseQuery({ baseUrl: '/' }),
  endpoints: (builder) => ({
    login: builder.mutation<UserState, LoginForm>({
      query: (credentials) => ({
        url: 'login',
        method: 'POST',
        body: credentials,
      }),
    }),
  }),
});

export const { useLoginMutation } = authApi;

export const rtkAuthSlice = createSlice({
  name: 'rtk-auth',
  initialState,
  reducers: {},
  extraReducers: (builder) => {
    builder
      // createAsyncThunkと違いaddMatcherで成功失敗の状態を取得可能です。
      /** RTK Queryのカスタムフックでローディングステータスを取得できるので、
       * 下記のようなローディング状態の取得のためだけの利用であれば不要だと思います。
       */
      // .addMatcher(authApi.endpoints.login.matchPending, (state) => {
      //   state.status = 'loading';
      // })
      .addMatcher(
        authApi.endpoints.login.matchFulfilled,
        (state, action: PayloadAction<UserState>) => {
          // state.status = 'idle';
          state.user = action.payload;
        },
      );
    // .addMatcher(authApi.endpoints.login.matchRejected, (state) => {
    //   state.status = 'idle';
    // });
  },
});

export default rtkAuthSlice.reducer;

Storeの用意

/src/app/store.ts
import { configureStore, ThunkAction, Action } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice';
import authReducer from '../features/auth/authSlice';
import rtkAuthReducer, { authApi } from '../features/rtk-auth/RtkAuthSlice';

export const store = configureStore({
  reducer: {
    counter: counterReducer,
    // createAsyncThunkのReducer
    auth: authReducer,
    // RTK QueryのReducer
    rtkauth: rtkAuthReducer,
    // RTK QueryのcreateApiが生成するReducer(今回は利用せず)
    [authApi.reducerPath]: authApi.reducer,
  },
  // RTK Query用の各種便利機能をmiddlewareとして登録(今回は利用せず)
  middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(authApi.middleware),
});

export type AppDispatch = typeof store.dispatch;
export type RootState = ReturnType<typeof store.getState>;
export type AppThunk<ReturnType = void> = ThunkAction<
  ReturnType,
  RootState,
  unknown,
  Action<string>
>;

ログイン画面の用意

ログインボタンだけで他になにもないページです。。

/src/App.tsx
import React, { useState } from 'react';
import { useAppDispatch, useAppSelector } from './app/hooks';
import { loginAsync, selectLoadingStatus } from './features/auth/authSlice';
import { useLoginMutation } from './features/rtk-auth/RtkAuthSlice';

function App() {
  const [userName, setUserName] = useState('');

  const inputHandler = (event: React.ChangeEvent<HTMLInputElement>) => {
    setUserName(event.target.value);
  };

  // CreateAsyncThunkでの制御

  // CreateAsyncThunkにはdispatchが必要
  const dispatch = useAppDispatch();
  // loadingの状態を取得
  const loadingStatus = useAppSelector(selectLoadingStatus);

  const loginHandler = async () => {
    const result = await dispatch(
      loginAsync({
        userName: userName,
        password: 'pass1234',
      }),
    );
    // ログインに成功した場合
    if (loginAsync.fulfilled.match(result)) {
      alert('loginに成功しました');
    }
    // ログインに失敗した場合
    if (loginAsync.rejected.match(result)) {
      alert('loginに失敗しました');
    }
  };

  // RTKQueryでの制御

  // RTK Queryはカスタムフックが生成される
  // result, error, isUnititialized, isLoading, isSuccess, isError
  const [login, { isLoading, isError }] = useLoginMutation();

  const rtkLoginHandler = async () => {
    const data = {
      userName: userName,
      password: 'pass1234',
    };
    try {
      const response = await login(data).unwrap();
      alert('loginに成功しました');
    } catch (error) {
      alert('loginに失敗しました');
    }
  };

  return (
    <div className="App">
      <input
        type="text"
        name="userName"
        placeholder="john"
        value={userName}
        onChange={inputHandler}
      />
      <hr />
      <div>
        <button type="button" onClick={loginHandler}>
          CreateAsyncThunk LOGIN
        </button>
        {loadingStatus === 'loading' ? (
          <div>loading...</div>
        ) : loadingStatus === 'failed' ? (
          <div>failed...</div>
        ) : null}
      </div>
      <hr />
      <div>
        <button type="button" onClick={rtkLoginHandler}>
          RTK LOGIN
        </button>
        {isLoading ? <div>loading...</div> : isError ? <div>failed...</div> : null}
      </div>
    </div>
  );
}

export default App;

動作確認

これで準備はできたので起動してみます。

$ yarn start

初期のStateはこのような状態です。

Inputエリアにjohnと入力して、
CreateAsyncThunk LOGINボタンを押すと、2秒間loading...と表示された後にReduxのStoreにデータが格納されます。

同じ用にRTK LOGINボタンを押すと、

それぞれ、Pendingの状態を経てfullfilledになったところでユーザー名とトークンの情報が入りました。

最終的なStateとしてはどちらも同じになりますが、ソースの記載はかなり異なっています。
RTK QueryもRedux独特の記載が多く記載量が少なくなるわけではありませんが、カスタムフックによるローディングやエラーハンドリングが楽になるためフロント側の処理はかなりシンプルになるはずです。(App.tsx参照)

さいごに

今回はデータをPOSTするのみでしたが、RTK Queryには、プリフェッチやポーリング、キャッシュ機能なども備わっているので状態管理としてReduxを利用する環境での相性がよいのはもちろんDeveloperToolもひとつにまとまってデバッグできるのも便利です。

Redux Toolkitは最近各種ドキュメントも刷新し、TypeScriptでの説明やmswを利用した新しいテストについてのドキュメントも充実しています。Redux不要論も多く聞かれますがToolkitの進化で使いやすく進化し続けており、これからも状態管理の一つの選択肢としてぜひ使っていきたいところです。

https://redux-toolkit.js.org/tutorials/overview

https://redux.js.org/usage/writing-tests

Discussion

ログインするとコメントできます