Scaling the Single Page Application with React.js and Flux


Here at AddThis, we just completed a rewrite of our dashboard using Facebook’s React.js framework and the Flux application architecture. Managing a large Javascript application as its complexity, number of users, and number of developers grows is something that we’ve spent a lot of time thinking about during this process. In this post I’ll attempt to distill down some of the lessons we learned along the way and how React.js and Flux can help to address them.

In the past few years there has been a huge shift in how we develop for the web. More and more tasks that may have been traditionally handled on the server are moving to the browser. Thanks to advances in modern Javascript engines, like Google’s V8, browsers can now run Javascript almost as fast as native C code, and performing computation in the browser is almost always cheaper than the network latency introduced when going back to the server.

This great migration from backend servers to browser-based applications written in Javascript has created an interesting set of challenges. Javascript was famously created in just 10 days by a single developer. It was probably never intended to be used for large applications, but due to its ubiquity (and the fact that it is the only language that natively runs in all browsers), the web development community has had no choice but to create ways of using the language that can scale.

Scaling State: Surrender, Just Rerender

Large web applications like Facebook or even the AddThis dashboard contain an enormous amount of state. Changes to state come from different sources, primarily, updates from the server or user interaction. Each change in state, no matter how small, has the potential to propagate through the entire application. With state change coming from different directions and having app-wide implications, maintaining consistency is a challenge.

So, how might we address maintaining consistency? Well, we might try to have a listener that performs updates to the affected DOM elements whenever the state changes. While in theory this will work, in practice it is verbose and error prone. The onus is on the developer to make sure all of the necessary DOM elements get updated properly, which is difficult to do because an individual developer may not know or remember all of the implications of a single state change.

Once the frustration from hunting bugs becomes too much, your instinct might be to throw your hands up and rerender the whole view every time the state changes. Good news! You’re not alone. This is the approach taken by several frameworks including React.js. Now we can define our view declaratively, and when the underlying data changes, we wipe it away and start fresh. This is starting to sound really nice, but before we start celebrating, there is another problem: we are re-rendering an entire page worth of DOM elements every time anything changes.

Scaling the View: Don’t Mess with DOM

The DOM, or document object model, is slow. More accurately, manipulating the DOM is slow. This is a pretty well known and oft repeated mantra among web developers, but it remains true. It stems from the fact that changes to any element in the DOM’s tree structure could potentially result in the browser having to completely layout the page again. This is a problem when we have large views and/or make frequent changes to the data model (and thus have to re-render frequently).

Don’t despair though, React can help with this problem too. React keeps a “virtual DOM” and tracks changes here. The virtual DOM is lightweight and blazing fast. Once the changes have been accumulated in the virtual DOM, React determines the minimum set of changes it must make on the real DOM, and uses optimizations to make these changes as efficiently as possible. This approach also allows React to batch writes to the DOM to further minimize the number of slow DOM interactions.

Scaling Your Team: Concurrency Through Components

Our dashboard that we recently converted to React+Flux was previously written in Backbone. Famously unopinionated, Backbone provides a team of developers ample flexibility. As the team and project grows, this flexibility has a tendency to morph into disorganization and lack of shared best practices. Specifically, views can become tightly coupled to data sources, business logic can creep in where it does not belong, and the router can grow out of control. Not only does this result in messy and hard to maintain code, it can also lead to stepping on toes and difficult to track down bugs.

Now, imagine working on a single visual component in complete isolation, independent of any specific data source or business logic. This level of modularization allows members of the team to work on separate components in parallel, without blocking each other or stepping on toes. React components take this approach and are also composable, which makes reuse simple. Generic components made in one project can easily be imported into another project because they are not tied to any specific data source. For instance, we created a “card” component that other teams later repurposed for their own projects. Adding a new card to a project then becomes as easy as writing a few lines of JSX (a Javascript syntax that looks like HTML):

		So simple.

I’ll note that this level of organized concurrent development and modularization is not something that couldn’t be achieved in another framework (such as Backbone). In fact, there are many teams out there who do this effectively. However, React+Flux is just prescriptive enough that this level of efficiency can be achieved with little effort.

Scaling Architecture: Enter Flux

Photo by krawaller

So far I’ve spent a lot of time talking about the view layer, which is an important part of a Javascript application, but not the whole story. While many Javascript frameworks separate their concerns into the traditional MVC (model-view-controller) or some slight derivative (model-view-viewmodel, or model-view-whatever), the Flux architecture takes a different approach altogether. It is important to note that Flux is not any particular library (though there are many common components used in Flux that have popular open source implementations). Instead, it is a way of structuring applications that addresses some of the pain points found in other MVC frameworks.

In a large MVC application it may be difficult to articulate lines of communication. A controller can pass data to the view, but might also receive user input back from the view. Maybe a change in a view also changes an adjacent view, and so on and so forth. Before we know it, there are data flow arrows pointing in every direction. This (lack of) structure can be difficult to reason about and even more difficult to debug.

Flux mandates unidirectional data flow. This directive is perhaps its defining characteristic. User interaction with a view creates an action, which is “dispatched” to data “stores” that in turn emit changes to the views. With data flow defined in one direction, events can easily be followed through the application by a developer—even one who is unfamiliar with this particular part of the application. Sanity, speed, and developer happiness quickly follow.

Another issue sometimes encountered in a large Javascript application (including ours) is the confusing nature of shared mutable state. In our Backbone application, data models were passed around the application, where they were free to be modified by any component that saw fit. When these changes are happening in many different places, coded by many different developers, debugging and reasoning about the application quickly becomes a mess.

Flux does its best to reduce shared mutable state. Flux “stores” (almost analogous to “models”) do not allow outsiders to modify their state. By not exporting their “setters” they make these methods private to the store itself. The setters only get called in the store’s callback registered with action dispatchers. The end result of this design pattern is that bugs are much easier to track down, because any time the store is changing it is happening in a known place.

Scaling Your Brain: Best Practices, Patterns, and Predictability

Tantamount to scaling a Javascript application is creating an environment in which every developer has the ability to reason about, quickly understand and contribute to, and debug a large and mostly unfamiliar codebase. The truth is, this can be achieved in any number of frameworks. I chose to focus on React and Flux because I happen to think these tools are a great means to that end.

We learned, however, that perhaps the most important takeaways are tech agnostic. Establish best practices, patterns, and conventions. Adopt them early and reinforce them often. Create a collaborative environment where the best practices are constantly discussed and improved upon. When everyone helps to create conventions, everyone is an owner and takes pride in enforcing them. We are now developing new features for our dashboard faster than ever and this way of thinking and developing has been crucial to setting that pace.