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 |
重要なポイント:
- リデューサーは純粋関数 — 副作用はミドルウェアで扱う
- RTK Query がカバーしない副作用には
createListenerMiddlewareを使う - デバウンスは
cancelActiveListeners+delayで実現する next(action)を呼び忘れるとアクションがリデューサーに届かない- 新規プロジェクトでは Redux-Saga より Listener Middleware を推奨
練習問題
問題 1: ロギングミドルウェア
開発環境でのみ動作するロギングミドルウェアを作成してください。process.env.NODE_ENV === 'development' の場合のみログを出力するようにしましょう。
問題 2: レート制限ミドルウェア
同じアクションタイプが1秒以内に連続でディスパッチされた場合、2回目以降を無視するミドルウェアを書いてください。
問題 3: localStorage 自動保存
以下の要件を満たすリスナーミドルウェアを実装してください:
settingsスライスの状態が変更されたら localStorage に保存する- 保存にはデバウンス(1秒)を適用する
- 保存成功時に
settingsSavedアクションをディスパッチする - 保存失敗時にコンソールにエラーを出力する
問題 4: 通知システム
RTK Query のミューテーションが成功・失敗した時に、それぞれ異なるトースト通知を表示するリスナーを実装してください。isFulfilled と isRejectedWithValue のマッチャーを使いましょう。