Day 7: Middleware & Side Effects
What You'll Learn Today
- What middleware is and its role in Redux
- How the middleware pipeline works
- Writing custom middleware from scratch
- Managing side effects with RTK's
createListenerMiddleware - Conditional execution and cancellation in listeners
- Practical middleware patterns
- Comparing Thunks, Listeners, and Sagas
What Is Middleware?
Middleware intercepts actions between dispatch and the reducer. It lets you run "side effects" β things like logging, error reporting, and async operations β that don't belong in pure reducer functions.
flowchart LR
subgraph Pipeline["Middleware 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
Why Do We Need Middleware?
| Problem | How Middleware Solves It |
|---|---|
| Reducers must be pure functions | Side effects are separated into middleware |
| Logic scattered across components | Common concerns centralized in one place |
| Complex async management | Unified patterns for async flows |
| Difficult debugging | Action flow becomes visible and traceable |
Middleware Structure
A Redux middleware is a curried function with three layers.
const myMiddleware = (storeAPI) => (next) => (action) => {
// Before the reducer runs
console.log('Dispatching:', action);
// Pass the action to the next middleware or reducer
const result = next(action);
// After the reducer runs (state has been updated)
console.log('Next state:', storeAPI.getState());
return result;
};
TypeScript version
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;
};
The Three Parameters
flowchart TB
subgraph StoreAPI["storeAPI"]
GS["getState()"]
DP["dispatch()"]
end
subgraph Next["next"]
NX["Calls the next middleware"]
end
subgraph Action["action"]
AC["The dispatched action"]
end
StoreAPI --> Next --> Action
style StoreAPI fill:#3b82f6,color:#fff
style Next fill:#8b5cf6,color:#fff
style Action fill:#22c55e,color:#fff
| Parameter | Description | Common Use |
|---|---|---|
storeAPI |
Object with getState() and dispatch() |
Read state, dispatch new actions |
next |
Calls the next step in the pipeline | Forward the action |
action |
The dispatched action object | Branch logic based on action type |
Writing Custom Middleware
Logging Middleware
The simplest middleware example. It logs every action and the state before and after.
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 version
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);
};
Error Reporting Middleware
Catches errors thrown by reducers and reports them to an external service.
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 }) {
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 version
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(),
}),
});
}
Registering Middleware
import { configureStore } from '@reduxjs/toolkit';
import todosReducer from './features/todos/todosSlice';
const store = configureStore({
reducer: {
todos: todosReducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(loggerMiddleware, errorReportingMiddleware),
});
TypeScript version
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;
Note:
getDefaultMiddleware()already includesredux-thunkand serialization checks. Usingconcatadds your custom middleware while preserving the defaults.
createListenerMiddleware β RTK's Side Effect Solution
Introduced in RTK 1.8, createListenerMiddleware provides a reactive way to handle side effects that go beyond simple thunks.
Core Concept
flowchart TB
subgraph Listener["Listener Middleware"]
direction TB
M["Action Matcher"]
E["Effect Function"]
M --> E
end
A["dispatch(action)"] --> Listener
Listener --> R["Reducer"]
E -.->|"additional dispatch"| A
style Listener fill:#8b5cf6,color:#fff
style A fill:#3b82f6,color:#fff
style R fill:#22c55e,color:#fff
Listener middleware lets you declaratively say "when this action is dispatched, run this side effect."
Setup
import { createListenerMiddleware } from '@reduxjs/toolkit';
const listenerMiddleware = createListenerMiddleware();
const store = configureStore({
reducer: {
todos: todosReducer,
user: userReducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().prepend(listenerMiddleware.middleware),
});
TypeScript version
import { createListenerMiddleware, addListener } from '@reduxjs/toolkit';
import type { RootState, AppDispatch } from './store';
const listenerMiddleware = createListenerMiddleware();
export const startAppListening = listenerMiddleware.startListening.withTypes<
RootState,
AppDispatch
>();
export const addAppListener = addListener.withTypes<RootState, AppDispatch>();
export { listenerMiddleware };
Tip: Use
prependto place listener middleware at the beginning of the pipeline, so it receives actions before other middleware.
Defining Listeners
Matching by actionCreator
Register a listener for a specific action creator.
import { todosApi } from './features/todos/todosApi';
import { showNotification } from './features/ui/uiSlice';
listenerMiddleware.startListening({
actionCreator: todosApi.endpoints.addTodo.matchFulfilled,
effect: async (action, listenerApi) => {
listenerApi.dispatch(
showNotification({
message: `"${action.payload.title}" was added`,
type: 'success',
})
);
},
});
Matching with matcher
Match multiple actions at once.
import { isAnyOf } from '@reduxjs/toolkit';
import { addTodo, removeTodo, toggleTodo } from './features/todos/todosSlice';
listenerMiddleware.startListening({
matcher: isAnyOf(addTodo, removeTodo, toggleTodo),
effect: async (action, listenerApi) => {
const state = listenerApi.getState();
localStorage.setItem('todos', JSON.stringify(state.todos));
},
});
Matching with predicate
Use flexible conditions for matching.
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 version
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}`);
},
});
Conditions and Cancellation
condition β Wait Until a Condition Is Met
listenerMiddleware.startListening({
actionCreator: userLoggedIn,
effect: async (action, listenerApi) => {
// Wait until the user profile finishes loading
const isLoaded = await listenerApi.condition((action, currentState) => {
return currentState.user.profileLoaded === true;
}, 5000); // Timeout: 5 seconds
if (isLoaded) {
listenerApi.dispatch(fetchUserPreferences());
} else {
console.warn('Profile load timed out');
}
},
});
cancelActiveListeners β Prevent Duplicate Execution
listenerMiddleware.startListening({
actionCreator: searchQueryChanged,
effect: async (action, listenerApi) => {
// Cancel any previous listener execution (debounce)
listenerApi.cancelActiveListeners();
// Wait 300ms
await listenerApi.delay(300);
// If not cancelled, run the search
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: Type "r"
Note right of L: Start timer (300ms)
U->>L: Type "re"
Note right of L: Cancel previous timer<br/>Start new timer
U->>L: Type "red"
Note right of L: Cancel previous timer<br/>Start new timer
Note right of L: 300ms elapsed
L->>A: search("red")
A->>L: Search results
fork β Run Child Tasks
listenerMiddleware.startListening({
actionCreator: startDataSync,
effect: async (action, listenerApi) => {
// Run multiple tasks in parallel
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,
}));
}
},
});
Example: Auto-Save to localStorage
Automatically save state to localStorage whenever it changes, and restore it on app startup.
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) => {
// Debounce: batch rapid changes over 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 version
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;
Example: Analytics Tracking Middleware
Track specific actions and send them to an external analytics service.
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) {
trackEvent(eventName, {
actionType: action.type,
payload: action.payload,
timestamp: Date.now(),
userId: storeAPI.getState().user?.id,
});
}
return result;
};
function trackEvent(name, properties) {
if (typeof window !== 'undefined' && window.gtag) {
window.gtag('event', name, properties);
}
console.log(`[Analytics] ${name}`, properties);
}
TypeScript version
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);
}
Example: Toast Notifications on Errors
Detect failed actions and show toast notifications in the UI.
import { isRejectedWithValue } from '@reduxjs/toolkit';
listenerMiddleware.startListening({
matcher: isRejectedWithValue,
effect: async (action, listenerApi) => {
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 version
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,
})
);
},
});
Comparing Side Effect Approaches
| Feature | Thunk | Listener Middleware | Redux-Saga |
|---|---|---|---|
| Ease of setup | Very easy (built into RTK) | Easy (built into RTK) | Moderate (extra library) |
| Learning curve | Low | Medium | High (generators) |
| Primary use | Async requests | Reactive side effects | Complex async flows |
| Trigger | Dispatched from components | Reacts to actions | Reacts to actions |
| Cancellation | AbortController | Built-in support | Built-in support |
| Testability | Medium | Medium | High |
| Debouncing | Manual implementation | delay() built-in |
debounce() built-in |
| Bundle size | Minimal | Small | Large |
| Recommended (2025) | Standard | Recommended | Legacy only |
When to Use What
flowchart TB
Q1{"What kind of side effect?"}
Q1 -->|"API requests"| A1["Use RTK Query"]
Q1 -->|"One-off async work"| A2["createAsyncThunk"]
Q1 -->|"React to actions"| Q2{"How complex?"}
Q2 -->|"Simple"| A3["Listener Middleware"]
Q2 -->|"Complex flow control"| A4["Listener + fork"]
Q1 -->|"Existing Saga code"| A5["Redux-Saga (consider migrating)"]
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
Summary
Today we explored Redux middleware and side effect management.
| Concept | Description |
|---|---|
| Middleware | Intercepts actions between dispatch and reducer |
| Custom middleware | A curried function: (storeAPI) => (next) => (action) => {} |
| createListenerMiddleware | RTK's built-in reactive side effect tool |
| startListening | API to register action matchers and effect functions |
| cancelActiveListeners | Prevents duplicate execution for debouncing |
| condition | Pauses until a specific state condition is met |
| fork | Runs parallel child tasks within a listener |
Key takeaways:
- Reducers are pure functions β handle side effects in middleware
- Use
createListenerMiddlewarefor side effects that RTK Query doesn't cover - Implement debouncing with
cancelActiveListeners+delay - Forgetting to call
next(action)stops the action from reaching the reducer - For new projects, prefer Listener Middleware over Redux-Saga
Exercises
Exercise 1: Development-Only Logger
Create a logging middleware that only runs in development. It should check process.env.NODE_ENV === 'development' before logging.
Exercise 2: Rate-Limiting Middleware
Write middleware that ignores duplicate dispatches of the same action type within 1 second.
Exercise 3: localStorage Auto-Save
Implement a listener middleware with these requirements:
- Save the
settingsslice to localStorage whenever it changes - Apply a 1-second debounce before saving
- Dispatch a
settingsSavedaction on success - Log errors to the console on failure
Exercise 4: Notification System
Implement a listener that shows different toast notifications when RTK Query mutations succeed or fail. Use the isFulfilled and isRejectedWithValue matchers.