applyMiddleware in 25 Lines: The Middleware Pipeline and Store Enhancement
Prerequisites
- ›Articles 1-3
- ›Higher-order functions and closures
- ›Familiarity with middleware concepts (Express, Koa, etc.)
applyMiddleware in 25 Lines: The Middleware Pipeline and Store Enhancement
We've built up a solid understanding of Redux's foundation: closure-based stores, the dispatch cycle, reducer composition, and the compose utility. Now we arrive at the mechanism that transformed Redux from a simple state container into an extensible platform: applyMiddleware.
The entire implementation is 25 lines of runtime code. Those 25 lines contain one of the most elegant closure tricks in JavaScript open source, and understanding them reveals why Redux middleware became the dominant pattern for side effects in React applications.
The StoreEnhancer Pattern
Before diving into applyMiddleware, we need to understand the abstraction it implements: StoreEnhancer. As we saw in Article 2, when createStore receives an enhancer, it short-circuits:
The call pattern is enhancer(createStore)(reducer, preloadedState). An enhancer is a higher-order function that:
- Receives
createStoreas an argument - Returns a new "enhanced"
createStore - The enhanced
createStorecan call the original, modify the resulting store, and return it
flowchart TD
A["enhancer(createStore)"] --> B["enhancedCreateStore"]
B --> C["enhancedCreateStore(reducer, preloadedState)"]
C --> D["Calls original createStore internally"]
D --> E["Gets base store"]
E --> F["Modifies/wraps store methods"]
F --> G["Returns enhanced store"]
The type definition makes the composition explicit:
StoreEnhancer<Ext, StateExt> is generic over two type parameters: Ext (methods/properties added to the store) and StateExt (properties added to the state). When enhancers are composed, these types accumulate through intersection: NextExt & Ext, NextStateExt & StateExt.
applyMiddleware: Line-by-Line
Here is the complete runtime implementation:
src/applyMiddleware.ts#L53-L77
Let me walk through each step:
sequenceDiagram
participant App
participant applyMiddleware
participant createStore
participant Middleware A
participant Middleware B
participant compose
App->>applyMiddleware: applyMiddleware(mwA, mwB)
Note over applyMiddleware: Returns a StoreEnhancer
applyMiddleware->>createStore: createStore(reducer, preloadedState)
createStore-->>applyMiddleware: base store
Note over applyMiddleware: Create throwing dispatch<br/>(safety during setup)
applyMiddleware->>Middleware A: mwA({ getState, dispatch })
Middleware A-->>applyMiddleware: chainA (next => action => ...)
applyMiddleware->>Middleware B: mwB({ getState, dispatch })
Middleware B-->>applyMiddleware: chainB (next => action => ...)
applyMiddleware->>compose: compose(chainA, chainB)(store.dispatch)
compose-->>applyMiddleware: composed dispatch
Note over applyMiddleware: Reassign dispatch variable!
applyMiddleware-->>App: { ...store, dispatch: composedDispatch }
Step 1: Create the base store (line 57). applyMiddleware calls the original createStore with no enhancer — it is the enhancer.
Step 2: Create a throwing dispatch (lines 58-63). During middleware setup, dispatching would be unsafe because the middleware chain isn't assembled yet. So dispatch is initially a function that throws. This prevents bugs like the one tested here:
test/applyMiddleware.spec.ts#L9-L20
Step 3: Build the middlewareAPI (lines 65-68). Each middleware receives { getState, dispatch }. Critically, dispatch here is not the throwing placeholder — it's a wrapper that calls whatever dispatch currently points to.
Step 4: Initialize each middleware (line 69). Calling middleware(middlewareAPI) with the API produces a function of shape next => action => ....
Step 5: Compose the chain (line 70). compose(...chain)(store.dispatch) threads the middleware together right-to-left, with the base store.dispatch as the innermost next.
Step 6: Reassign dispatch (line 70). The let dispatch variable is now reassigned to the composed chain.
Step 7: Return enhanced store (lines 72-75). Spread the base store and override dispatch with the composed version.
The Middleware Signature: store => next => action
Every Redux middleware is a three-layer curried function. Let's demystify each layer using the thunk middleware from the test helpers:
test/helpers/middleware.ts#L1-L9
flowchart TD
subgraph "Layer 1: Setup"
A["({ dispatch, getState }) =>"]
end
subgraph "Layer 2: Chain building"
B["(next) =>"]
end
subgraph "Layer 3: Action handling"
C["(action) => ..."]
end
A --> B --> C
D["middlewareAPI"] --> A
E["next middleware's<br/>action handler<br/>(or store.dispatch)"] --> B
F["Dispatched action"] --> C
Layer 1 ({ dispatch, getState } =>) is called once during applyMiddleware. The middleware captures the API in its closure.
Layer 2 (next =>) is called once during compose. next is the downstream dispatch — the next middleware in the chain, or the raw store.dispatch for the last middleware.
Layer 3 (action =>) is called on every dispatch. This is the "hot" function that processes actions.
The "onion model" means actions flow inward through the layers and back out:
flowchart LR
A["Action dispatched"] --> B["Middleware A<br/>(outer)"]
B --> C["Middleware B<br/>(inner)"]
C --> D["store.dispatch<br/>(base)"]
D --> E["Reducer"]
E --> F["New state"]
Each middleware can:
- Modify the action before passing to
next - Intercept the action entirely (don't call
next) - Call
nextand then do something after - Dispatch additional actions via the
dispatchfrom Layer 1
The Closure Trick: Recursive Dispatch
Here's the subtle genius of applyMiddleware. Look at lines 65-68 again:
const middlewareAPI: MiddlewareAPI = {
getState: store.getState,
dispatch: (action, ...args) => dispatch(action, ...args)
}
dispatch here is not the throwing placeholder. It's a wrapper function that calls dispatch — the let variable. At setup time, that variable points to the throwing function. But by the time any middleware actually calls middlewareAPI.dispatch, line 70 has already reassigned dispatch to the composed chain.
sequenceDiagram
participant Thunk
participant middlewareAPI.dispatch
participant dispatch variable
participant Full Chain
Note over dispatch variable: Initially: throwing fn
Note over dispatch variable: After compose: full chain
Thunk->>middlewareAPI.dispatch: dispatch(someAction)
middlewareAPI.dispatch->>dispatch variable: dispatch(someAction)
dispatch variable->>Full Chain: Goes through ALL middleware
Full Chain->>Thunk: Result
This means when a thunk does dispatch(anotherAction), that action goes through the entire middleware chain — not just the remaining downstream middleware. This is what enables recursive dispatch patterns: a thunk can dispatch another thunk, which dispatches a plain action, and they all flow through every middleware.
If middlewareAPI.dispatch had captured the base store.dispatch directly (or even the composed dispatch at a fixed point), thunks dispatching thunks would bypass the middleware chain. The mutable let variable + closure is what makes it work.
Tip: This closure trick is the reason the
dispatchproperty onmiddlewareAPIuses a wrapper(action, ...args) => dispatch(action, ...args)instead of justdispatchdirectly. A direct reference would capture the current value of the variable; the wrapper defers the lookup to call time, always reading the latest value.
Working Example: Thunk Middleware
Let's trace a complete flow. Consider this test scenario:
test/applyMiddleware.spec.ts#L47-L65
The store is created with two middleware: a spy and the thunk middleware. When addTodoAsync is dispatched, it returns a function (a "thunk"). Here's the flow:
store.dispatch(addTodoAsync('Use Redux'))calls the composed dispatch- The spy middleware receives the thunk function as
action, callsspyOnMethods(action), then callsnext(action) - The thunk middleware receives the function, detects
typeof action === 'function', and callsaction(dispatch, getState) - Inside the thunk, it eventually calls
dispatch(addTodo('Use Redux'))— this goes through the entire chain again (spy → thunk → store.dispatch) - This time the action is a plain object, so thunk calls
next(action), which hits the basestore.dispatch
The spy is called twice (once for the thunk, once for the plain action), proving that recursive dispatch goes through the full chain.
The test at lines 119-151 goes further, demonstrating that extra arguments to dispatch are preserved through the chain — an important detail for more exotic middleware patterns.
What's Next
We've seen how 25 lines of code create one of the most powerful extension mechanisms in the JavaScript ecosystem. The key ingredients are closures (the mutable dispatch variable), higher-order functions (the three-layer middleware signature), and function composition (the compose call that threads the chain together).
In the next article, we'll shift from runtime behavior to the type system. Redux's TypeScript types are surprisingly sophisticated — conditional types that infer state shapes from reducer maps, the UnknownIfNonSpecific trick, and type-level tests that validate inference without running code.