How to Migrate React to Isomorphic Rendering

SITE NAME
2015-10-01T01:00:00+00:00

Coursera adopted isomorphic rendering in React and saw a 4x improvement in page load times. In our last post we discussed how to develop web apps with isomorphic rendering (aka universal Javascript, but how do you get from an existing codebase that assumes client-side rendering to one that can render isomorphically? Here we discuss a few gotchas you’re likely to encounter and how to deal with them.

Multiple clients per server

Your code probably assumes that a render process has the whole Javascript VM to itself. In client-side rendering (CSR) each user has their own Javascript VM. (More precisely, each browser tab.) You might have singletons, which are basically global state. In server side-rendering (SSR), each request can go to an arbitrary VM in your cluster, so a single VM is serving many requests from many users. That means you’ll have to exorcise singletons from any state that you want to be different per user or per request. (For more on this see React.js Conf 2015 Flux Panel on the trade-offs.)

State that changes from request to request must exist within the context of that request. Some Flux libraries won’t work at all because they simplify access to stores and actions by using singletons. A growing number, however, can work isomorphically. We chose Fluxible, which we found to have the strongest support for isomorphic architecture. In Fluxible, each request has a FluxibleContext which provides the scope for components, stores and actions for that request.

Some state changes from request to request, but only within a small finite set. For example, one request may be rendering from code built on a different repo branch. In our architecture, each build is named by its file hash and each request can run with assets from an arbitrary build. However we don’t want to load these Javascript modules on each request. Instead, we use the multiversion feature of our module loader, RequireJS, to reuse an existing module loader context for the build specified in the request (or create one as needed).

Localization also changes per request within a finite set. We localize our UI in seven languages and load translation tables using a customized version of the i18n loader in RequireJS. These tables are thus per-build and per-language, so we simply combine the two for the module loading context (multiversion) key. Note that this decision was to minimize our migration costs. Starting a new app in pure React, it would be better to look up translation tables from the React context (like in ReactIntl’s IntlProvider).

Single pass render

Part of the beauty of React is that it renders data idempotently. Its DOM updating is so efficient that it can lull developers to allow repeated renders, or assemble state over multiple steps. However in server-side rendering (SSR) there is exactly one render pass before sending the serialized DOM and application state to the client. This implies that your app must have all state available before that render pass happens in renderToString().

If your app is very simple, you could populate your stores with all the data. Of course, ideally you’d load only the data the rendered components need. Facebook’s Relay enables each component to specify its data requirements using a declarative language, GraphQL, but that requires a backend to GraphQL. React Transmit is a more general approach in which components specify promises for the data, but for isomorphism it requires using its own render functions and serialization. At Coursera we already had an excellent REST backend framework called Naptime so instead we built an analog to Relay we call NaptimeJS that lets us specify data dependencies declaratively using our Naptime semantics.

If you’re just getting started and don’t want to invest in a library, there is a simple alternative: let components specify what data they need before navigation completes. When a request comes in, React Router activates a list of route handlers, and our navigate action inspects each to see whether it has promises that need to be resolved before rendering. Once all the promises have been resolved, the components render using the data from stores. Here is some code from before we moved to NaptimeJS.

/**
 * The router runs this navigate action once a new route is resolved.
 * This returns a promise that must be satisfied before the render
 * starts. The promise consists of all actions that each handler
 * in the route declares that it needs before navigating.
 *
 * WHEN we upgrade react-router to 1.0, we should revisit this design
 * https://github.com/rackt/react-router/pull/1158
 *
 * @return {Promise} to satisfy all the dependencies of the route
 */
navigate: (actionContext, payload) => {
  actionContext.dispatch('CHANGE_ROUTE', payload);

  var handlers = payload.routes.map((route) => route.handler);
  var allActionDependencies = _(handlers.map((h) => h.navigateActionDependencies)).
    chain().flatten().compact().value();

  var allPromises = allActionDependencies.map(
    // pass the payload from this navigate action
    (action) => actionContext.executeAction(action, payload)
  );

  // navigation ready once all dependent promises are satisifed
  return Q.all(allPromises);
},

Rehydration

So you’ve gathered all the data, rendered the page and sent it down to the client. Now you want the client-side to pick up where the server left off. You can simply begin executing the same Javascript on the client, and the page should render the same. However if the virtual DOM the client renders doesn’t exactly match the server’s, then the client has no idea what changed and can’t begin diff updates to it. Instead it will replace the whole thing, making a flicker and potentially scrapping important elements. React tests for sameness with a data-react-checksum on the root mounting element. For the test to pass, the first virtual DOM of the client to must match exactly the server’s that generated the HTML.

To make sure the client renders as the server did, you need to provide it with the same state. To guarantee this, the server has to serialize the required state and the client has to deserialize it. Fluxible calls this “dehydration” and “rehydration”, terms we like because you’re not just deserializing data but making the dry UI begin to flow. The whole app context has dehydrate and rehydrate methods to run on the server and client. Each store has dehydrate and rehydrate methods to manage any complications that aren’t captured in JSON. (Though you don’t have to worry about functions and regexs if you use Yahoo’s serialize-javascript JSON superset.)

Note that the components themselves aren’t serialized. In CSR you might be loading application state in componentDidMount() and keeping it in the component state, but that won’t be available during rehydration. Your app will work like this, but when the rehydration takes place the UI will inexplicably update. Sometimes this is appropriate, for example if you want to add a social media share button to the page which can’t be rendered SSR. These cases should be explicit and handled carefully from a UX perspective. Most of the time, you’ll need to move that state into stores so that the client has the same state as the server during rehydration.

Server-incompatible code

In migrating to server-side rendering, we found some of our code just couldn’t run on the server. For example, anything referencing window. In some of our modules this happens in the module definition, so simply requiring that module would kill execution. These errors can crop up easily when someone adds a dependency anywhere in the dependency subtree.

In some cases the logic in the offending module was required by the server too. In these cases, we refactored the module so that the shared safe code was separate from the code that depends on the environment. For code that did depend on the environment, we created “server” versions of the client modules that on the server are loaded instead. This is managed by overriding our standard RequireJS config map in the server environment. For example, our client-side data fetching uses the browser’s xmlHttpRequest but on the server we use node-fetch. We simply map the standard module name to the server variant.

Sometimes the code can’t be refactored into new modules. For example, jQuery doesn’t make any sense on the server. Those we map to a module that simply exports null, so that requiring them can’t break the whole SSR module environment. Some modules are data singletons containing state, so we also map those to null as a guard. Finally some code can’t be refactored or excluded, like anything in a React component that deals with window. Well, you could refactor that but it’s much easier to simply restrict such code to execute within componentDidMount() that executes only on the client. (Note, not componentWillMount() which executes on both client and server.) Sometimes you can factor the code out but it doesn’t seem to merit its own module. In those cases you can branch on the current environment, using something like if (typeof window !== ‘undefined’), but try to keep that to a minimum.

Take-aways

Refactor singletons.

Think carefully about scope: what can be per VM, per module loader, per request..

Pick a method to gather all data before the render pass.

Move application state from components to stores.

Whatever can happen only after mounting should be designed carefully for the user experience of page load through full rehydration.

Guard out server-incompatible code.