Skip to main content

🚦 Understanding Middleware

A plain Redux store can only do one thing when you dispatch: run the reducer and update state. Middleware is the escape hatch that lets you slip logic between the dispatch and the reducer — logging, error reporting, async calls, analytics — without ever touching your reducers. It is the single most important concept for understanding how real Redux apps do anything beyond the basics.

Week 6 · Day 3 (Wednesday: Redux Middleware) · Lecture 1

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain what middleware is and where it sits in the dispatch pipeline
  • Read and write the curried store => next => action signature with confidence
  • Describe the roles of store, next, and action in a middleware
  • Register middleware with applyMiddleware and predict the order it runs in
  • Build logging, crash-reporting, and authentication middleware from scratch
  • Explain how applyMiddleware composes the chain internally

Estimated Time: 60 minutes

Practice: Write a logging middleware and trace an action through a three-middleware chain.

In This Lesson

What Is Middleware?

Out of the box, dispatching an action is a straight shot: store.dispatch(action) hands the action to the reducer, the reducer returns new state, and subscribers are notified. There is no room in that flow for a side effect — no place to log, no place to make an API call, no place to reject a bad action. Middleware is Redux's official extension point: it inserts a customizable stage between "an action was dispatched" and "the reducer sees it."

🚦 The Highway Checkpoint Analogy

Picture an action as a car driving down a highway toward the reducer. Middleware are the checkpoints along the road. At each one the car can be inspected (logging), have paperwork added (metadata), be waved through (pass to the next stage), be turned back (block the action), or sent off on an errand first (async side effects). The car only reaches its destination — the reducer, which updates the "city" that is your store — after clearing every checkpoint you set up.

Crucially, middleware keeps your reducers pure. Reducers should be simple functions of (state, action) with no timers, no fetch, no logging. All of that messy, real-world behavior lives in middleware instead — which is exactly why async logic belongs in middleware, not in reducers.

The Dispatch Pipeline

When middleware is installed, every dispatched action flows through the chain in order before reaching the reducer. Each middleware decides whether — and how — to pass the action along.

graph LR A["dispatch(action)"] --> B[Middleware 1
logger] B --> C[Middleware 2
crashReporter] C --> D[Middleware 3
auth] D --> E[Reducer] E --> F[New State] F --> G[Store Notifies Subscribers]

The picture above shows the action's forward journey. But there is a subtlety worth internalizing early: because each middleware calls next(action) and can run code after that call returns, the chain is really a nested set of "before" and "after" phases — like layers of an onion.

Middleware forms nested before/after layers around the reducer logger: before → crashReporter: try { auth: check token → REDUCER runs ← logger: after (log next state)
Every next(action) dives one layer deeper; when the reducer returns, control unwinds back out through each middleware's "after" code.

Written out, the difference between the plain and middleware-enabled flow looks like this:

// Without middleware — a straight shot to the reducer
store.dispatch(action);   // -> reducer(state, action) -> newState

// With middleware — the action passes through each stage first
store.dispatch(action);   // -> logger
                          //   -> crashReporter
                          //     -> auth
                          //       -> reducer(state, action) -> newState

// At each stage, a middleware can:
//   1. Pass the action along untouched      -> next(action)
//   2. Modify the action first              -> next({ ...action, meta })
//   3. Stop the action entirely             -> return; (never call next)
//   4. Dispatch a different action          -> store.dispatch(other)
//   5. Run a side effect (log, fetch, ...)  -> before or after next()

Anatomy: store => next => action

Every Redux middleware has the same triple-arrow shape. It looks cryptic the first time, but it is just three nested functions — a curried function — each capturing one piece of context.

const myMiddleware = store => next => action => {
  // 1. "before" phase — runs on the way IN, before the reducer
  console.log('Dispatching:', action);

  // 2. hand the action to the next middleware (or the reducer)
  const result = next(action);

  // 3. "after" phase — runs on the way OUT, once the reducer has updated state
  console.log('Next state:', store.getState());

  // 4. return whatever next() returned (usually the action itself)
  return result;
};

The three arrows are not decoration — Redux calls them one at a time, at three different moments. Unrolled into plain nested functions, the exact same middleware reads like this:

// Identical behavior, written without arrow-function shorthand
function myMiddleware(store) {
  // Called ONCE at store setup. Captures the store.
  return function wrapDispatch(next) {
    // Called ONCE while building the chain. Captures the "next" link.
    return function handleAction(action) {
      // Called EVERY time an action is dispatched.
      // ...middleware logic...
      return next(action);
    };
  };
}

💡 What each parameter is

  • store — a slimmed-down store, just { getState, dispatch }. Use getState() to read current state and dispatch() to fire brand-new actions.
  • next — the next middleware's handler in the chain (and for the last middleware, the store's real dispatch that reaches the reducer). Calling next(action) passes the baton forward.
  • action — the action currently travelling through the pipeline.

The golden rule: next(action) moves forward one link; store.dispatch(action) starts a brand-new trip through the whole chain. Confusing the two is the #1 way to accidentally create an infinite loop.

Why curry it at all? Currying lets Redux supply each dependency at the exact moment it becomes available. The store exists once the store is created, so applyMiddleware passes it first. The next link only exists once the chain is assembled, so it is passed second. And the action arrives fresh on every dispatch, so it is passed last. Three separate moments, three separate arrows.

Classic Middleware Patterns

Almost every custom middleware you will ever write is a variation on a handful of patterns. Here are the ones you will meet again and again.

1. Logging middleware

The "hello world" of middleware: print the action and the resulting state. Notice how the "before" and "after" phases straddle the next(action) call.

const logger = store => next => action => {
  const started = Date.now();
  console.group(action.type);
  console.log('prev state', store.getState());
  console.log('action', action);

  const result = next(action);        // reducer runs here

  console.log('next state', store.getState());
  console.log(`elapsed: ${Date.now() - started}ms`);
  console.groupEnd();
  return result;
};

Console output for one dispatched ADD_TODO

▼ ADD_TODO
    prev state  { todos: [] }
    action      { type: 'ADD_TODO', payload: 'Buy milk' }
    next state  { todos: ['Buy milk'] }
    elapsed: 0ms

2. Crash-reporter middleware

Wrap next(action) in a try/catch so a reducer that throws does not silently kill your app. Report the error with full context, then re-throw so nothing gets swallowed.

const crashReporter = store => next => action => {
  try {
    return next(action);
  } catch (err) {
    console.error('Caught an exception!', err);
    errorService.log({
      error: err,
      action,
      state: store.getState(),
      timestamp: new Date().toISOString()
    });
    throw err;   // re-throw so the app still knows something broke
  }
};

3. Authentication middleware

Inspect the action, read auth state, and either block it, redirect, or enrich it. This middleware refuses actions flagged requiresAuth when the user is logged out, and attaches the bearer token to API requests.

const authMiddleware = store => next => action => {
  const state = store.getState();

  // Block protected actions when logged out
  if (action.meta?.requiresAuth && !state.auth.isAuthenticated) {
    store.dispatch({ type: 'REDIRECT_TO_LOGIN', payload: { reason: 'Auth required' } });
    return;   // stop the action here — never call next()
  }

  // Enrich API requests with the token
  if (action.type === 'API_REQUEST' && state.auth.token) {
    action = {
      ...action,
      payload: {
        ...action.payload,
        headers: { ...action.payload.headers, Authorization: `Bearer ${state.auth.token}` }
      }
    };
  }

  return next(action);
};

4. Analytics middleware

Fire tracking calls off the side of the pipeline whenever an action carries analytics metadata — reducers stay blissfully unaware.

const analytics = store => next => action => {
  if (action.meta?.analytics) {
    const { eventName, eventData } = action.meta.analytics;
    analyticsService.track(eventName, {
      ...eventData,
      userId: store.getState().user?.id,
      timestamp: Date.now()
    });
  }
  return next(action);
};

// A component dispatches an ordinary action; the metadata rides along:
dispatch({
  type: 'PRODUCT_ADDED_TO_CART',
  payload: { productId: '123', quantity: 1 },
  meta: { analytics: { eventName: 'add_to_cart', eventData: { price: 29.99 } } }
});

Composing & applyMiddleware

You register middleware when you create the store, by passing them to applyMiddleware. Order matters: the action flows through them left to right.

import { createStore, applyMiddleware } from 'redux';

const store = createStore(
  rootReducer,
  applyMiddleware(logger, crashReporter, authMiddleware, analytics)
);
// An action visits logger -> crashReporter -> auth -> analytics -> reducer

✅ Modern setup with Redux Toolkit

In current Redux you rarely call createStore or applyMiddleware by hand. Redux Toolkit's configureStore sets up a sensible middleware chain for you (including the thunk middleware from the next lesson) and lets you extend it:

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

const store = configureStore({
  reducer: rootReducer,
  middleware: (getDefault) => getDefault().concat(logger, analytics)
});

How applyMiddleware works under the hood

It is worth seeing the trick once, because it demystifies the whole system. applyMiddleware gives every middleware the store, collects their next => action => ... handlers, and then composes them into a single super-dispatch.

function applyMiddleware(...middlewares) {
  return (createStore) => (reducer, preloadedState) => {
    const store = createStore(reducer, preloadedState);

    // 1. Give each middleware the store: store => (next => action => ...)
    const chain = middlewares.map(mw => mw(store));

    // 2. Compose the chain so each wraps the next, ending at the real dispatch
    const dispatch = compose(...chain)(store.dispatch);

    return { ...store, dispatch };
  };
}

// compose(f, g, h)(x)  ===  f(g(h(x)))
function compose(...funcs) {
  if (funcs.length === 0) return arg => arg;
  if (funcs.length === 1) return funcs[0];
  return funcs.reduce((a, b) => (...args) => a(b(...args)));
}

// The end result is a single nested dispatch:
// dispatch = logger(crashReporter(auth(analytics(store.dispatch))))

That final line is the entire secret. Each middleware's next is simply the next function inward, and the innermost next is the store's genuine dispatch that reaches the reducer. Currying plus composition — nothing more.

Practice & Quiz

🏋️ Exercise 1: A timing logger

Goal: Write timingLogger, a middleware that logs each action's type and how many milliseconds the reducer took, without changing the action.

const timingLogger = store => next => action => {
    // TODO: record start time, call next(action),
    //       then log `${action.type} took Nms` and return the result
};
💡 Hint

Capture const start = performance.now() before next(action), store its return value, compute the elapsed time after, log it, and return the stored result. Never forget to return what next gave you.

✅ Solution
const timingLogger = store => next => action => {
    const start = performance.now();
    const result = next(action);               // reducer runs
    const ms = (performance.now() - start).toFixed(2);
    console.log(`${action.type} took ${ms}ms`);
    return result;
};

🏋️ Exercise 2: Block empty todos

Goal: Write a validation middleware that stops ADD_TODO actions whose payload.text is empty, dispatching an ADD_TODO_ERROR instead of letting the empty todo reach the reducer.

✅ Solution
const validateTodo = store => next => action => {
    if (action.type === 'ADD_TODO' && !action.payload?.text?.trim()) {
        return next({ type: 'ADD_TODO_ERROR', payload: 'Todo text is required' });
    }
    return next(action);
};

Because we call next with a replacement action, the reducer sees the error action instead of the invalid one — the bad data never reaches state.

🎯 Quick Quiz

Question 1: In store => next => action, what does calling next(action) do?

Question 2: A middleware runs code after its next(action) call returns. When does that code execute?

Question 3: How do you register middleware with a classic Redux store?

Best Practices & Pitfalls

✅ Do

  • Keep each middleware focused on a single responsibility — one for logging, one for auth, and so on
  • Always call next(action) unless you are deliberately blocking the action
  • Return the value of next(action) so the chain and dispatch return values stay intact
  • Read state with the provided store.getState(), never by importing the store
  • Handle errors gracefully and re-throw when the app needs to know

❌ Don't

  • Don't store.dispatch(action) the very same action you received — that restarts the chain and loops forever
  • Don't mutate the incoming action or state; spread into a new object instead
  • Don't run heavy synchronous computation inside a middleware — it blocks every dispatch
  • Don't put business logic order-dependencies across middleware; keep them independent

⚠️ The infinite-loop trap

// ❌ Dispatching the same action re-enters the whole chain forever
const bad = store => next => action => {
  store.dispatch(action);   // BOOM: back to the top of the chain
  return next(action);
};

// ✅ Use next() to continue, dispatch() only for a DIFFERENT action
const good = store => next => action => {
  if (action.type === 'LOGIN_SUCCESS') {
    store.dispatch({ type: 'SHOW_WELCOME' });   // a new, different action
  }
  return next(action);
};

Summary

🎉 Key Takeaways

  • Middleware is the extension point between dispatch and the reducer — it keeps reducers pure while enabling side effects
  • Every middleware has the curried shape store => next => action, called at three different moments
  • next(action) moves forward one link; store.dispatch(action) restarts the whole chain
  • Code before next() is the "before" phase; code after it runs once the reducer has updated state
  • applyMiddleware composes the chain into one nested dispatch: a(b(c(store.dispatch)))

📚 Additional Resources

🚀 What's Next?

You now understand the pipeline that middleware plugs into. The next lesson puts it to work on the single most common real-world need: Redux Thunk for async actions — the tiny middleware that lets action creators return functions so you can fetch data and dispatch when it arrives.

🎉 Great work!

The store => next => action signature will never look cryptic again — and everything Redux does asynchronously is built on top of it.