Redux is a state management library used by millions. It provides a simple way to manage your global state, subscribe to it and update it safely. Most of you know what it is, but not many have an idea of how it works under the hood. In this article, I will show you how this project is structured, how it was written and the philosophy behind it. By the end of the reading, you can expect to know how Redux was designed and be able to read its source code freely.
Before you go on reading it, a fair warning: Redux is written in TypeScript and I will be assuming TS knowledge for the rest of the article. I also would not explain how to use Redux, so if you have never heard of it, I suggest reading the docs first. You do not need to know React though, as I am dealing purely with Redux. And, lastly, I am not affiliated with the Redux team, nor am I a core contributor. I have opened a few PRs, but this article is purely my subjective view that you should not treat as official.
With the paperwork done, let us dive into the source code. If you wish to follow along on your computer, I suggest cloning/forking the official Redux repo.
Project structure
Before exploring the source code itself, let me show you how this project is structured. As with any Node package, it has a package.json
file:
I have omitted a few sections (keywords
and devDependencies
) as they are not important right now. So what can we learn by just looking at package.json
? Well, the name of the package is redux
(duh), it is open-source under MIT license, its only dependency is @babel/runtime
and it is being tested with jest
. By examining the scripts section, we see that the source code is formatted with prettier, linted with eslint, compiled with typescript and packaged with rollup. The comprehensive script that performs all the checks and tests is the prepare
script. Feel free to run npm run prepare
in project root (if you have cloned the repo) and observe the output. If the developers did not screw anything up, it should perform the following tasks:
- Clean the output directories (
/dist /types /es /coverage /lib
) - Statically check TypeScript types
- Check formatting with Prettier
- Check code quality with ESlint
- Build a production version with Rollup
- Test project with Jest
You might be confused why do we need to build redux into a production version. After all, it is imported into projects that are themselves compiled into production versions later on. This is required for redux to work across runtimes (different versions of Node, browsers, other JS runtimes) and different environments within the JS runtime (testing, for example). This is a good practice and I encourage you to use tools like rollup
or bob
while developing your own libraries as well.
The main
, unpkg
and module
properties specify entry points for different runtimes. For conventional Node, main
is used. unpkg
is for the unpkg packager and module
is for never environments that support ES6 modules. This is all possible due to rollup
. Lastly, the types
property points to the TypeScript types definition to provide linting and type checking in any project that imports Redux.
That is everything we need from package.json at the moment. Our next stop is config files. These are pretty self-explanatory, but I am going to go over them just in case:
.babelrc
– The Babel transpiler config file which specifies how the code should be transpiled and what level of compatibility is desired.editorconfig
– the common config file that is understood by most major code editors and IDEs. Used to bring consistency across development machines.eslintignore
,.eslintrc.js
– config files for ESLint. Specify the rules used and files ignored.gitbook.yaml
– config for GitBook, a markdown documentation solution (I am not sure if it is actually used).prettierrc
– config for Prettier, specifies rules for code formatting.travis.yml
– config for TravisCI, sets the environment and tells Travis what script to run (it’s theprepare
script)netlify.yml
– config for Netlify which is used to deploy the website with the docsrollup.config.js
– config for rollup which specifies the targets and directory mappingstsconfig.json
– config for the TypeScript transpiler
The last thing I am going to talk about in this section is the directory structure. Here is a list of directories in the project root and their purposes:
build, dist, es, lib, types
– the production version outputs that I talked about earlierdocs
– markdown-formatted API docsexamples
– a collection of real-world usage examples. Each one is a separate NPM package.website
– a Docasaurus-powered documentation website. Provides the website itself and grabs docs from /docs folder. A separate NPM package as wellsrc
– the source code (finally)
Now you should have an idea how a library as popular and stable as Redux is structured and built. Now I will talk about how it plays with the CI/CD.
Project infrastructure
The project is hosted on Github, within the ReduxJS organization. It includes other companion libraries, such as react-redux, reselect and redux-toolkit.
The CI solution in use is TravisCI, and you can monitor it here. It fires on every pull request and runs the prepare
script, which I introduced earlier. This makes sure that tests are passing at all times, the code in the repo is always consistently formatted and no regressions were introduced.
The documentation is hosted on Netlify, and it runs some checks as well. On every pull request, it checks that no unsafe http links are present in the source code and deploys the new docs version on a temporary URL, so that everyone can have a look and collaborate.
I did not find any CD solutions to publish the new package version, nor any automated version increments, so I am assuming this is done manually.
Core features
Now it is finally time to dive into source code. We start at src/index.ts
. This file just imports all the definitions from the rest of the files and re-exports them from one place for convenience. Here is the listing (simplified a bit):
It should be pretty clear what is going on here. On lines 1-8 are the imports of the core Redux functions you should be familiar with. Next, on lines 10 through 39, type definitions are reexported. These are used by TypeScript developers to define strictly types stores, reducers and action creators. Lastly, on lines 41-48 has the primary export, used across JS and TS environments that holds the core features. So far, so good.
createStore
Next, let us explore the createStore
function, whose source code is located in src/createStore.ts (simplified):
You can see the function type signature on lines 16-25. It looks extremely confusing because it relies heavily on type generics. This is done to make strict typing in TS environments possible, at the cost of readability of source code. S
is the type definition for the root state itself, A
is the generic definition for actions (note that it extends Action
to ensure that every action has a type defined). I will not cover Ext
and StateExt
just now.
On lines 26-54 we have the validation logic. It checks that:
- You are not passing multiple enhancers (lines 26-35). This is done to simplify the logic, and that is why you have to use
applyMiddleware
to combine multiple middlewares into one. - Checks if the initial state is a function, in which case it is treated as an enhancer (lines 37-40)
- If an enhancer is present, checks if it is a function. If yes, passes itself as an argument to the enhancer (to give it a chance to provide custom logic) and returns. In case it is not a function, throws an error because it makes no sense (lines 45-54).
Since the article is named “Code Review”, I will also give some comments and my opinion on the quality of the code. Straight off, I would have moved the validation logic outside the createStore
function. It is at 280 lines right now, and it is way too much lines and responsibility for a single function. Though I am not going to cover unit testing in this article (but let me know if I should), this makes testing particularly hard, amongst other things.
On lines 56-60 you can see the core variables being set up. currentReducer
is being set to reducer
(which holds the root reducer you pass as an argument), currentState
holds the state object, isDispatching
, well, tells you if an action is being dispatched right now. We also have currentListeners
and nextListeners
, which are very confusing at first glance. Listeners are callback functions that get fired when an action is dispatched (even if it did not change the contents of the state). currentListeners
holds a list of such callback functions and is used while dispatching. currentListeners
is immutable, meaning you cannot change its contents directly, but you can just reassign the whole list. nextListeners
is mutable and a shallow copy of currentListeners
. Whenever you add or remove a listener, it mutates the nextListeners
. Finally, when you dispatch an action, immediately before firing callbacks, nextListeners
is assigned to currentListeners
(line 206). This is done to prevent concurrency bugs associated with adding/removing listeners while dispatching an action. The confusing part is why do we need to check for isDispatching
in subscribe
/unsubscribe
functions if there is such protection in place? This is an issue of ether code or comments, which prove to be useless in this case.
The ensureCanMutateNextListeners
(lines 69-73) function checks if nextListeners
and currentListeners
reference the same object, and, if they are, make a shallow copy of currentListeners
and assign it to nextListeners
. It is called before any mutation of nextListeners
to make sure these changes do not propagate to currentListeners
for reasons I explained earlier.
The getState
function on lines 80-87 should be familiar to you: you probably used it somewhere in your code. It is also very simple: check if any actions are dispatching right now, and, if not, return the current state. The reason you are discouraged using it directly instead of relying on subscribers/wrappers like react-redux is precisely because it will throw if anything is being dispatched. This will be particularly painful while writing async code or using middleware such as redux-thunk
. As a side note: do not confuse the getState
function provided by redux-thunk
and calling getState
on the store object directly. redux-thunk
will provide necessary guards to make sure you do not end up with an exception.
On lines 115-153 we have the subscribe function. It implements the listeners functionality that I introduced earlier. It is also painfully simple: check that nothing is being dispatched (though, it seems redundant) and push the new listener to the nextListeners
list. It returns an unsubscribe
function, that the callee will use to remove itself from the listeners list. It checks if the listener is still subscribed (in case the callee stored the reference to unsubscribe
and called it more than once), checks if anything is being dispatched, and, if everything clears, removes the listener from nextListeners
, and (!) sets currentListeners
to null
. This is done to absolutely make sure that the unsubscribed listener will not be called ever again. This seems redundant (as it should not happen under normal circumstances either way) but I trust that the developers had good reasons for it.
Lastly, the dispatch function (lines 180-213), the holy grail of the redux state. Firstly lines (181-186) it will check that the action is a plain object. That is, if the action is serializable. This is very important to the concept of redux and allows for other cool features such as persisting state or time traveling. Next (lines 188-193) make sure that the action has a type
property defined, as without it the action would not make any sense. Lastly, a familiar check if anything is being dispatched. Now, the most important lines of the redux codebase:
try {
isDispatching = true
currentState = currentReducer(currentState, action)
} finally {
isDispatching = false
}
It begins by setting the isDispatching
flag to true, and applying an action to the state. You can see that there are no checks for state mutability (if the reference received from the reducer points to the same state), and, I think there should be one. Not an error-throwing one, but at least log a warning. After reducing the state, regardless of how it went, the isDispatching
flag is restored to true.
After the setup is done, the ‘@@INIT’ action is dispatched on line 218. This is done to populate the state with the initial state from the reducers. Since you cannot import this action type and therefore cannot add a switch case for it, this enforces the default branch in your switch cases that returns initial state.
Lastly, everything is combined into one store
object that the createStore
function returns. All done!
applyMiddleware
The next function we are going to look at is the applyMiddleware. It allows injecting custom logic for the action dispatch process. You might have used it in conjunction with such middlewares as redux-thunk
, redux-saga
, redux-logger
, etc. Here is its source code (simplified):
This file is much shorter than the rest of the redux codebase. The applyMiddleware
function accepts a list of middlewares to apply and returns a callback function that will be called in createStore
(line 47). This function accepts the createStore
function itself and returns another callback (!). This last callback will accept the same set of arguments as the createStore
function and will create the store with the createStore
function and attach the middlewares to it. I consider this to be an unnecessary complexity, to say the least. I see that this logic is overkill for middlewares, but it does allow more fine-tuning that may be required by other than middleware store enhancers. Another issue is that the ‘@@INIT’ action will be dispatched in the createStore
function, before the middlewares are applied.
On line 14, the store is created by calling the createStore
function the same way you called it in the first place, apart from the enhancers, they do not get passed in this time. On line 15, the dummy dispatch
function is created. It throws an error until the function returns, to ensure that nothing is going on while constructing the middlewares.
On lines 22-25, the middlewareAPI
object is created. It exposes the getState function from the store and the custom dispatch function. It will be passed to the middlewares to give them the intervention points for custom behavior. For example, redux-thunk injects these functions into your async action creators.
On lines 26-27 the middleware chain is created. It is done by iterating through the middlewares
list and calling every middleware with the middlewareAPI
object created earlier. This chain is a list of functions that accept an action, do something about it (await, for example) and return an action, which may or may not be the original one. On line 27, the last line before returning, the dispatch
function is overwritten with the composed chain of middlewares. Composed means that the list of functions is turned into one function, that chains them one after another. This is done with the compose
function, also part of the redux codebase.
Lastly, the store object is unpacked, the dispatch function is overwritten and your enhanced redux store is ready to go!
Closing notes
This article is already way longer than I anticipated, so I am going to wrap now. There are a few aspects and functions that I did not cover, but with knowledge from this post, you will be able to make sense of them yourself. I will say that Redux is an elegantly written library, it is very concise and the code is clear, many thanks to its numerous contributors. Thank you for reading and let me know what library should I dissect next!