Read OSS

applyMiddleware in 25 Lines: The Middleware Pipeline and Store Enhancement

Advanced

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:

src/createStore.ts#L131-L144

The call pattern is enhancer(createStore)(reducer, preloadedState). An enhancer is a higher-order function that:

  1. Receives createStore as an argument
  2. Returns a new "enhanced" createStore
  3. The enhanced createStore can 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:

src/types/store.ts#L220-L232

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 next and then do something after
  • Dispatch additional actions via the dispatch from 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 dispatch property on middlewareAPI uses a wrapper (action, ...args) => dispatch(action, ...args) instead of just dispatch directly. 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:

  1. store.dispatch(addTodoAsync('Use Redux')) calls the composed dispatch
  2. The spy middleware receives the thunk function as action, calls spyOnMethods(action), then calls next(action)
  3. The thunk middleware receives the function, detects typeof action === 'function', and calls action(dispatch, getState)
  4. Inside the thunk, it eventually calls dispatch(addTodo('Use Redux')) — this goes through the entire chain again (spy → thunk → store.dispatch)
  5. This time the action is a plain object, so thunk calls next(action), which hits the base store.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.