A good way to learn how things work is to build it from scratch. Most of the time we are using a Javascript library as a black box. We only care about how to use the public APIs they provide without caring much about how it works. This tutorial will focus on unveiling the magic behind Redux, and walk you through how to write it from scratch. You might find it useful in the following aspects:
- Better understand how Redux works under the hood.
- Try to think from the author’s perspective. Maybe next time you will come up with your own library!
- Learn some new patterns to write code you have never done before.
If you don’t know what is Redux or never use it before, there are tons of tutorials you can learn. This tutorial assumes you already know basic knowledge of Redux.
Primary APIs
Before starting to write Redux, we need to figure out where to start coding! Let’s first summarize what we need to know to build Redux:
- Core concept of Redux is a predictable state container for Javascript apps.
- Store API: a store holds the whole state tree.
createStore(reducer, state, enhancer)
: create a Redux storegetState()
: get the store statedispatch(action)
: update the store state based on a new actionsubscribe(listener)
: subscribe to the store update by adding a change listenerreplaceReducer(nextReducer)
: an advanced API which we put aside in this tutorial
3. combineReducers(reducers object)
: combine multiple reducers into one
4. applyMiddleware(middlewares)
: return an enhancer for createStore
to use
Apparently Redux is centered around the store
. It makes sense to start building store
APIs.
Store APIs
createStore
We know that createStore
accepts reducer
, state
and enhancer
as inputs, and output a store
object. So we can first build a skeleton createStore
:
function createStore(reducer, initialState, enhancer) { const store = {};
const state = initialState;
const listeners = []; store.getState = () => state;
store.dispatch = (action) => {};
store.subscribe = (listener) => listeners.push(listener); return store;
}
Here we do not use store.state
to save state. It makes sure the only way to get state
from store
is through the public API getState
.
Dispatch
What dispatch
does is to use reducer
to get new state
and notify each listener the state change. So dispatch
can look like this:
store.dispatch = (action) => {
state = reducer(state, action);
listeners.forEach(listener => listener());
};
Subscribe
The current subscribe
method allows us to add listener to the store
. But it is impossible to remove the listener. We can define a unsubscribe
method to achieve this:
store.unsubscribe = (listener) => {
const index = listeners.indexOf(listener);
if (index >= 0) {
listeners.splice(index, 1);
}
};
The Redux author Dan Abramov has a smart trick here. Instead of defining a new method, he makes unsubscribe
method as the return value of subscribe
.
store.subscribe = (listener) => {
listeners.push(listener);
return () => {
const index = listeners.indexOf(listener);
listeners.splice(index, 1);
};
}
Note that we don’t even need to check if listener
exists in the listeners
array because listener
must already be pushed into listeners
within the closure.
combineReducers
When an app is getting larger, the reducer
function can be very long. It is a good practice to split the main reducer
into many reducers
. Each smaller reducer
only handles a sub-tree of the state. Once each reducer
completes its job and gets a new sub-state, we can compose all the sub-states and builds the entire new state tree. The combineReducers
is just a utility function to save time for some boilerplate code. Its role is to replace the following snippet:
export default function todoApp(state = {}, action) {
return {
visibilityFilter: visibilityFilter(state.visibilityFilter, action),
todos: todos(state.todos, action)
}
}
with:
const todoApp = combineReducers({
visibilityFilter,
todos
})
The input of combineReducers
is a plain object whose keys are the keys of state, and values are the name of reducers
. The output is the complete reducer
method. It should be easy to first set up the skeleton code:
function combineReducers(obj) {
return (state = {}, action) => {}
}
Then, we just need to loop through each key of obj
and assign each key the corresponding reducer
.
function combineReducers(obj) {
return (state = {}, action) => {
const newState = {};
for (let key in obj) {
newState[key] = obj[key](state[key], action);
}
return newState;
}
}
The fun part finally comes! You might think Redux is really easy to understand and implement so far. But this section may change your mind. If you really understand how middleware works and can implement applyMiddleware
, as a reward, you will have a much deeper understanding of the Redux ecosystem and why it is so easy to get extended.
Middleware provides a third-party extension point between dispatching an action, and the moment it reaches the reducer. People use Redux middleware for logging, crash reporting, talking to an asynchronous API, routing, and more.
Let’s start with a very simple logging middleware. It can log each dispatched action along with the state computed after it. How to achieve that?
The straightforward idea is to modify the dispatch
method as below:
store.dispatch = (action) => {
console.log('action', action);
state = reducer(state, action);
console.log('state', state);
listeners.forEach(listener => listener());
};
It works, but adding irrelevant logging code into dispatch
is a bad idea. We need a way to extract it from the core dispatch
method. The second idea may look better which defines a wrapped dispatch
method:
function loggingDispatch(store, action) {
console.log('action', action);
store.dispatch(action);
console.log('state', store.getState());
}
The wrapped method decorates dispatch
by adding extra logging logic. But it is not convenient to define a new specific dispatch
each time we need a new middleware. Let’s make another middleware filterDeleteDispatch
to blacklist DELETE
action:
function filterDeleteDispatch(store, action) {
if (action.type === 'DELETE') return;
store.dispatch(action);
}
We can manually merge those two middlewares into one by defining a new middleware loggingAndFilterDeleteDispatch
. It is quite inflexible because user may want different combinations of different middlewares. In order to chain middlewares together, the input and output of each middleware should be the same type.
store.dispatch --> Middleware 1 --> dispatch v1 --> Middleware 2 --> dispatch v2
The whole system is like an onion. store.dispatch
is the core of the onion. We then keep padding with different middlewares. So in Redux, the input and output of each middleware are dispatch
like functions:
function middleware(dispatch) {
return function newDispatch(action) {
dispatch(action);
}
}
Let’s write our logging middleware based on the template above:
function logger(dispatch) {
return function newDispatch(action) {
console.log('action', action);
dispatch(action);
console.log('state', store.getState());
}
}
Wait?! Do we miss something in the code above? Where can we access the context store
? The code above is the expected logging middleware we want to have. The trick is: if we know how the method looks like but it misses context, we can build a factory method to generate this method. In our case, we can build a createLoggerMiddleware
method to generate this logger middleware.
function createLoggerMiddleware(store) {
return function logger(dispatch) {
return function newDispatch(action) {
console.log('action', action);
dispatch(action);
console.log('state', store.getState());
}
}
}
It looks so simple, right? What we just did is to create a wrapper on top of function logger
with store
passed in. Let’s do the same thing for filterDelete middleware as well:
function createFilterDeleteMiddleware(store) {
return function filterDelete(dispatch) {
return function newDispatch(action) {
if (action === 'DELETE') return;
dispatch(action);
}
}
}
Suppose we want to wrap store.dispatch
with logger middleware first and then filterDelete middleware, we can do the following:
const logger = createLoggerMiddleware(store);
const filterDelete = createFilterDeleteMiddleware(store);
const newDispatch = filterDelete(logger(store.dispatch));
Apparently newDispatch
contains the logic of both middlewares as well as the original store.dispatch
. Is there a way to make the above code more generic? We can define a function decorateDispatch
which takes store
and a list of middleware factories, and then output a decorated dispatch
.
function decorateDispatch(store, middlewareFactories) {
let dispatch = store.dispatch;
middlewareFactories.forEach(factory => {
dispatch = factory(store)(dispatch);
});
return dispatch;
}
Redux user should never worry about decorating dispatch
. It needs to be done in the store
initialization stage createStore
based on the list of middlewares. So we can modify createStore
so that it can take middleware factories as input.
function createStore(reducer, initialState, middlewareFactories=[]) {
const store = {};
const state = initialState;
const listeners = []; store.getState = () => state;
store.dispatch = (action) => {
state = reducer(state, action);
listeners.forEach(listener => listener());
};
store.subscribe = (listener) => listeners.push(listener);
return store;
}; store.dispatch = decorateDispatch(store, middlewareFactories);
return store;
}
Extras
createStore API
The Redux author Dan Abramov defines a slightly different API for createStore
. The major reason is to ensure store can only be decorated once.
function createStore(reducer, initialState, enhancer) {
if (typeof enhancer === 'function') {
return enhancer(createStore)(reducer, initialState);
}
...
}
enhancer
takes createStore
and outputs a decorated createStore
. The idea is very similar to how the middleware is designed. In order to implement it, we can first build a skeleton code:
function enhancer(createStore) {
return function newCreateStore(reducer, initialState) {
createStore(reducer, initialState);
}
}
Again, the enhancer
has no idea about middlewares. Remember the trick we mentioned before? We can reuse it here and create a enhancer factory that injects the middlewares as context:
function enhancerFactory(...middlewares) {
return function enhancer(createStore) {
return function newCreateStore(reducer, initialState) {
createStore(reducer, initialState);
}
}
}
Don’t feel surprised. enhanceFactory
is just the same as the Redux API applyMiddleware
! Let’s finish the rest of the code in applyMiddleware
:
function applyMiddleware(...middlewareFactories) {
return function enhancer(createStore) {
return function newCreateStore(reducer, initialState) {
const store = createStore(reducer, initialState);
let dispatch = store.dispatch;
middlewareFactories.forEach(factory => {
dispatch = factory(store)(dispatch);
});
store.dispatch = dispatch;
return store;
}
}
}
Here we borrowed the logic from decorateDispatch
to decorate dispatch
. The implementation in Redux is slightly different but the core idea is the same.
Curry functions
Curry functions are usually hard to understand. But we already have curry functions in our code above, have you noticed?
function createLoggerMiddleware(store) {
return function logger(dispatch) {
return function newDispatch(action) {
console.log('action', action);
dispatch(action);
console.log('state', store.getState());
}
}
}
is the same as
const createLoggerMiddleware = store => dispatch => action => {
console.log('action', action);
dispatch(action);
console.log('state', store.getState());
}
applyMiddleware
can also be written in this style:
const applyMiddleware = (...middlewareFactories) => createStore => (...args) => {
const store = createStore(...args);
let dispatch = store.dispatch;
middlewareFactories.forEach(factory => {
dispatch = factory(store)(dispatch);
});
store.dispatch = dispatch;
return store;
}
Redux-thunk middleware
With the knowledge about how middleware works, it is pretty easy to build the redux-thunk middleware which can handle the async request. From the logger middleware example, we can easily write something like this:
const createThunkMiddleware = store => dispatch => action => {
if (typeof action === 'function') {
return action(dispatch, store.getState());
}
return dispatch(action);
}
Wrap up
This tutorial is a walkthrough of building the main Redux APIs from scratch. Some implementation details are not the same as official Redux library, but the core idea is the same. Redux also includes a lot of error handling which I think is not important in this tutorial. The middleware section may not make sense on the first reading. That’s okay, it will make more sense with time.
If you found this article helpful, please tap the 👏.