Lessons learned refactoring Codesandbox.io from Redux to Cerebral
A brief history
When Codesandbox.io was released last year I immediately got a bit upset. A 19 year old kid just kicked my 33 year old ass. My Webpackbin project got some serious competition! But being an open source enthusiast I saw this as an opportunity to share ideas. I contacted Ives and was very happy to know that he felt the exact same way. We started collaborating on handling NPM packages, which both Webpackbin and Codesandbox needed. During this time we talked frequently about merging our efforts into one project, but I think we both felt the timing was not right.
A year later Codesandbox.io has increased a lot in popularity and features. At the same time another project I have been working on, Cerebral, has moved to a stable API and proven itself a valuable tool to manage complex applications. After meeting… and drinking… face to face at ReactiveConf 2017 we decided it was time to make this happen.
So lets talk about why changing out the stack and do a refactor was even needed.
The first issue was the normalization of the sandbox. A sandbox includes what is called modules (files) and directories. Due to Redux immutable nature this structure causes a performance issue. When for example the code of a module is changed, the module itself is changed, also the modules array and even the sandbox object. Since several components depends on the sandbox and the modules, this would cause a lot of unnecessary renders whenever you typed in the editor. The level of granular shouldComponentUpdate and/or props passing that would be needed to ensure optimal rendering was challenging. So to improve the situation the directories and modules are moved into their own separate state and reference back to the sandbox with an id. This helps the situation because a code change now only changes the module. But it also causes a problem. It exchanges the performance issue with a boilerplate issue. Normalization itself adds additional code and concepts developers needs to understand and reason about, but more importantly you have to add code to bring the modules and directories back together in different scenarios. This typically happens in a Redux selector or manually in the components. Codesandbox also has a lot of helpers to figure out things related to modules and directories, these helpers would typically take a modules and directories argument, instead of just passing in the sandbox.
Normalizing for the sake of keeping for example the change of a user consistent between related entities is one thing, but when it is used only for performance it indicates that Redux might not be the best fit for this project.
The second issue is more general. Projects that evolves quickly benefits taking a few steps back now and then to refactor. This is how you keep codebases manageable. During this new refactor I told Ives that I was really impressed how he was able to keep the codebase quite manageable while pushing all these new features. I think both Ives and Redux deserves credit here. But even though the different domains of the application was nicely split into folders containing actions, reducers and selectors, the codebase was hurting a bit by the fast pace of new features. The domains had started to become overloaded and it slowly became difficult to reason about for new contributors.
The third issue was also a matter of overloading. The components were in some situations quite difficult to reason about. As an example:
- You open a component file
- You scroll past styled-components definitions, which could be over 50 lines of code in some situations
- You scroll past flow typings
- You get to the mapping of state where you reason about how state and selectors produces props for the component
- You get to the component definition where you reason about what is internal component state, what state came from the connected props and what props came from the parent, which might have been connected props as well
The fourth issue is related to how Redux with its immutability converts a state change to imperative logic. The code editor and preview window of Codesandbox needs to be manually handled by calling methods on them. Things like refreshing the preview iframe, executing preview code etc. The codeeditor has its own set of imperative methods that needs to be called based on changes to the state. With Redux it was necessary to render these components again and again, checking changed props in componentDidUpdate to evaluate if a method should be called. This just does not feel right and can be difficult to manage, as we see in this example from the old code.
Ives van Hoorne is all about producing value, so the stack itself is not a religion to him. Suggesting a refactor with Cerebral did not require a lot of convincing. Mobx-state-tree had caught his eye though. Cerebral ships with a state store, but it can be replaced by for example mobx-state-tree. So we agreed that replacing Redux with Cerebral and mobx-state-tree as state store would fix the before mentioned issues. Cerebral, with its declarative approach, would improve the readability and scalability of the complex business logic, while mobx-state-tree would give us type safety, avoid normalization, translate state changes to imperative logic using reactions and do optimal rendering out of the box.
I was ready to dive into refactoring!
Getting to know a new codebase
The biggest challenge refactoring Codesandbox.io is that I did not know the codebase at all. Luckily I have experience writing Redux applications so I did know the general pattern of reducers, selectors and actions. That said, understanding the pattern does not instantly give you a mental image of how the application works. You need to know the actual state inside the reducers, what the actions do and how the selectors brings state together to produce component specific state.
To start building my mental image I decided to just dive into it. I removed Redux, plugged in Cerebral and started handling one error after the other. As I did this I got to know the main components, what actions they triggered and what state they needed. As I went through the code I started to make improvements like optimistic updates. It started to feel doable and I thought it would be a good idea to create a WIP pull request. Of course I did not expect it to be able to merge instantly, but rebasing my changes on top of latest master made me realize that I was pretty much f*****. There were so many new features and other changes, it was impossible for me to get these two versions together. I must admit I felt a bit discourage. I had to leave it for a few days, not in frustration, but because procrastinating is my number one tool for problem solving.
A week later the back of my brain had done its work. Instead of removing Redux I would rather use the “strangle strategy”. I had experience with this at a previous employer many years ago, putting a modern HTML5 layer on top of a legacy frame based project. With Codesandbox I would just expose Cerebral on top of Redux and implement the business logic and state needed one component at a time. For every Redux action, a Cerebral signal was triggered as well. A copy of the state from both reducers and the components were put into Cerebral, typed with mobx-state-tree. With the Cerebral debugger it was easy to validate that the correct state, side effects and state changes were made. That means Cerebral handles the business logic and state in addition to Redux, meaning no changes were made to the original application. This ensured that the application would run as normal and new features and other changes would merge in without much problems.
So the first critical milestone was to get through all connected components and create the Cerebral equivalent of Redux. When this was done the second critical milestone was to replace the Redux component connector with mobx-react. Instead of mapping actions and state we just inject all the Cerebral signals and state. Whatever state the component uses is automatically observed and a new render happens when the observed state is changed. Here is an example of how such a component was refactored:
Cleaning up components
The third milestone was to clean up and restructure the components. In Redux there is a concept of “Container components”. Basically they are responsible for mapping state and actions and pass them down to “Presentation components”. With Cerebral you do not think this way. You connect state and signals exactly where you need them. That means the containers folder needed to go. Instead the existing pages folder would act as the connected components and the components folder would be the “not connected” reusable components.
The second part of this cleanup was to make every component a folder. That way all the style definitions, using styled-components, could be moved into their own files. For example:
Creating a mental image
I believe creating a mental image is core to solving any problem. You have to understand what you are looking at as a whole to make changes to it. This is exactly what declarative code helps us with, and is why I started the Cerebral project. As an example we can look at the editor module. The file contains a declarative description of the initial state, any getters, computed and what events in the application causes what business logic to run. By looking at this one file you get a complete mental image of what the “editor” domain manages in the application.
Also the business logic in Cerebral is written declarative. It gives you an instant mental image of what is expected to happen when for example a user clicks a button. Looking at the sequence handlePreviewAction you see what can happen when the Codesandbox preview triggers this signal. There is no disturbing instructions to the computer, just domain specific labels for you as a developer to understand what is happening. Cerebral allows this syntax with the Function-Tree project, which is a functional composition tool.
Last but not least you also need to build mental images of executing code, what actually happened when you clicked the button. This is where the Cerebral debugger helps you. It has insight into all the running logic of your application, helping you understand what data has passed through any execution, what side effects has run and what state changes has been made.
To summarize this article I want to point out some lessons learned. We changed out the stack to solve some issues in this project, but that does not mean everything became rainbows and butterflies. Redux, Mobx and Cerebral are all tools that manages the surprisingly complex problem space of state and UIs, but they have radically different approaches. That said, none of these tools are a silver bullet. They are all good tools to build applications, with their advantages and disadvantages. This is what I want to leave you with before sharing the good and the bad we experienced during this refactor:
Mobx-state-tree — The good parts
- Type definitions gives predictability out of the box. By looking at the model of your state you always know exactly what values are valid for any state. If you mess up, mobx-state-tree politely yells at you
- Observing state in components has the benefit of you getting access to the whole state store in any component. You point explicitly to the state you need and Mobx handles the component renders
- Getters and computed are one of the really great features of Mobx. It is challenging to optimally keep a calculated state value up to date when it is based on changes to other state values. Mobx handles this very well as it knows exactly when this calculated state value needs to update
- Reactions are a really good way to react to any state change and trigger imperative logic. This feature optimized the Codesandbox code editor and preview window as there was no rendering involved to run imperative logic. Original Redux version VS New Cerebral version
Mobx-state-tree — The not so good parts
- Implicit renders can be a problem when they do not work. This happened to me a few times. It is not always obvious what is actually being observed. Sometimes I just forgot to make the component an observer, other times I was confused by passing observed values as props to child component that did not observe. Also reactions is not always straight forward . For example I needed to run imperative logic when an array of errors changed. To handle this correctly I had to: store.editor.errors.map(error => error). The array reference itself does not change when an error is added er removed, so the map ensures that when the reaction runs due to a change it will return a new array which tells the reaction that it has updated… pretty mind bending
- Missing or bad errors happened in two different scenarios. There is no error if I try to add state that is not defined in the model. I experienced the state being ignored, throwing me down the rabbit hole as it was not available in the components. When you insert state with wrong type you get this huge error serializing the whole state tree and at the end the actual path to the wrong state value. Would be enough with the last part
Redux — The good parts
- Flow or Typescript support seems to be a given with the simple and minimal approach of Redux
Redux — The not so good parts
- Jumping files is a common routine in Redux applications. You are looking at an action prop in a component. To figure out where the prop came from you have to move up to the parent component which is in a different file. Then you have to move to the mapping function in the connector to figure out where the action is coming from. When you jump into the action it is probably a thunk that does multiple dispatches. To figure out what reducers handles the action dispatched you can end up having to search through your reducers
- Immutability is a great concept for avoiding unwanted mutations and change detection is as easy as checking the previous value with the new. The big problem with immutability though is that a nested change will also make a change to all parent values. For example a change to the code in a file, makes a change to the file, the array of files, the sandbox, the editor and to the whole store. If you are not careful about how you connect state you will get into problems. One way to reduce the effect of changes is to split up nested state into a more flat structure (normalize). So files are not children of sandboxes for example, but rather separated and linked by the id of the sandbox. The problem here is that you need additional logic to link them back together where needed. It also ruins your mental image of how things actually relate. One thing is normalizing authors as users, as users might be part of other entities… but the files and directories of a sandbox is related to only that one sandbox
- Containers separates state and actions from where they are actually used. It is not uncommon that you have to jump into a parent component or even another parent to figure out where the heck a property came from. Personally I really dislike this. In my component I want to know if I am using something from my state store and exactly where in the state store it came from. With mobx-state-tree I can say: props.store.preferences.settings.zenMode, where the equivalent in Redux would be: props.zenMode. You do not know if props.zenMode is just a normal prop or if it is state from your state store. You have to traverse up the component tree to verify
Cerebral — The good parts
- Functional concept for managing business logic, has not been explored much in frameworks. Redux has its addons, but other frameworks usually depends on a single function to contain all the logic needed for often very complex flows. In Cerebral you have a functional composition tool called Function-Tree which allows you to express business logic declaratively
- The Cerebral Debugger is pretty awesome. It gives you insight into your running logic. You always know what state has been changed, when it was changed and what side effects were run related to those changes. It is also a tool for beginners to understand what is happening in the application without even looking at code
Cerebral — The not so good parts
- Highly decoupled and composeable are not good concepts for Typescript and Flow. They try their best at inferring how your code runs, but when that code is dynamic in nature it gets tricky
An exciting progress in the Cerebral project is the fluent addon. This gives full type safety using Typescript. But this is for a later article!
A special thanks goes to Wayne Bamford for helping out with the initial refactoring. Mobx-state-tree is a really great project and I want to express my thanks to that team as well! And of course, last but not least, a big thanks to Ives van Hoorne for putting all his time into this very special project. I am really looking forward to what 2018 will bring in terms of features and other fun stuff :-)