Stephen Cook
1 Mar 2018
•
5 min read
This post is about how we at Onfido recently chipped out a React SPA from a Rails/Ember monolith, with no feature/code-freeze.
Most companies have monoliths that they regret. We all know the pattern: code is added to one repo at crunch-time because it’s easier. Splitting out a separate pipeline would take longer, so we resolve to clean things up later… More features are added, and the clean-up is pushed back until we have a little more time… Until one day, we finally look up to see the monolith we’ve created, only to notice it’s now beginning to blot the sun. Deploy times take hours. Teams you’ve never met block your releases with issues in their part of the monolith. The 20 minute coffee-break you take to let your unit-tests run on feature branches no longer cuts it.
At Onfido, we aim to split our code by 2 dimensions: horizontal splits, splitting a product by the technology involved (e.g., front and back-end); and vertical splits, splitting a product by services provided (e.g., separating out log-in functionality, microservice-style).
We recently split one of our codebases horizontally, away from the Rails monolith where it used to live, over to its own repo. This new shiny, independent codebase lives as an SPA, communicating with the monolith via an API.
As will be the case with most everyone, we couldn’t afford a lengthy feature-freeze. We knew that whatever we did, it would have to be alongside active development on the codebase. This initially sounded like a daunting task, but in the end the whole conversion was relatively painless!
Our codebase was originally Ember & Rails, but is now React & Redux — so not everything may apply directly to your use-cases, but hopefully our advice here should be transferable for any codebase trying to split away to a React application.
Here’s how we did it.
Our first step was creating a hybrid app (chimera, Frankenstein’s monster — whatever you want to call it, it’s temporary!). A great feature of React is its interoperability, which Facebook explicitly put into the design spec so that they could perform a gradual refactor of their site to use the framework.
We place high value in interoperability with existing systems and gradual adoption. Facebook has a massive non-React codebase. Its website uses a mix of a server-side component system called XHP, internal UI libraries that came before React, and React itself. It is important to us that any product team can start using React for a small feature rather than rewrite their code to bet on it.
https://reactjs.org/docs/design-principles.html#interoperability
Leveraging this, we began to port over components that were common on a lot of pages over to React.
For us, this meant porting our old Ember components over to React, and injecting them into the Ember app. We used the following code to inject in our React components into the Ember application:
# handlebar_helpers.js
Ember.Handlebars.helper('react-component', (className, data) => renderReactElement(className, data.hash))
# renderReactElement.js
let reactCompCount = 0;
const mountList = [];
const lazyGetElementById = (mountId) => new Promise(resolve => {
const mountEl = document.getElementById(mountId);
if(mountEl) {
resolve(mountEl);
}
else {
Ember.run.next(() => lazyGetElementById(mountId).then(resolve));
}
});
export default (className, data, mountClass='') => {
const mountId = 'emberReactComp' + (reactCompCount++);
// We need to attach React components to the window object, if Ember is to use
// them. This is so HBS files etc. can still reference the React classes
const classVal = window[className] || className;
const reactEl = React.createElement(classVal, data);
const mount = new Ember.Handlebars.SafeString(`<div id="${mountId}" class="${mountClass}"> </div>`);
// We can't control Ember rendering our mount, so instead we wait until it has
// rendered, and get the mount DOM node via. `document`, to render to
lazyGetElementById(mountId).then(reactMount => {
ReactDOM.render(reactEl, reactMount);
mountList.push(reactMount);
});
return mount;
};
We’re currently doing a similar thing in a pure Rails codebase, and use the following code to do the same thing in our ERB templates:
# asset_helper.rb
def react_node(nodeClass, data = {}, mountId = nil)
content_tag :div, "", {
"data-react-class" => nodeClass,
"data-react-data" => data.to_json,
"id" => mountId
}
end
# mountReactElements.js
Array.from(document.querySelectorAll('[data-react-class]')).forEach((reactMount) => {
const reactClass = window[reactMount.dataset.reactClass];
const reactData = JSON.parse(reactMount.dataset.reactData);
const reactEl = React.createElement(reactClass, reactData);
ReactDOM.render(reactEl, reactMount);
// Ensure we don't (1) keep the DOM dirty, (2) re-mount an element if this
// method is called twice
delete reactMount.dataset.reactClass;
delete reactMount.dataset.reactData;
});
This lets us start chipping away at the views, moving us over to our new framework. But we can’t port our app architecture using this method, only the view layer. Which brings us on to…
Our next step was creating pure React/Redux pages, powered by their own API requests to the backend (rather than needing their data passed in via props). Creating new pages is easy enough to do, but we needed a way to transfer the user between these 2 different “apps”. With a bit of nginx config, so that each of the different apps (React/Ember) has their own unique url structure, we added the following config:
# our Ember.Route extend
transitionTo: step => {
const reactRoute = {
'controller.new_page_1': 'new_page_1',
'controller.new_page_2': 'new_page_2',
}[step]
if reactRoute
window.location = newAppBaseRoute + reactRoute;
else
this._super(step);
}
# our React router setup
<Route path="/new_page_1" component={Page1} />
<Route path="/new_page_2" component={Page2} />
<Route
path="/*"
onEnter={e => {
// Redirect from /react/some/ember/route
// to /some/ember/route (destructively to preserve browser history stack)
window.location.replace(getPageUrl(e.params));
}}
/>
So any reference to the new pages on the old app, will redirect to the new app. And any references to an unknown page on the new app, will redirect to the old app. (Unknown rather than old, so that the old app is still handling 404s).
Or described with a picture, an architecture something like this:
The benefit of using different url structures to get to the React/Ember apps is that the nginx config doesn’t need to update every time a new page is ported.
And with a bit more config to add feature-flags, dynamically passing only certain users over to the newer routes, giving us the following:
It’s worth noting that maintaining 2 versions of pages is non-trivial. This is certainly the largest drawback that we encountered in the whole process. Maintaining 2 versions of a page means duplicating any feature-requests and bug-fixes for that page — which for more complex issues, can require solving the same problem twice.
Our goal here is to get to a new repository, that is separate from the monolith. So our next step was to do just that, so — even before all of the pages were ready — we created the new repo and copied our new React app over to it.
At this stage, we continued to allow feature and bug tickets merge into the old monolith repository. We just wanted to get the pipeline set up in the new repository, and test that the built assets are pushing to S3 correctly.
Once the pipeline was all set up, the new S3 buckets etc. were all correctly configured, and all of the old app’s pages were ported over to the new app — we were finally in a position to do The Final Big Swap.
At this point we finally had to implement a “code freeze”. In our case we didn’t actually freeze the codebase at all, and rather just picked a time of low activity in the codebase, with the intention to cherry-pick any few commits that did go in during the “freeze” period. The swap was fortunately very quick, so there were only a handful of these commits that we needed to cherry-pick.
The config changes required to swap over to your new codebase will depend on your site architecture, but for us it just involved rolling out our DNS changes, so people were pointing to our new S3 buckets, rather than our old monolith boxes.
Finally came just keeping an eye on our logs and metrics to ensure that nothing was going awry.
And that’s a wrap! After the dust had all settled… And the logs seemed okay… We looked up at our monolith, now a chip smaller.
If you're interested in working with React.js full time, check out all of our React jobs here on the job board!
Ground Floor, Verse Building, 18 Brunswick Place, London, N1 6DZ
108 E 16th Street, New York, NY 10003
Join over 111,000 others and get access to exclusive content, job opportunities and more!