Concept
dispatch only accepts plain objects by default, but real apps need to dispatch things like async operations, log every action, or halt certain actions entirely. Middleware is Redux's mechanism for intercepting every dispatched action before it reaches the reducer, with the ability to inspect, transform, delay, or block it.
const loggerMiddleware = (store) => (next) => (action) => {
console.log("before:", action.type);
const result = next(action); // passes control to the NEXT middleware (or the reducer, if last)
console.log("after:", action.type);
return result;
};That triple-arrow-function shape is the fixed contract every middleware must follow: store => next => action => { ... }. store gives access to getState/dispatch. next is how this middleware hands the action onward. action is the dispatched action itself. Calling next(action) is what actually continues the chain, a middleware that never calls next silently swallows the action, and nothing downstream (including the reducer) ever sees it.
Composing multiple middleware: confirmed onion order
import { createStore, applyMiddleware } from "redux";
const store = createStore(rootReducer, applyMiddleware(loggerMiddleware, auditMiddleware));
store.dispatch({ type: "counter/incremented" });applyMiddleware(loggerMiddleware, auditMiddleware)store.dispatch({ type: 'INC' });
Confirmed by running this exact setup: with applyMiddleware(logger, audit), logger is OUTERMOST, it runs first on the way IN.
Confirmed by running this exact setup and logging each middleware's before/after lines: the execution order is before-logger → before-audit → [reducer runs] → after-audit → after-logger. loggerMiddleware, listed first in applyMiddleware, is the outermost layer, it's the first thing an action passes through on the way in, and the last thing it passes through on the way out. auditMiddleware is nested one layer in. This is exactly an "onion": each middleware wraps around everything listed after it, and unwinding happens in the reverse order of winding in.
What middleware can actually do at each point
Because each middleware controls whether and when it calls next(action), it can:
- Log or inspect the action before it's processed (as shown above).
- Transform the action before passing it on, call
next({ ...action, extra: "data" })instead of the original. - Delay it,
awaitsomething, or usesetTimeout, before callingnext. - Block it entirely, simply never call
next(action), and the action (and the reducer) never sees it. - Dispatch other actions, middleware has access to
store.dispatch, so it can dispatch additional actions of its own in response.
This last capability, dispatching from inside middleware, in response to something that isn't itself a plain synchronous action, is exactly what makes middleware the foundation for handling async logic in Redux, covered concretely in createAsyncThunk (which wraps a small piece of built-in "thunk" middleware) and Saga Effects (a much larger, generator-based middleware for complex async workflows).
Try It
Predict the outcome before checking the solution.
const mwA = (store) => (next) => (action) => {
console.log("A-before");
const result = next(action);
console.log("A-after");
return result;
};
const mwB = (store) => (next) => (action) => {
console.log("B-before");
const result = next(action);
console.log(
What order do the four console.log lines print, given mwB is listed first this time?
Solution
B-before, A-before, A-after, B-after.
The listing order in applyMiddleware determines the onion's layers directly, whichever middleware is listed first is outermost, regardless of its variable name. Here mwB is listed first, so it's outermost: it logs B-before first (before handing off via next), then mwA (nested inside) logs A-before, then the reducer runs, then unwinding happens in reverse: A-after first (innermost unwinds first), then B-after last (outermost unwinds last).
Implement It Yourself
Build a minimal version of applyMiddleware, to see exactly how the onion composition is constructed:
function myApplyMiddleware(...middlewares) {
return (createStoreFn) => (reducer, preloadedState) => {
const store = createStoreFn(reducer, preloadedState);
let dispatch = store.dispatch;
const middlewareAPI = { getState: store.getState, dispatch: (action) => dispatch(action) };
// each middleware(middlewareAPI) returns a `next => action => ...` function
const chain = middlewares.map((middleware) => middleware(middlewareAPI));
// compose right-to-left: chain[0] wraps chain[1] wraps ... wraps the ORIGINAL dispatch
dispatch =
The key line is chain.reduceRight(...): reducing right-to-left means the last middleware in the array gets built first, wrapping the raw store.dispatch, and each middleware before it wraps the previous result. So chain[0] (the first-listed middleware) ends up as the outermost wrapper, exactly matching the confirmed execution order above. This single line is the entire mechanism behind the "first-listed is outermost" rule.
Under the Hood
This builds directly on Store's single dispatch funnel, middleware doesn't add a second path into the store, it wraps the existing dispatch function, layer by layer, before the action ever reaches the reducer covered in Actions & Reducers. The dispatch-from-within-middleware capability shown here is exactly what createAsyncThunk and Saga Effects are built on, both are middleware that intercept a non-plain-object "action" (a thunk function, or a generator) and use store.dispatch internally to eventually produce real plain-object actions the reducer can handle.
Common Mistakes
1. Forgetting to call next(action)
const brokenMiddleware = (store) => (next) => (action) => {
console.log("saw:", action.type);
// ❌ never calls next(action), the action silently dies here
};Without calling next, the action never reaches any middleware after this one, and never reaches the reducer, state simply doesn't update, with no error thrown anywhere.
2. Forgetting to return the result of next(action)
const middleware = (store) => (next) => (action) => {
next(action); // ❌ no return, dispatch(action) itself now resolves to undefined
};dispatch(action) returns whatever the middleware chain's final next(action) call returns (normally the action itself, unless a later middleware/thunk changes that). Skipping the return breaks any code relying on dispatch(...)'s return value.
3. Assuming middleware order doesn't matter
applyMiddleware(thunkMiddleware, loggerMiddleware); // logger CAN'T see thunk functions, they're already resolved
applyMiddleware(loggerMiddleware, thunkMiddleware); // logger sees the RAW thunk BEFORE thunk middleware processes itSince order determines the onion's layering, listing logger before thunk means logger logs the raw dispatched value (which could be a function, not a plain action), while listing thunk first means by the time logger sees anything, thunk middleware has already resolved it into real dispatched actions. This is a genuinely common source of confusing debug output.
Best Practices
- List middleware in the order you want it to observe the RAW dispatched value, logging/debugging middleware usually goes first (outermost) so it sees everything, including non-plain-object values other middleware will later transform.
- Always call and return
next(action)unless intentionally blocking an action, a middleware that forgets either breaks the chain for everything downstream. - Keep middleware side effects narrow and attributable to one concern, a logging middleware should log, an auth-check middleware should check auth; mixing concerns in one middleware makes the onion's execution order harder to reason about.
Performance Tips
- Every middleware in the chain runs on every single dispatched action in the app, not just ones it cares about, keep each middleware's "not relevant to me" path (e.g. a type check that immediately calls
nextand returns) as cheap as possible. - Middleware that does expensive work (deep logging, serialization for persistence) should generally guard itself behind an environment check (e.g. skip verbose logging in production builds) since it's genuinely on the hot path of every dispatch.
