Day 4: 非同期処理
今日学ぶこと
- Redux で非同期処理にミドルウェアが必要な理由
- Redux Thunk の仕組みと RTK での組み込みサポート
createAsyncThunkによるライフサイクル管理(pending / fulfilled / rejected)extraReducersとbuilder.addCaseによるローディングステート管理- エラーハンドリングパターン(rejectWithValue)
AbortControllerによるキャンセル処理- Thunk vs Saga vs Observable の比較
なぜ Redux で非同期処理にミドルウェアが必要なのか
Redux のアクションはプレーンなオブジェクトです。リデューサーは純粋関数でなければなりません。つまり、API 呼び出しやタイマーのような副作用をリデューサー内で直接実行することはできません。
// これは動かない!アクションはプレーンオブジェクトでなければならない
function fetchUsers() {
// Bad: アクションクリエーターで非同期処理を直接実行
const response = await fetch("/api/users");
const data = await response.json();
return { type: "users/loaded", payload: data };
}
ミドルウェアは、dispatch とリデューサーの間に割り込む仕組みです。これにより、関数やPromiseなどのプレーンオブジェクト以外をdispatchできるようになります。
sequenceDiagram
participant C as Component
participant D as dispatch
participant M as Middleware (Thunk)
participant R as Reducer
participant S as Store
participant API as API Server
C->>D: dispatch(fetchUsers())
D->>M: thunk function を受け取る
M->>API: fetch("/api/users")
API-->>M: Response (JSON)
M->>D: dispatch({ type: "fulfilled", payload: data })
D->>R: アクションを処理
R->>S: ステートを更新
S-->>C: 再レンダリング
Redux Thunk
Thunk とは「遅延評価される計算」を意味するプログラミング用語です。Redux Thunk は、アクションの代わりに 関数 を dispatch できるようにするミドルウェアです。
RTK に組み込み済み
Redux Toolkit の configureStore は、デフォルトで Redux Thunk ミドルウェアを含んでいます。追加のインストールは不要です。
import { configureStore } from "@reduxjs/toolkit";
const store = configureStore({
reducer: rootReducer
// thunk ミドルウェアは自動的に含まれる
});
手動で Thunk を書く
// Thunk アクションクリエーター
const fetchUsers = () => {
// dispatch と getState を引数に受け取る関数を返す
return async (dispatch, getState) => {
dispatch({ type: "users/loading" });
try {
const response = await fetch("/api/users");
const data = await response.json();
dispatch({ type: "users/loaded", payload: data });
} catch (error) {
dispatch({ type: "users/error", payload: error.message });
}
};
};
// コンポーネントから dispatch
dispatch(fetchUsers());
TypeScript版
import { AppDispatch, RootState } from "./store";
const fetchUsers = () => {
return async (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: "users/loading" });
try {
const response = await fetch("/api/users");
const data: User[] = await response.json();
dispatch({ type: "users/loaded", payload: data });
} catch (error) {
dispatch({
type: "users/error",
payload: error instanceof Error ? error.message : "Unknown error"
});
}
};
};
しかし、毎回このボイラープレートを書くのは面倒です。そこで createAsyncThunk の出番です。
createAsyncThunk
createAsyncThunk は、非同期処理のための Thunk アクションクリエーターを生成する RTK のユーティリティです。3つのライフサイクルアクションを自動的に生成してくれます。
基本的な使い方
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
// 非同期 Thunk の定義
const fetchUsers = createAsyncThunk(
"users/fetchUsers", // アクションタイプの接頭辞
async (_, { rejectWithValue }) => {
try {
const response = await fetch("/api/users");
if (!response.ok) {
throw new Error("Failed to fetch users");
}
const data = await response.json();
return data; // これが fulfilled アクションの payload になる
} catch (error) {
return rejectWithValue(error.message);
}
}
);
TypeScript版
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
interface User {
id: number;
name: string;
email: string;
}
const fetchUsers = createAsyncThunk<
User[], // 成功時の戻り値の型
void, // 引数の型
{ rejectValue: string } // rejectWithValue の型
>(
"users/fetchUsers",
async (_, { rejectWithValue }) => {
try {
const response = await fetch("/api/users");
if (!response.ok) {
throw new Error("Failed to fetch users");
}
const data: User[] = await response.json();
return data;
} catch (error) {
return rejectWithValue(
error instanceof Error ? error.message : "Unknown error"
);
}
}
);
自動生成されるアクション
createAsyncThunk は以下の3つのアクションタイプを自動的に生成します。
| アクション | タイプ | タイミング |
|---|---|---|
| pending | users/fetchUsers/pending |
非同期処理の開始時 |
| fulfilled | users/fetchUsers/fulfilled |
成功時 |
| rejected | users/fetchUsers/rejected |
エラー時 |
flowchart LR
subgraph Lifecycle["createAsyncThunk ライフサイクル"]
direction LR
D["dispatch\n(fetchUsers())"] --> P["pending"]
P --> API["API呼び出し"]
API -->|成功| F["fulfilled\npayload: data"]
API -->|失敗| R["rejected\npayload: error"]
end
style P fill:#f59e0b,color:#fff
style F fill:#22c55e,color:#fff
style R fill:#ef4444,color:#fff
extraReducers でローディングステートを管理する
createAsyncThunk で生成されたアクションは、extraReducers で処理します。
ローディングステートパターン
const usersSlice = createSlice({
name: "users",
initialState: {
ids: [],
entities: {},
status: "idle", // "idle" | "loading" | "succeeded" | "failed"
error: null
},
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchUsers.pending, (state) => {
state.status = "loading";
state.error = null;
})
.addCase(fetchUsers.fulfilled, (state, action) => {
state.status = "succeeded";
// エンティティアダプターを使用している場合
usersAdapter.setAll(state, action.payload);
})
.addCase(fetchUsers.rejected, (state, action) => {
state.status = "failed";
state.error = action.payload ?? "An error occurred";
});
}
});
TypeScript版
import { createSlice, createEntityAdapter, EntityState } from "@reduxjs/toolkit";
interface User {
id: number;
name: string;
email: string;
}
interface UsersState extends EntityState<User, number> {
status: "idle" | "loading" | "succeeded" | "failed";
error: string | null;
}
const usersAdapter = createEntityAdapter<User>();
const initialState: UsersState = usersAdapter.getInitialState({
status: "idle",
error: null
});
const usersSlice = createSlice({
name: "users",
initialState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchUsers.pending, (state) => {
state.status = "loading";
state.error = null;
})
.addCase(fetchUsers.fulfilled, (state, action) => {
state.status = "succeeded";
usersAdapter.setAll(state, action.payload);
})
.addCase(fetchUsers.rejected, (state, action) => {
state.status = "failed";
state.error = action.payload ?? "An error occurred";
});
}
});
コンポーネントでの使用
import { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { fetchUsers } from "./usersSlice";
function UserList() {
const dispatch = useDispatch();
const users = useSelector(usersSelectors.selectAll);
const status = useSelector((state) => state.users.status);
const error = useSelector((state) => state.users.error);
useEffect(() => {
if (status === "idle") {
dispatch(fetchUsers());
}
}, [status, dispatch]);
if (status === "loading") {
return <div className="spinner">読み込み中...</div>;
}
if (status === "failed") {
return <div className="error">エラー: {error}</div>;
}
return (
<ul>
{users.map((user) => (
<li key={user.id}>
{user.name} ({user.email})
</li>
))}
</ul>
);
}
実践例: CRUD 操作
リソースの作成
const createUser = createAsyncThunk(
"users/createUser",
async (userData, { rejectWithValue }) => {
try {
const response = await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(userData)
});
if (!response.ok) {
const errorData = await response.json();
return rejectWithValue(errorData.message);
}
return await response.json();
} catch (error) {
return rejectWithValue(error.message);
}
}
);
リソースの更新
const updateUser = createAsyncThunk(
"users/updateUser",
async ({ id, changes }, { rejectWithValue }) => {
try {
const response = await fetch(`/api/users/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(changes)
});
if (!response.ok) {
return rejectWithValue("Update failed");
}
return await response.json();
} catch (error) {
return rejectWithValue(error.message);
}
}
);
リソースの削除
const deleteUser = createAsyncThunk(
"users/deleteUser",
async (userId, { rejectWithValue }) => {
try {
const response = await fetch(`/api/users/${userId}`, {
method: "DELETE"
});
if (!response.ok) {
return rejectWithValue("Delete failed");
}
return userId; // 削除されたIDを返す
} catch (error) {
return rejectWithValue(error.message);
}
}
);
すべてを extraReducers で処理する
const usersSlice = createSlice({
name: "users",
initialState: usersAdapter.getInitialState({
status: "idle",
error: null
}),
reducers: {},
extraReducers: (builder) => {
builder
// Fetch
.addCase(fetchUsers.pending, (state) => {
state.status = "loading";
})
.addCase(fetchUsers.fulfilled, (state, action) => {
state.status = "succeeded";
usersAdapter.setAll(state, action.payload);
})
.addCase(fetchUsers.rejected, (state, action) => {
state.status = "failed";
state.error = action.payload;
})
// Create
.addCase(createUser.fulfilled, (state, action) => {
usersAdapter.addOne(state, action.payload);
})
// Update
.addCase(updateUser.fulfilled, (state, action) => {
usersAdapter.upsertOne(state, action.payload);
})
// Delete
.addCase(deleteUser.fulfilled, (state, action) => {
usersAdapter.removeOne(state, action.payload);
});
}
});
TypeScript版
interface User {
id: number;
name: string;
email: string;
}
interface CreateUserPayload {
name: string;
email: string;
}
interface UpdateUserPayload {
id: number;
changes: Partial<Omit<User, "id">>;
}
const createUser = createAsyncThunk<User, CreateUserPayload, { rejectValue: string }>(
"users/createUser",
async (userData, { rejectWithValue }) => {
try {
const response = await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(userData)
});
if (!response.ok) {
const errorData = await response.json();
return rejectWithValue(errorData.message);
}
return (await response.json()) as User;
} catch (error) {
return rejectWithValue(
error instanceof Error ? error.message : "Unknown error"
);
}
}
);
const updateUser = createAsyncThunk<User, UpdateUserPayload, { rejectValue: string }>(
"users/updateUser",
async ({ id, changes }, { rejectWithValue }) => {
try {
const response = await fetch(`/api/users/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(changes)
});
if (!response.ok) {
return rejectWithValue("Update failed");
}
return (await response.json()) as User;
} catch (error) {
return rejectWithValue(
error instanceof Error ? error.message : "Unknown error"
);
}
}
);
const deleteUser = createAsyncThunk<number, number, { rejectValue: string }>(
"users/deleteUser",
async (userId, { rejectWithValue }) => {
try {
const response = await fetch(`/api/users/${userId}`, {
method: "DELETE"
});
if (!response.ok) {
return rejectWithValue("Delete failed");
}
return userId;
} catch (error) {
return rejectWithValue(
error instanceof Error ? error.message : "Unknown error"
);
}
}
);
エラーハンドリングパターン
rejectWithValue を使ったカスタムエラー
デフォルトでは、Thunk 内で例外が発生すると、エラーのシリアライズに問題が起きることがあります。rejectWithValue を使うことで、構造化されたエラー情報を返せます。
const fetchUsers = createAsyncThunk(
"users/fetchUsers",
async (_, { rejectWithValue }) => {
try {
const response = await fetch("/api/users");
if (!response.ok) {
// API が返すエラー情報を構造化して返す
const errorData = await response.json();
return rejectWithValue({
status: response.status,
message: errorData.message || "Unknown error",
details: errorData.errors || []
});
}
return await response.json();
} catch (error) {
// ネットワークエラーなど
return rejectWithValue({
status: 0,
message: "Network error",
details: []
});
}
}
);
// reducer で使う
builder.addCase(fetchUsers.rejected, (state, action) => {
state.status = "failed";
if (action.payload) {
// rejectWithValue で返したエラー
state.error = action.payload.message;
state.errorDetails = action.payload.details;
} else {
// 予期しないエラー
state.error = action.error.message;
}
});
エラー状態のリセット
const usersSlice = createSlice({
name: "users",
initialState,
reducers: {
errorCleared(state) {
state.error = null;
state.status = "idle";
}
},
extraReducers: (builder) => {
// ...
}
});
// コンポーネントでリトライする際にエラーをリセット
function UserList() {
const dispatch = useDispatch();
const error = useSelector((state) => state.users.error);
const handleRetry = () => {
dispatch(errorCleared());
dispatch(fetchUsers());
};
if (error) {
return (
<div>
<p>エラー: {error}</p>
<button onClick={handleRetry}>再試行</button>
</div>
);
}
}
キャンセル処理
AbortController によるキャンセル
createAsyncThunk は AbortController によるキャンセルをサポートしています。
const fetchUsers = createAsyncThunk(
"users/fetchUsers",
async (_, { signal }) => {
// signal は自動的に AbortController.signal が渡される
const response = await fetch("/api/users", { signal });
return await response.json();
}
);
// コンポーネントでキャンセルする
function UserList() {
const dispatch = useDispatch();
useEffect(() => {
const promise = dispatch(fetchUsers());
// クリーンアップ時にリクエストをキャンセル
return () => {
promise.abort();
};
}, [dispatch]);
}
condition オプションによる条件付き実行
重複リクエストを防ぐために、condition オプションを使えます。
const fetchUsers = createAsyncThunk(
"users/fetchUsers",
async () => {
const response = await fetch("/api/users");
return await response.json();
},
{
// 既にローディング中なら実行しない
condition: (_, { getState }) => {
const { status } = getState().users;
if (status === "loading") {
return false; // Thunk をキャンセル
}
}
}
);
TypeScript版
const fetchUsers = createAsyncThunk<
User[],
void,
{ state: RootState; rejectValue: string }
>(
"users/fetchUsers",
async (_, { signal, rejectWithValue }) => {
try {
const response = await fetch("/api/users", { signal });
if (!response.ok) {
return rejectWithValue("Failed to fetch");
}
return (await response.json()) as User[];
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
return rejectWithValue("Request cancelled");
}
return rejectWithValue("Network error");
}
},
{
condition: (_, { getState }) => {
const { status } = getState().users;
return status !== "loading";
}
}
);
ローディングステートパターン
非同期処理のステート管理には、以下の標準パターンを使います。
{
status: "idle" | "loading" | "succeeded" | "failed",
error: string | null
}
stateDiagram-v2
[*] --> idle
idle --> loading : dispatch(fetchData())
loading --> succeeded : fulfilled
loading --> failed : rejected
succeeded --> loading : 再取得
failed --> loading : リトライ
failed --> idle : errorCleared
複数の非同期操作がある場合
const initialState = usersAdapter.getInitialState({
fetchStatus: "idle",
createStatus: "idle",
updateStatus: "idle",
deleteStatus: "idle",
error: null
});
// または、操作ごとにオブジェクトにまとめる
const initialState2 = usersAdapter.getInitialState({
operations: {
fetch: { status: "idle", error: null },
create: { status: "idle", error: null },
update: { status: "idle", error: null },
delete: { status: "idle", error: null }
}
});
ミドルウェア比較: Thunk vs Saga vs Observable
| 特徴 | Redux Thunk | Redux Saga | Redux Observable |
|---|---|---|---|
| 概念 | 関数を dispatch | ジェネレーター関数 | RxJS Observable |
| 学習コスト | 低い | 高い | 高い |
| RTK 組み込み | はい | いいえ | いいえ |
| テスタビリティ | 普通 | 高い | 高い |
| 複雑な非同期フロー | 限定的 | 得意 | 得意 |
| バンドルサイズ | 最小 | 中程度 | 大きい |
| 推奨ケース | ほとんどのアプリ | 複雑な非同期フロー | リアクティブな処理 |
RTK を使う場合は、まず Thunk から始めましょう。 ほとんどのアプリケーションでは Thunk で十分です。複雑な非同期フローが必要になった場合は、RTK Query(Day 8 で学習)を検討してください。
実践例: ユーザー管理画面
ここまで学んだことを組み合わせた完全な例です。
// features/users/usersSlice.js
import { createSlice, createAsyncThunk, createEntityAdapter } from "@reduxjs/toolkit";
const usersAdapter = createEntityAdapter({
sortComparer: (a, b) => a.name.localeCompare(b.name)
});
// Async Thunks
export const fetchUsers = createAsyncThunk(
"users/fetchUsers",
async (_, { rejectWithValue, signal }) => {
try {
const response = await fetch("/api/users", { signal });
if (!response.ok) throw new Error("Failed to fetch");
return await response.json();
} catch (error) {
return rejectWithValue(error.message);
}
},
{
condition: (_, { getState }) => {
return getState().users.status !== "loading";
}
}
);
export const createUser = createAsyncThunk(
"users/createUser",
async (userData, { rejectWithValue }) => {
try {
const response = await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(userData)
});
if (!response.ok) {
const err = await response.json();
return rejectWithValue(err.message);
}
return await response.json();
} catch (error) {
return rejectWithValue(error.message);
}
}
);
export const deleteUser = createAsyncThunk(
"users/deleteUser",
async (userId, { rejectWithValue }) => {
try {
const response = await fetch(`/api/users/${userId}`, {
method: "DELETE"
});
if (!response.ok) return rejectWithValue("Delete failed");
return userId;
} catch (error) {
return rejectWithValue(error.message);
}
}
);
// Slice
const usersSlice = createSlice({
name: "users",
initialState: usersAdapter.getInitialState({
status: "idle",
error: null
}),
reducers: {
errorCleared(state) {
state.error = null;
state.status = "idle";
}
},
extraReducers: (builder) => {
builder
.addCase(fetchUsers.pending, (state) => {
state.status = "loading";
state.error = null;
})
.addCase(fetchUsers.fulfilled, (state, action) => {
state.status = "succeeded";
usersAdapter.setAll(state, action.payload);
})
.addCase(fetchUsers.rejected, (state, action) => {
state.status = "failed";
state.error = action.payload;
})
.addCase(createUser.fulfilled, usersAdapter.addOne)
.addCase(deleteUser.fulfilled, usersAdapter.removeOne);
}
});
// Selectors
export const usersSelectors = usersAdapter.getSelectors(
(state) => state.users
);
export const { errorCleared } = usersSlice.actions;
export default usersSlice.reducer;
// components/UserManagement.jsx
import { useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import {
fetchUsers,
createUser,
deleteUser,
errorCleared,
usersSelectors
} from "../features/users/usersSlice";
function UserManagement() {
const dispatch = useDispatch();
const users = useSelector(usersSelectors.selectAll);
const status = useSelector((state) => state.users.status);
const error = useSelector((state) => state.users.error);
const [name, setName] = useState("");
const [email, setEmail] = useState("");
useEffect(() => {
const promise = dispatch(fetchUsers());
return () => promise.abort();
}, [dispatch]);
const handleCreate = async (e) => {
e.preventDefault();
await dispatch(createUser({ name, email }));
setName("");
setEmail("");
};
const handleDelete = (userId) => {
if (window.confirm("本当に削除しますか?")) {
dispatch(deleteUser(userId));
}
};
const handleRetry = () => {
dispatch(errorCleared());
dispatch(fetchUsers());
};
if (status === "loading") {
return <div>読み込み中...</div>;
}
if (status === "failed") {
return (
<div>
<p>エラー: {error}</p>
<button onClick={handleRetry}>再試行</button>
</div>
);
}
return (
<div>
<h1>ユーザー管理</h1>
<form onSubmit={handleCreate}>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="名前"
required
/>
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="メールアドレス"
type="email"
required
/>
<button type="submit">追加</button>
</form>
<ul>
{users.map((user) => (
<li key={user.id}>
{user.name} ({user.email})
<button onClick={() => handleDelete(user.id)}>削除</button>
</li>
))}
</ul>
</div>
);
}
export default UserManagement;
まとめ
| 概念 | 説明 |
|---|---|
| ミドルウェア | dispatch とリデューサーの間に割り込む仕組み |
| Redux Thunk | 関数を dispatch できるようにするミドルウェア(RTK に組み込み済み) |
createAsyncThunk |
pending / fulfilled / rejected のライフサイクルを自動管理 |
extraReducers |
外部で定義されたアクション(Thunk など)を処理する |
| ローディングステート | status: "idle" | "loading" | "succeeded" | "failed" |
rejectWithValue |
構造化されたエラー情報を返す |
signal |
AbortController による リクエストのキャンセル |
condition |
重複リクエストの防止 |
練習問題
問題1: 基本的な createAsyncThunk
以下の API エンドポイントに対応する createAsyncThunk を作成してください。
GET /api/todos— すべての Todo を取得POST /api/todos— 新しい Todo を作成(body:{ title, completed })PUT /api/todos/:id— Todo を更新(body:{ title, completed })DELETE /api/todos/:id— Todo を削除
それぞれの Thunk に適切なエラーハンドリング(rejectWithValue)を実装してください。
問題2: ローディングステート管理
問題1で作成した Thunk を使って、以下の要件を満たすスライスを実装してください。
createEntityAdapterを使ってエンティティを管理- 各操作(fetch, create, update, delete)のローディングステートを個別に管理
- エラーメッセージをユーザーに表示するためのセレクター
- エラーのリセット機能
問題3: キャンセルと条件付き実行
以下のシナリオに対応するコードを書いてください。
- 検索フォームで、ユーザーがキー入力するたびに API を呼び出す
- 新しいリクエストが発生したら、前のリクエストをキャンセルする
- 同じ検索語での重複リクエストを防ぐ
- デバウンス(300ms)を実装する
ヒント: useEffect のクリーンアップ関数と promise.abort() を組み合わせてください。