Redux Middleware Explained
How Does Redux Middleware Actually Work?
Whether you're looking to add some customization to your Redux workflow or just looking to understand how Redux works a little better, it's great to know exactly what Redux is doing under the hood with middleware.
I've been going down this rabbit hole trying to write my own Redux middleware so I thought I'd share my findings.
Fortunately for us, Redux turns out to be fairly simple, so let's take a closer look at the Redux source code and try to figure out what's going on.
I'll pull examples from the Redux source code and type definitions, but I'll be stripping it down for simplicity, mostly removing some error-handling and subscription management code (subscriptions aren't particularly relevant for us here). Let's start with the primitives and work our way up.
Examples are based on Redux
4.0.4
export interface Action<T = any> {
type: T
}
Most strictly, an
Action
is an object with a field type
. More usefully, an Action
describes what change should be made to the application state.export type Reducer<S = any, A extends Action = AnyAction> = (
state: S | undefined,
action: A
) => S
A
Reducer
is a function that, given an existing state and an Action
, produces the state that should exist after the Action
has been applied. If an Action
describes what changes should be made, a Reducer
describes how that change should be made.Very simply, it's just an object that provides us a way to get the current application state (
getState()
) and provides a dispatch
function that allows us to send Action
s to our Reducer
s. A simplified interface would be something like this:interface Store<S = any, A extends Action = AnyAction> {
dispatch: Dispatch<A>
getState(): S
}
We create our Redux store using the
createStore
function, which, well, creates our Store
. More importantly, it contains some important variables that will get used by functions like dispatch
.function createStore(reducer, preloadedState, enhancer) {
...
let currentReducer = reducer
let currentState = preloadedState
let currentListeners = []
let nextListeners = currentListeners
let isDispatching = false
...
}
What's important is that
dispatch
and other functions are also created in createStore
, meaning that these variables are closed over, i.e. dispatch
will have access to these variables, and we'll see how it uses them next.I'll be honest, I was a little surprised at just how simple the
dispatch
function is when I first saw it (though I don't really know what I expected). You'll see that is uses those variables defined in createStore
from before. Here's a stripped down version, showing just the meat of it:function dispatch(action) {
try {
isDispatching = true
currentState = currentReducer(currentState, action)
} finally {
isDispatching = false
}
return action
Well, that's about it. It reassigns
currentState
to the new application state the reducer spit out in response to an Action
.Keep in mind, there is only one
Reducer
– don't let the variable name currentReducer
throw you off. It's only "current", because of some functionality Redux provides for dynamically loading reducers that isn't relevant for us here. Speaking of which, how does Redux make all our different reducers act as one?Redux provides the
combineReducers
function, which takes a object-map of sub-reducers and returns a single function, i.e. a Reducer
, that invokes all of them. Here's a simplified version of combineReducers
.function combineReducers(reducers) {
return (state = {}, action) => {
let hasChanged = false
const nextState = {}
for (const reducerKey in reducers) {
const reducer = reducers[reducerKey]
const previousStateForKey = state[reducerKey]
const nextStateForKey = reducer(previousStateForKey, action)
nextState[key] = nextStateForKey
hasChanged = hasChanged || nextStateForKey !== previousStateForKey
}
return hasChanged ? nextState : state
}
}
Again, just like
dispatch
, this is actually pretty simple. We go through all the sub-reducers we have, pass it existing state and the action, and collect all those results into a new state object.Middleware is where this gets a little more complicated (which is why it was important to go over the building blocks!).
Middleware allows us to add all sorts of functionality to Redux between the points when an action is first dispatched to when it's sent to the reducer. It also allows us to enhance the
dispatch
function to add to its capabilities.Let's examine all the relevant code.
export interface MiddlewareAPI<D extends Dispatch = Dispatch, S = any> {
dispatch: D
getState(): S
}
A
MiddlewareAPI
is what gives our middleware access to dispatch
and a way to get the current state. Simple enough.export interface Middleware<
DispatchExt = {},
S = any,
D extends Dispatch = Dispatch
> {
(api: MiddlewareAPI<D, S>): (
next: Dispatch<AnyAction>
) => (action: any) => any
}
This type definition might be a little hard to follow, but notice that the last function in the signature just takes in an action, i.e. the same as
dispatch
.I think the best way to think about it is that every middleware is actually just a
dispatch
that has a handle to the next dispatch in the chain. So really, our middlewares are just custom dispatch
s that handle the action in some way and then pass the action off to the next dispatch. You may need to stew on that for a minute and make sure you understand it.The reason this signature needs to be curried is that the custom
dispatch
we create needs access to the MiddlewareAPI
and a handle to the next middleware/dispatch in our middleware chain. All that happens in applyMiddleware
.export function applyMiddleware(...middlewares: Array<Middleware>): StoreEnhancer
Basic type definition.
export default function applyMiddleware(...middlewares) {
return createStore => (...args) => {
const store = createStore(...args)
let dispatch
const middlewareAPI = {
getState: store.getState,
dispatch: (...args) => dispatch(...args)
}
const chain = middlewares.map(middleware => middleware(middlewareAPI))
dispatch = compose(...chain)(store.dispatch)
return {
...store,
dispatch
}
}
}
I'm assuming you have a baseline understanding of curried functions in JavaScript here I think a helpful way to conceptualize deeply curried functions is as just a single function with multiple 'layers' of parameters. The 'layers' get 'peeled off' when you apply a argument to it, which allows us to share common data between multiple curried functions. In this way, a
Middleware
is a curried function with three layers – an APIMiddleware
, a Dispatch
, and finally an Action
. applyMiddleware
'peels off' two layers of our Middleware
s, creating a single function that becomes our Redux Store's dispatch
.To 'peel off' the first layer, we first create a
MiddlewareAPI
from our newly created store and give all our middlewares access to it with middlewares.map(middleware => middleware(middlewareAPI))
.To 'peel off' the second layer, we chain our
Middleware
s together with the compose
function. compose
serially chains multiple functions together into a single function. It's actually not terribly complicated, but it's easier to understand by example: compose(F, G, H)
turns into (...args) => F(G(H(...args)))
. (Note that in this chain, the parameters that H
accepts become the parameters that our newly created chain accepts.) Finally, we call this composed Middleware
chain with the default dispatch
from our store.With the second layer peeled off, now our
middlewares
are just normal dispatch
s (Action => any
) that have a handle to next
i.e. the next dispatch
in the chain. (That means we have to make sure our Middleware
calls next
at some point, or the dispatch won't make it to the end i.e. our Reducer
!)I'll try my best to illustrate that process the best I can.
const chain = middlewares.map(middleware => middleware(middlewareAPI))
const composedChain = compose(...chain)
dispatch = composedChain(store.dispatch)
Calling our composed chain with
store.dispatch
will make store.dispatch
the final dispatch
in the chain. Here's some pseudocode that outlines how that application would go.H(store.dispatch):
(action: Action) => {
// custom stuff...
store.dispatch(action)
}
G(H):
(action: Action) => {
// custom stuff...
H(action)
}
F(G):
(action: Action) => { // Our new Dispatch!
// custom stuff...
G(action)
}
And now here's what our final
dispatch
will look like. This is, again, pseudocode to illustrate how the data flows, not the definitions of the functions.(action: Action) => {
F(action) ->
// custom stuff
G(action) ->
// custom stuff
H(action) ->
// custom stuff
store.dispatch(action)
}
The final bit of
applyMiddleware
is returning our store
, but with dispatch
overridden to be our custom dispatch
.return {
...store,
dispatch
}
And finally, we've created our Redux store with our middleware applied!
I hope this look into Redux has given you what you need to start tinkering yourself. I did most of this research while figuring out how to create a custom middleware of my own. It's certainly within reach to try it yourself – maybe a logging framework, maybe a network call abstraction layer, maybe something even wilder. Go ahead and tinker with it and see what you can come up with.
Last modified 3yr ago