At the end of 2016, the CALC team experimented with converting the single-page app on its front page, referred to as the Data Explorer, from JQuery to React + Redux.
We inherited the 1350-line explorer.js
from a previous
team. This code was written under different project and time constraints,
and it functioned well for over a year. However, we were fearful of
modifying it due to poor test coverage--there were a few Selenium/WebDriver
tests for it, and virtually no unit tests.
Understanding how data flowed through the app was also confusing; state was maintained in lots of different places and there was no "single source of truth". This was evidenced by a number of subtle bugs in the app, such as buggy browser history and erratic form controls.
Furthermore, a lot of rendering code consisted of directly setting a
DOM node's inner HTML via jQuery's .html()
method, which
led to XSS vulnerabilities.
As our project's needs turned towards potentially improving the functionality of the Data Explorer, we thought the code could use either a full rewrite or significant refactoring, but we weren't sure which path to take.
As two of our team members were experienced with React, and had read about Redux, we thought these technologies, and the architectural patterns they engendered, might be a better fit for the Data Explorer. We thought that perhaps we could encase the whole app in one giant React component that still used the same JQuery-based code inside, and could then chip away inside it somehow.
This "top-down" approach proved unsuccessful, but we were able to
accomplish one very useful thing: we linted explorer.js
according to the Airbnb linting rules we were using elsewhere in our
project.
One of the most beneficial aspects of the linting was the conversion of
var
declarations into let
and const
; this gave us important
guidance on how we could refactor the app.
We used this knowledge to modularize explorer.js
, factoring
out six separate modules from it. The code felt less overwhelming and
the modularization made the data flow easier to understand.
We were pretty confident about the linting and modularization we had done, so we merged them into our main branch.
It was at this point that we decided to try introducing React again.
However, instead of the "top-down" approach, we tried a "bottom-up" one: we targeted one tiny part of the user interface and converted it into a React component.
One of the biggest benefits of this refactoring was how much easier
reading the code became: rather than having to refer to an HTML file
to see the original state of the widget, and then see the jQuery
code to infer how that DOM changed over time, we could simply read
the component's declarative render()
method and instantly understand
how the component would look given any state. Furthermore, due to
the security-conscious design of JSX, we were confident that
our new code would be resilient against XSS attacks.
While we were successful at this one tiny change, we weren't sure how successful following this strategy of "chipping away" at the app would be. It still felt very monolithic and intertwined: would we eventually reach a point where we'd just have to throw up our arms in defeat, and all would be for naught?
At this point we tried introducing Redux into the app, using its store to drive props to our one tiny React component.
Though one engineer was familiar with the Elm Architecture that inspired Redux, none of us actually had hands-on experience with Redux itself. Introducing it into the codebase in this piecemeal way made it easy for us to acquaint ourselves with it and understand its philosophy.
We continued to chip away at the app in an exploratory way. When we couldn't think of a way to pull out a new React component, we'd just refactor things until we could see a way through.
One of the core principles of this gradual evolution, however, was that the app was always to remain production-ready: we rarely made any commits that broke the build or regressed the app's functionality in any way.
A key part of this strategy was creating temporary mechanisms that allowed the legacy code and the new React + Redux code to co-exist peacefully. For example, the legacy code used a library called formdb to maintain some of its state in the DOM; to ensure that it played nicely with our Redux store, we created a temporary Redux middleware that synchronized the states of these two separate stores, allowing us to gradually "wean" the app off formdb. Once none of the code relied on formdb anymore, we were able to do away with the temporary middleware too.
Once we became fairly confident that our chipping away would eventually lead us to a better place rather than a brick wall, we introduced Jest and Enzyme to the project's testing repertoire. Each test was written by someone who didn't write the original code being tested, which provided them with an interactive way to familiarize themselves with it.
Despite the fact that the app was (almost) always in a production-ready state, it wasn't until fairly late in the process that we felt confident that the React + Redux approach would be successful.
Combined with the fact that the Data Explorer still wasn't a very high priority for our team, all of this work did actually pile up into one giant pull request. However, it was also being constantly monitored by the team and discussed as a work-in-progress PR, so when the time came to review it for merging, it wasn't too overwhelming.
Actually, at the time of this writing, in March 2017, there's still a fair amount of legacy code in the Data Explorer.
Third-party JQuery widgets were used for an autocomplete field and a slider, so these were wrapped into React components that largely delegated to the JQuery code via React's various component lifecycle methods. The autocomplete field is being migrated to React because we'd like to enhance it eventually, but we're not sure about the slider.
Furthermore, a histogram at the heart of the Data Explorer is implemented in D3, and we don't currently have a compelling reason to migrate it over to React.
Only time will tell.
Ultimately we were pleased with the Data Explorer's migration to
React + Redux. While the total number of lines of code is likely
greater than the previous explorer.js
and static HTML, the
conversion came with the following benefits:
-
As mentioned earlier, the declarative approach of React made it made it much easier to understand what the DOM was supposed to look like at any given point in the app's lifecycle.
-
The unidirectional data flow encouraged by React and Redux made it much easier for us to reason about how our app's state changed over time. It also made it easier to ensure that all representations of the app's state--including views and the current URL--stayed in-sync, which was a problem with the legacy codebase.
-
While the Data Explorer does still require JavaScript to function, we do have a potential path towards progressive enhancement, as the initial rendering of our React components can potentially be done on the server-side.
-
Test coverage is much better than it was before, though we still have a long way to go. That said, the modular nature of React components and Redux reducers/middleware have made it much more straightforward to improve our test coverage: we have a clear path forward that we didn't have before.
-
The modular approach of React and Redux have also made it much easier to understand the codebase and separate concerns in a logical way.
-
As mentioned earlier, the nature of JSX made it much easier to mitigate XSS attacks while preserving source code readability.