Learn Redux in 10 DaysDay 7: Middleware & Side Effects
Chapter 7Learn Redux in 10 Days

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 includes redux-thunk and serialization checks. Using concat adds 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 prepend to 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:

  1. Reducers are pure functions β€” handle side effects in middleware
  2. Use createListenerMiddleware for side effects that RTK Query doesn't cover
  3. Implement debouncing with cancelActiveListeners + delay
  4. Forgetting to call next(action) stops the action from reaching the reducer
  5. 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 settings slice to localStorage whenever it changes
  • Apply a 1-second debounce before saving
  • Dispatch a settingsSaved action 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.