Redux Toolkit の非同期処理の変遷を試してみる
はじめに
Reduxにはながーい歴史の中、非同期処理についてはいろいろと語られてきましたが、、🙈
Redux Toolkit v1.5.0からは新たな非同期処理であるRTK Queryが組み込まれました。
RTK Queryはswrやreact-queryと同じようなキャッシュ戦略を駆使したRedux Toolkitチーム謹製のQueryツールとなっています。
いくつかのプロジェクトでも使い始めていたところですが、ちょうどRedux Thunk -> createAsyncThunk -> RTK Queryの書き方の変遷を書いている記事を見つけたので、動作の参考になるサンプルプロジェクトを作成してみました。
あ、でもRedux Thunkは省略です🧟♂️
お試しプロジェクト作成
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を利用してファイルを生成します。
$ npx msw init ./public --save
/public/mockServiceWorker.js
が生成され、package.json
ファイルに下記が追記されます。
"msw": {
"workerDirectory": "public"
}
APIの動作とレスポンスを登録します。
create-react-app
で作成したファイルに追記していきます。
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
として用意します。
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
として用意します。
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の用意
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>
>;
ログイン画面の用意
ログインボタンだけで他になにもないページです。。
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の進化で使いやすく進化し続けており、これからも状態管理の一つの選択肢としてぜひ使っていきたいところです。
Discussion
Redux の 非同期処理周りを勉強中でしたので大変助かりました。
はじめに の部分に typo であろうものがありましたので一応報告させていただきます🙏
ご指摘ありがとうございます🙇♂️
修正させていただきました。
少々古い内容になってしまっていますがご参考になれば幸いです。