はじめに
Redux Toolkit とは、アプリケーションの状態管理をする Redux をより簡単に扱えるようにしたライブラリです。公式 にもあるように、Redux の公式推奨ライブラリとなっています。
従来の Redux 記法における問題点として挙げられる「ストアのセットアップが煩雑」「不変性を意識した State の更新処理(スプレッド構文の多用)」等を解決し、よりシンプルに Redux を扱えるようになっています。
本記事の流れ
今回のトピックはこちらです。
- ストアの作成
- State の参照
- State の更新
- API 通信を伴う State の更新
動作環境はこちらになります。
- Vite: 5.0.8
- React: 18.2.0
- TypeScript: 5.2.2
Redux Toolkit 関連のライブラリもインストールしておきます。
$ yarn add @reduxjs/toolkit react-redux
"@reduxjs/toolkit": "^2.0.1",
"react-redux": "^9.0.4",
では、本題に入ります。
ストアの作成
まずは、スライスを作成します。
スライスとは、Reducer や Action をまとめたものです。
スライスの作成には、createSlice
を使います。
createSlice
に State(初期状態)と Reducer 関数をまとめて渡してあげることでスライスが作成されます。
import { createSlice } from "@reduxjs/toolkit";
export const USER_SLICE_NAME = "user";
type UserState = {
id: string;
firstName: string;
lastName: string;
};
const initialState: UserState = {
id: "1",
firstName: "John",
lastName: "Doe",
};
const userSlice = createSlice({
name: USER_SLICE_NAME,
initialState,
reducers: {
changeFirstName: (state, action) => {
state.firstName = action.payload;
},
changeLastName: (state, action) => {
state.lastName = action.payload;
},
},
});
export const { changeFirstName, changeLastName } = userSlice.actions;
export const userReducer = userSlice.reducer;
name
にはスライスの名前が入ります。ストアから State を取得する際のキーとして使用します。reducers
には、Reducer 関数(State を更新する処理)を記述します。
スライスを作成すると、内部で Action Creator と Action Type も自動的に生成されます。Action Type は {type: "user/changeFirstName"}
の様な形式となり、スライスの名前がプレフィックスになっています。
以上で、スライスの作成は完了。どんなアクションで、どのように State を更新するか定義できました。
続いて、ストアの作成を行いますが、その前に、従来の Redux 記法と比較してみます。
// 従来の Redux で書いた場合
// Action Type
const CHANGE_FIRST_NAME = 'CHANGE_FIRST_NAME'
const CHANGE_LAST_NAME = 'CHANGE_LAST_NAME'
// Action Creator
const changeFirstName = (firstName: string) => ({
type: CHANGE_FIRST_NAME,
payload: firstName
})
const changeLastName = (lastName: string) => ({
type: CHANGE_LAST_NAME,
payload: lastName
})
const initialState = {
id: "1",
firstName: "John",
lastName: "Doe",
};
// Reducer
const userReducer = (state = initialState, action) => {
switch (action.type) {
case CHANGE_FIRST_NAME:
return {
...state,
firstName: action.payload
}
case CHANGE_LAST_NAME:
return {
...state,
lastName: action.payload
}
default:
return state
}
}
export { changeFirstName, changeLastName, userReducer }
Redux Toolkit の方がスッキリしているかと思います。また、スプレッド構文も省略できています。これは、Redux Toolkit 内部で Immer が使用されていることにより、Redux State の不変性を意識した更新処理が不要になったからです。
本題に戻ります。
次に、configureStore
を使って、ストアを作成します。先程作成した Reducer を渡してあげます。
import { configureStore } from "@reduxjs/toolkit";
import { userReducer } from "path/to/user-slice";
const store = configureStore({
reducer: {
user: userReducer,
// hoge: hogeReducer, ← 作成した Reducer はここに追加していく
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export default store;
store.getState
はストアの State を返すメソッドです。つまり、RootState
は State をまとめた型となります。
type RootState = {
user: UserState;
hoge: HogeState;
}
RootState
は State 取得時に使用します。
AppDispatch
は、dispatch
関数の型です。dispatch
関数作成時に使用します。
最後に、ストアを Provider
に渡してセットアップ完了です。
import { Provider } from "react-redux";
import store from "path/to/store";
function App() {
return (
<Provider store={store}>
{/* ... */}
</Provider>
);
}
export default App;
State の参照
State の参照には、useSelector
フックを使います。
useSelector
のインターフェースの定義は以下です。
// Interface
<TState = unknown, Selected = unknown>(selector: (state: TState) => Selected, equalityFn?: EqualityFn<Selected>): Selected;
第1引数にはセレクター関数を渡し、どの State を取得するか指定します。また、ストア作成時に定義した RootState
を使って型付けも行います。
import { useSelector } from "react-redux";
import { RootState } from "path/to/store";
import { USER_SLICE_NAME } from "path/to/user-slice";
const userState = useSelector<RootState, RootState[USER_SLICE_NAME]>((state) => state.user);
console.log(userState.firstName, userState.lastName); // John Doe
以上で、useSelector
を使った State の参照は完了です。
アクションが発行されると、前のセレクター結果値と新しいセレクター結果値の厳密等価比較(===)を行い、変更があればコンポーネントが再レンダリングされます。
State の更新
State を更新するには、アクションを発行し、 Reducer を呼び出す必要があります。
Reducer は、発行されたアクションの Action Type に応じて、State を更新します。
まずは、アクションを発行するための Dispatch 関数を作成します。Dispatch 関数の作成には、useDispatch
フックを使います。
import { useDispatch } from "react-redux";
import { AppDispatch } from "path/to/store";
const dispatch = useDispatch<AppDispatch>();
ストア作成時に定義した AppDispatch
を使って型付けも行っています。
Dispatch 関数の作成から、アクション発行までの一連の流れです。
// Usage
const Form = () => {
const dispatch = useDispatch<AppDispatch>();
const dispatchChangeFirstName = useCallback(
(firstName: string) => {
dispatch(changeFirstName(firstName));
},
[dispatch]
);
return (
<div>
<input
type="text"
value={firstName}
onChange={(e) => dispatchChangeFirstName(e.target.value)}
/>
</div>
);
};
dispatch(changeFirstName(firstName));
の部分でアクションを発行しています。アクションは以下の形式で発行されます。
{
"type": "user/changeFirstName",
"payload": "<input value>"
}
API 通信を伴う State の更新
非同期に API 通信を行い、その結果を State に反映させます。
Redux には非同期処理の手法がいくつかありますが、今回は、createAsyncThunk
関数を使って非同期処理を行います。
createAsyncThunk
は、非同期リクエストのライフサイクルを管理するための関数です。
引数に、Action Type と非同期処理を受け取り、Action Creator を生成します。
import { createAsyncThunk } from "@reduxjs/toolkit";
type UserResponse = {
id: string;
firstName: string;
lastName: string;
};
export const findUser = createAsyncThunk<UserResponse, string>(
"user/findUser", // Action Type
async (userId) => { // Async Function
const response = await fetch(`https://example.com/users/${userId}`);
const data = await response.json();
return data;
}
);
第2引数(非同期処理)は、payloadCreator
と呼ばれ、名前の通り、アクションの Payload を作成します。つまり、findUser
起点のアクションは以下になります。
{
"type": "user/findUser/pending",
"payload": "<Result of async function>"
}
Action Type の末尾に pending
という文字列が急に出てきましたが、これは非同期処理の状況を表すものです。処理状況は、pending
(非同期処理中)、fulfilled
(非同期処理の成功)、rejected
(非同期処理の失敗) の3種類があります。
前述した通り、createAsyncThunk
は、非同期リクエストのライフサイクルを管理できます。そのため、処理状況に応じて処理を切り替えることが可能で、 pending
の時はローディング画面、rejected
の時はエラー画面を表示するといったことができます。
では、続いて、非同期処理の状況に応じて処理を切り替えてみます。
まず初めに、Reducer に処理を追加する必要があります。と言うのも、現時点では、createAsyncThunk
によりアクションの準備はできたものの、そのアクションを受け取り、State を更新してくれる Reducer が存在しないからです。
「ストアの作成」で作成したスライスに Reducer を追加します。
createAsyncThunk
など、スライス外で定義したアクションは extraReducers
に追加します。
type User = {
id: string;
firstName: string;
lastName: string;
};
type Status = "idle" | "loading" | "succeeded" | "failed";
type UserState = {
data: User;
status: Status;
};
const initialState: UserState = {
data: {
id: "1",
firstName: "John",
lastName: "Doe",
},
status: "idle",
};
const userSlice = createSlice({
name: USER_SLICE_NAME,
initialState,
reducers: {
// ...
},
extraReducers: (builder) => { // 追加
builder
.addCase(findUser.pending, (state) => {
state.status = "loading";
})
.addCase(findUser.fulfilled, (state, action) => {
state.status = "succeeded";
state.data = action.payload;
})
.addCase(findUser.rejected, (state) => {
state.status = "failed";
});
},
});
builder.addCase
により、アクションと Reducer をマッピングしています。
また、State(UserState
) の型も変更し新たに status
を保持するようにしています。status
は画面表示の切り替えに使用します。
State の内容に応じて画面表示を切り替えてみます。
import { useCallback, useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { AppDispatch, RootState } from "path/to/store";
import { USER_SLICE_NAME } from "path/to/user-slice";
import { findUser } from "path/to/user-async-thunk";
const UserDetail = () => {
const userState = useSelector<RootState, RootState[USER_SLICE_NAME]>(
(state) => state.user
);
const dispatch = useDispatch<AppDispatch>();
useEffect(() => {
dispatch(findUser("1"));
}, [dispatch]);
return (
<div>
{userState.status === "loading" && <div>Loading...</div>}
{userState.status === "failed" && <div>Something went wrong!</div>}
{userState.status === "succeeded" && (
<div>
<div>{userState.data.firstName}</div>
<div>{userState.data.lastName}</div>
</div>
)}
</div>
);
};
これで非同期処理(API 通信)の状況に応じて画面表示を切り替えることができます。
以上です。