🚦 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 => actionsignature with confidence - Describe the roles of
store,next, andactionin a middleware - Register middleware with
applyMiddlewareand predict the order it runs in - Build logging, crash-reporting, and authentication middleware from scratch
- Explain how
applyMiddlewarecomposes 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.
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.
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 }. UsegetState()to read current state anddispatch()to fire brand-new actions.next— the next middleware's handler in the chain (and for the last middleware, the store's realdispatchthat reaches the reducer). Callingnext(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 anddispatchreturn 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
actionor 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 applyMiddlewarecomposes the chain into one nesteddispatch:a(b(c(store.dispatch)))
📚 Additional Resources
- Redux — Middleware (concepts & design)
- Redux API — applyMiddleware
- Redux Fundamentals — Store & Middleware
🚀 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.