10日で覚えるReduxDay 4: 非同期処理
books.chapter 410日で覚えるRedux

Day 4: 非同期処理

今日学ぶこと

  • Redux で非同期処理にミドルウェアが必要な理由
  • Redux Thunk の仕組みと RTK での組み込みサポート
  • createAsyncThunk によるライフサイクル管理(pending / fulfilled / rejected)
  • extraReducersbuilder.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 によるキャンセル

createAsyncThunkAbortController によるキャンセルをサポートしています。

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 を使って、以下の要件を満たすスライスを実装してください。

  1. createEntityAdapter を使ってエンティティを管理
  2. 各操作(fetch, create, update, delete)のローディングステートを個別に管理
  3. エラーメッセージをユーザーに表示するためのセレクター
  4. エラーのリセット機能

問題3: キャンセルと条件付き実行

以下のシナリオに対応するコードを書いてください。

  1. 検索フォームで、ユーザーがキー入力するたびに API を呼び出す
  2. 新しいリクエストが発生したら、前のリクエストをキャンセルする
  3. 同じ検索語での重複リクエストを防ぐ
  4. デバウンス(300ms)を実装する

ヒント: useEffect のクリーンアップ関数と promise.abort() を組み合わせてください。