10日で覚えるReduxDay 7: ミドルウェアと副作用

Day 7: ミドルウェアと副作用

今日学ぶこと

  • ミドルウェアの概念とReduxにおける役割
  • ミドルウェアパイプラインの仕組み
  • カスタムミドルウェアの実装方法
  • RTK の createListenerMiddleware による副作用管理
  • リスナーの条件付き実行とキャンセル
  • 実践的なミドルウェアパターン
  • Thunk・Listener・Saga の比較と使い分け

ミドルウェアとは

ミドルウェアは、dispatch されたアクションがリデューサーに届く前に処理を挟む仕組みです。ログ出力、エラー報告、非同期処理など、リデューサーでは扱えない「副作用(Side Effects)」を実行するために使います。

flowchart LR
    subgraph Pipeline["ミドルウェアパイプライン"]
        direction LR
        MW1["Logger MW"]
        MW2["Error MW"]
        MW3["Thunk MW"]
    end

    D["dispatch(action)"] --> MW1
    MW1 --> MW2
    MW2 --> MW3
    MW3 --> R["Reducer"]
    R --> S["New State"]

    style Pipeline fill:#3b82f6,color:#fff
    style D fill:#8b5cf6,color:#fff
    style R fill:#22c55e,color:#fff
    style S fill:#f59e0b,color:#fff

なぜミドルウェアが必要なのか

問題 ミドルウェアによる解決
リデューサーは純粋関数でなければならない 副作用をミドルウェアに分離できる
コンポーネントにロジックが散在する 共通処理を一箇所に集約できる
非同期処理の管理が複雑 統一的なパターンで管理できる
デバッグが難しい アクションの流れを可視化できる

ミドルウェアの基本構造

Redux ミドルウェアは、3段階のカリー化関数です。

const myMiddleware = (storeAPI) => (next) => (action) => {
  // dispatch前の処理
  console.log('Dispatching:', action);

  // next を呼ぶことで、次のミドルウェアまたはリデューサーに渡す
  const result = next(action);

  // dispatch後の処理(state が更新された後)
  console.log('Next state:', storeAPI.getState());

  return result;
};
TypeScript版
import { Middleware } from '@reduxjs/toolkit';
import type { RootState } from './store';

const myMiddleware: Middleware<{}, RootState> = (storeAPI) => (next) => (action) => {
  console.log('Dispatching:', action);
  const result = next(action);
  console.log('Next state:', storeAPI.getState());
  return result;
};

3つのパラメータの役割

flowchart TB
    subgraph StoreAPI["storeAPI"]
        GS["getState()"]
        DP["dispatch()"]
    end

    subgraph Next["next"]
        NX["次のミドルウェアを呼ぶ関数"]
    end

    subgraph Action["action"]
        AC["ディスパッチされたアクション"]
    end

    StoreAPI --> Next --> Action

    style StoreAPI fill:#3b82f6,color:#fff
    style Next fill:#8b5cf6,color:#fff
    style Action fill:#22c55e,color:#fff
パラメータ 説明 よく使う場面
storeAPI getState()dispatch() を持つオブジェクト 状態の参照、新しいアクションの発行
next パイプラインの次の処理を呼ぶ関数 アクションの転送
action ディスパッチされたアクションオブジェクト アクションの種類に応じた処理分岐

カスタムミドルウェアの実装

ロギングミドルウェア

最もシンプルなミドルウェアの例です。すべてのアクションとその前後の状態をコンソールに出力します。

const loggerMiddleware = (storeAPI) => (next) => (action) => {
  console.group(action.type);
  console.log('Previous state:', storeAPI.getState());
  console.log('Action:', action);

  const result = next(action);

  console.log('Next state:', storeAPI.getState());
  console.groupEnd();

  return result;
};
TypeScript版
import { Middleware, isAction } from '@reduxjs/toolkit';
import type { RootState } from './store';

const loggerMiddleware: Middleware<{}, RootState> = (storeAPI) => (next) => (action) => {
  if (isAction(action)) {
    console.group(action.type);
    console.log('Previous state:', storeAPI.getState());
    console.log('Action:', action);

    const result = next(action);

    console.log('Next state:', storeAPI.getState());
    console.groupEnd();

    return result;
  }
  return next(action);
};

エラー報告ミドルウェア

リデューサーで発生したエラーをキャッチし、外部サービスに報告するミドルウェアです。

const errorReportingMiddleware = (storeAPI) => (next) => (action) => {
  try {
    return next(action);
  } catch (err) {
    console.error('Caught an exception in reducer:', err);

    // エラー報告サービスに送信
    reportError({
      error: err,
      action: action,
      state: storeAPI.getState(),
    });

    throw err;
  }
};

function reportError({ error, action, state }) {
  // Sentry, Datadog などに送信
  fetch('/api/error-report', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      message: error.message,
      stack: error.stack,
      actionType: action.type,
      timestamp: new Date().toISOString(),
    }),
  });
}
TypeScript版
import { Middleware, isAction } from '@reduxjs/toolkit';
import type { RootState } from './store';

interface ErrorReport {
  error: Error;
  action: unknown;
  state: RootState;
}

const errorReportingMiddleware: Middleware<{}, RootState> = (storeAPI) => (next) => (action) => {
  try {
    return next(action);
  } catch (err) {
    console.error('Caught an exception in reducer:', err);

    if (err instanceof Error) {
      reportError({
        error: err,
        action,
        state: storeAPI.getState(),
      });
    }

    throw err;
  }
};

function reportError({ error, action, state }: ErrorReport): void {
  fetch('/api/error-report', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      message: error.message,
      stack: error.stack,
      actionType: isAction(action) ? action.type : 'unknown',
      timestamp: new Date().toISOString(),
    }),
  });
}

ミドルウェアの登録

import { configureStore } from '@reduxjs/toolkit';
import todosReducer from './features/todos/todosSlice';

const store = configureStore({
  reducer: {
    todos: todosReducer,
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(loggerMiddleware, errorReportingMiddleware),
});
TypeScript版
import { configureStore } from '@reduxjs/toolkit';
import todosReducer from './features/todos/todosSlice';

const store = configureStore({
  reducer: {
    todos: todosReducer,
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(loggerMiddleware, errorReportingMiddleware),
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

注意: getDefaultMiddleware() にはすでに redux-thunk やシリアライズチェックなどが含まれています。concat で追加することで、デフォルトのミドルウェアを維持しつつカスタムミドルウェアを追加できます。


createListenerMiddleware — RTK の副作用管理

RTK 1.8 以降で導入された createListenerMiddleware は、サンク(Thunk)ではカバーしきれない反応型の副作用を扱うための仕組みです。

基本概念

flowchart TB
    subgraph Listener["Listener Middleware"]
        direction TB
        M["アクションマッチャー"]
        E["エフェクト関数"]
        M --> E
    end

    A["dispatch(action)"] --> Listener
    Listener --> R["Reducer"]
    E -.->|"追加のdispatch"| A

    style Listener fill:#8b5cf6,color:#fff
    style A fill:#3b82f6,color:#fff
    style R fill:#22c55e,color:#fff

リスナーミドルウェアは「特定のアクションがディスパッチされたら、この副作用を実行する」というパターンを宣言的に記述できます。

セットアップ

import { createListenerMiddleware } from '@reduxjs/toolkit';

const listenerMiddleware = createListenerMiddleware();

// store に登録
const store = configureStore({
  reducer: {
    todos: todosReducer,
    user: userReducer,
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().prepend(listenerMiddleware.middleware),
});
TypeScript版
import { createListenerMiddleware, addListener } from '@reduxjs/toolkit';
import type { RootState, AppDispatch } from './store';

const listenerMiddleware = createListenerMiddleware();

// 型付きの startListening
export const startAppListening = listenerMiddleware.startListening.withTypes<
  RootState,
  AppDispatch
>();

export const addAppListener = addListener.withTypes<RootState, AppDispatch>();

export { listenerMiddleware };

ポイント: prepend を使ってリスナーミドルウェアをパイプラインの先頭に配置します。これにより、他のミドルウェアよりも先にアクションを受け取れます。


リスナーの定義方法

actionCreator でマッチ

特定のアクションクリエイターに対してリスナーを登録します。

import { todosApi } from './features/todos/todosApi';
import { showNotification } from './features/ui/uiSlice';

listenerMiddleware.startListening({
  actionCreator: todosApi.endpoints.addTodo.matchFulfilled,
  effect: async (action, listenerApi) => {
    // Todo追加成功時に通知を表示
    listenerApi.dispatch(
      showNotification({
        message: `"${action.payload.title}" を追加しました`,
        type: 'success',
      })
    );
  },
});

matcher でマッチ

複数のアクションをまとめてマッチさせます。

import { isAnyOf } from '@reduxjs/toolkit';
import { addTodo, removeTodo, toggleTodo } from './features/todos/todosSlice';

listenerMiddleware.startListening({
  matcher: isAnyOf(addTodo, removeTodo, toggleTodo),
  effect: async (action, listenerApi) => {
    // Todoが変更されるたびに localStorage に保存
    const state = listenerApi.getState();
    localStorage.setItem('todos', JSON.stringify(state.todos));
  },
});

predicate でマッチ

より柔軟な条件でマッチさせます。

listenerMiddleware.startListening({
  predicate: (action, currentState, previousState) => {
    // カートの合計金額が変わった時だけ実行
    return currentState.cart.totalAmount !== previousState.cart.totalAmount;
  },
  effect: async (action, listenerApi) => {
    const { cart } = listenerApi.getState();
    console.log(`Cart total changed to: ${cart.totalAmount}`);
  },
});
TypeScript版
startAppListening({
  predicate: (action, currentState, previousState) => {
    return currentState.cart.totalAmount !== previousState.cart.totalAmount;
  },
  effect: async (action, listenerApi) => {
    const { cart } = listenerApi.getState();
    console.log(`Cart total changed to: ${cart.totalAmount}`);
  },
});

条件付き実行とキャンセル

condition — 条件が満たされるまで待機

listenerMiddleware.startListening({
  actionCreator: userLoggedIn,
  effect: async (action, listenerApi) => {
    // ユーザーデータの読み込みが完了するまで待つ
    const isLoaded = await listenerApi.condition((action, currentState) => {
      return currentState.user.profileLoaded === true;
    }, 5000); // タイムアウト: 5秒

    if (isLoaded) {
      // プロフィール読み込み完了後の処理
      listenerApi.dispatch(fetchUserPreferences());
    } else {
      console.warn('Profile load timed out');
    }
  },
});

cancelActiveListeners — 重複実行の防止

listenerMiddleware.startListening({
  actionCreator: searchQueryChanged,
  effect: async (action, listenerApi) => {
    // 以前のリスナーをキャンセル(デバウンスの実現)
    listenerApi.cancelActiveListeners();

    // 300ms 待機
    await listenerApi.delay(300);

    // キャンセルされていなければ検索実行
    const query = action.payload;
    listenerApi.dispatch(searchApi.endpoints.search.initiate(query));
  },
});
sequenceDiagram
    participant U as User
    participant L as Listener
    participant A as API

    U->>L: 入力 "r"
    Note right of L: タイマー開始 (300ms)
    U->>L: 入力 "re"
    Note right of L: 前のタイマーをキャンセル<br/>新しいタイマー開始
    U->>L: 入力 "red"
    Note right of L: 前のタイマーをキャンセル<br/>新しいタイマー開始
    Note right of L: 300ms 経過
    L->>A: search("red")
    A->>L: 検索結果

fork — 子タスクの実行

listenerMiddleware.startListening({
  actionCreator: startDataSync,
  effect: async (action, listenerApi) => {
    // 並列で複数のタスクを実行
    const userTask = listenerApi.fork(async (forkApi) => {
      const response = await fetch('/api/users');
      return response.json();
    });

    const settingsTask = listenerApi.fork(async (forkApi) => {
      const response = await fetch('/api/settings');
      return response.json();
    });

    // 両方の結果を待つ
    const [users, settings] = await Promise.all([
      userTask.result,
      settingsTask.result,
    ]);

    if (users.status === 'ok' && settings.status === 'ok') {
      listenerApi.dispatch(syncCompleted({
        users: users.value,
        settings: settings.value,
      }));
    }
  },
});

実践例: localStorage への自動保存

状態が変更されるたびに自動的に localStorage に保存し、アプリ起動時に復元するパターンです。

import { createSlice, configureStore, createListenerMiddleware } from '@reduxjs/toolkit';

// Slice
const todosSlice = createSlice({
  name: 'todos',
  initialState: {
    items: JSON.parse(localStorage.getItem('todos') || '[]'),
  },
  reducers: {
    addTodo: (state, action) => {
      state.items.push({
        id: Date.now(),
        text: action.payload,
        completed: false,
      });
    },
    toggleTodo: (state, action) => {
      const todo = state.items.find((t) => t.id === action.payload);
      if (todo) {
        todo.completed = !todo.completed;
      }
    },
    removeTodo: (state, action) => {
      state.items = state.items.filter((t) => t.id !== action.payload);
    },
  },
});

export const { addTodo, toggleTodo, removeTodo } = todosSlice.actions;

// Listener Middleware
const listenerMiddleware = createListenerMiddleware();

listenerMiddleware.startListening({
  predicate: (action, currentState, previousState) => {
    return currentState.todos !== previousState.todos;
  },
  effect: async (action, listenerApi) => {
    // デバウンス: 連続変更を500msまとめる
    listenerApi.cancelActiveListeners();
    await listenerApi.delay(500);

    const state = listenerApi.getState();
    try {
      localStorage.setItem('todos', JSON.stringify(state.todos.items));
      console.log('Todos saved to localStorage');
    } catch (err) {
      console.error('Failed to save todos:', err);
    }
  },
});

// Store
const store = configureStore({
  reducer: {
    todos: todosSlice.reducer,
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().prepend(listenerMiddleware.middleware),
});
TypeScript版
import {
  createSlice,
  configureStore,
  createListenerMiddleware,
  PayloadAction,
} from '@reduxjs/toolkit';

interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

interface TodosState {
  items: Todo[];
}

const initialState: TodosState = {
  items: JSON.parse(localStorage.getItem('todos') || '[]'),
};

const todosSlice = createSlice({
  name: 'todos',
  initialState,
  reducers: {
    addTodo: (state, action: PayloadAction<string>) => {
      state.items.push({
        id: Date.now(),
        text: action.payload,
        completed: false,
      });
    },
    toggleTodo: (state, action: PayloadAction<number>) => {
      const todo = state.items.find((t) => t.id === action.payload);
      if (todo) {
        todo.completed = !todo.completed;
      }
    },
    removeTodo: (state, action: PayloadAction<number>) => {
      state.items = state.items.filter((t) => t.id !== action.payload);
    },
  },
});

export const { addTodo, toggleTodo, removeTodo } = todosSlice.actions;

const listenerMiddleware = createListenerMiddleware();

const startAppListening = listenerMiddleware.startListening.withTypes<
  RootState,
  AppDispatch
>();

startAppListening({
  predicate: (_action, currentState, previousState) => {
    return currentState.todos !== previousState.todos;
  },
  effect: async (_action, listenerApi) => {
    listenerApi.cancelActiveListeners();
    await listenerApi.delay(500);

    const state = listenerApi.getState();
    try {
      localStorage.setItem('todos', JSON.stringify(state.todos.items));
    } catch (err) {
      console.error('Failed to save todos:', err);
    }
  },
});

const store = configureStore({
  reducer: {
    todos: todosSlice.reducer,
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().prepend(listenerMiddleware.middleware),
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

実践例: アナリティクス追跡ミドルウェア

特定のアクションを追跡し、外部のアナリティクスサービスに送信します。

const analyticsEvents = {
  'todos/addTodo': 'todo_created',
  'todos/toggleTodo': 'todo_toggled',
  'todos/removeTodo': 'todo_deleted',
  'user/login': 'user_login',
  'user/logout': 'user_logout',
};

const analyticsMiddleware = (storeAPI) => (next) => (action) => {
  const result = next(action);

  const eventName = analyticsEvents[action.type];
  if (eventName) {
    // Google Analytics, Mixpanel などに送信
    trackEvent(eventName, {
      actionType: action.type,
      payload: action.payload,
      timestamp: Date.now(),
      userId: storeAPI.getState().user?.id,
    });
  }

  return result;
};

function trackEvent(name, properties) {
  // 実際のアナリティクスSDKを呼び出す
  if (typeof window !== 'undefined' && window.gtag) {
    window.gtag('event', name, properties);
  }
  console.log(`[Analytics] ${name}`, properties);
}
TypeScript版
import { Middleware, isAction } from '@reduxjs/toolkit';
import type { RootState } from './store';

const analyticsEvents: Record<string, string> = {
  'todos/addTodo': 'todo_created',
  'todos/toggleTodo': 'todo_toggled',
  'todos/removeTodo': 'todo_deleted',
  'user/login': 'user_login',
  'user/logout': 'user_logout',
};

interface TrackEventProperties {
  actionType: string;
  payload: unknown;
  timestamp: number;
  userId?: string;
}

const analyticsMiddleware: Middleware<{}, RootState> = (storeAPI) => (next) => (action) => {
  const result = next(action);

  if (isAction(action)) {
    const eventName = analyticsEvents[action.type];
    if (eventName) {
      trackEvent(eventName, {
        actionType: action.type,
        payload: (action as { payload?: unknown }).payload,
        timestamp: Date.now(),
        userId: storeAPI.getState().user?.id,
      });
    }
  }

  return result;
};

function trackEvent(name: string, properties: TrackEventProperties): void {
  if (typeof window !== 'undefined' && (window as any).gtag) {
    (window as any).gtag('event', name, properties);
  }
  console.log(`[Analytics] ${name}`, properties);
}

実践例: エラー時のトースト通知

失敗したアクションを検知して、UIにトースト通知を表示します。

import { isRejectedWithValue } from '@reduxjs/toolkit';

listenerMiddleware.startListening({
  matcher: isRejectedWithValue,
  effect: async (action, listenerApi) => {
    // RTK Query のエラーレスポンスからメッセージを取得
    const errorMessage =
      action.payload?.data?.message ||
      action.error?.message ||
      'An error occurred';

    listenerApi.dispatch(
      showToast({
        message: errorMessage,
        type: 'error',
        duration: 5000,
      })
    );

    // エラーログも送信
    console.error(`Action ${action.type} failed:`, action.payload);
  },
});
TypeScript版
import { isRejectedWithValue } from '@reduxjs/toolkit';
import { showToast } from './features/ui/uiSlice';

startAppListening({
  matcher: isRejectedWithValue,
  effect: async (action, listenerApi) => {
    const payload = action.payload as { data?: { message?: string } } | undefined;
    const errorMessage =
      payload?.data?.message ||
      action.error?.message ||
      'An error occurred';

    listenerApi.dispatch(
      showToast({
        message: errorMessage,
        type: 'error',
        duration: 5000,
      })
    );
  },
});

副作用管理アプローチの比較

特徴 Thunk Listener Middleware Redux-Saga
導入の容易さ とても簡単(RTKに組み込み) 簡単(RTKに組み込み) やや複雑(追加ライブラリ)
学習コスト 低い 中程度 高い(ジェネレータ)
主な用途 非同期リクエスト リアクティブな副作用 複雑な非同期フロー
起動タイミング コンポーネントからdispatch アクションに反応 アクションに反応
キャンセル AbortController 組み込みサポート 組み込みサポート
テスト容易性 中程度 中程度 高い
デバウンス 自分で実装 delay() で簡単 debounce() で簡単
バンドルサイズ 最小 小さい 大きい
推奨度(2025年) 標準 推奨 レガシーのみ

いつ何を使うべきか

flowchart TB
    Q1{"副作用の種類は?"}
    Q1 -->|"APIリクエスト"| A1["RTK Query を使う"]
    Q1 -->|"単発の非同期処理"| A2["createAsyncThunk"]
    Q1 -->|"アクションに反応する処理"| Q2{"複雑さは?"}
    Q2 -->|"シンプル"| A3["Listener Middleware"]
    Q2 -->|"複雑なフロー制御"| A4["Listener + fork"]
    Q1 -->|"既存のSagaコード"| A5["Redux-Saga(移行検討)"]

    style Q1 fill:#3b82f6,color:#fff
    style Q2 fill:#8b5cf6,color:#fff
    style A1 fill:#22c55e,color:#fff
    style A2 fill:#22c55e,color:#fff
    style A3 fill:#22c55e,color:#fff
    style A4 fill:#f59e0b,color:#fff
    style A5 fill:#ef4444,color:#fff

まとめ

今日は、Reduxのミドルウェアと副作用管理について学びました。

概念 説明
ミドルウェア dispatch からリデューサーへのパイプライン上で処理を挟む仕組み
カスタムミドルウェア (storeAPI) => (next) => (action) => {} のカリー化関数
createListenerMiddleware RTK組み込みのリアクティブ副作用管理ツール
startListening アクションマッチャーとエフェクト関数を登録するAPI
cancelActiveListeners デバウンスなどの重複実行防止に使用
condition 特定の状態になるまで待機するAPI
fork リスナー内で並列タスクを実行するAPI

重要なポイント:

  1. リデューサーは純粋関数 — 副作用はミドルウェアで扱う
  2. RTK Query がカバーしない副作用には createListenerMiddleware を使う
  3. デバウンスは cancelActiveListeners + delay で実現する
  4. next(action) を呼び忘れるとアクションがリデューサーに届かない
  5. 新規プロジェクトでは Redux-Saga より Listener Middleware を推奨

練習問題

問題 1: ロギングミドルウェア

開発環境でのみ動作するロギングミドルウェアを作成してください。process.env.NODE_ENV === 'development' の場合のみログを出力するようにしましょう。

問題 2: レート制限ミドルウェア

同じアクションタイプが1秒以内に連続でディスパッチされた場合、2回目以降を無視するミドルウェアを書いてください。

問題 3: localStorage 自動保存

以下の要件を満たすリスナーミドルウェアを実装してください:

  • settings スライスの状態が変更されたら localStorage に保存する
  • 保存にはデバウンス(1秒)を適用する
  • 保存成功時に settingsSaved アクションをディスパッチする
  • 保存失敗時にコンソールにエラーを出力する

問題 4: 通知システム

RTK Query のミューテーションが成功・失敗した時に、それぞれ異なるトースト通知を表示するリスナーを実装してください。isFulfilledisRejectedWithValue のマッチャーを使いましょう。