How to Migrate React to Isomorphic Rendering
Multiple clients per server
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.
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,
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.
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.
- 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.