Server Rendering, Code Splitting, and Lazy Loading with React Router v4


Photo by Matt Duncan on Unsplash

“Godspeed those who attempt the server-rendered, code-split apps.”
 — Ryan Florence, Co-Creator of React Router

Challenge accepted.

Some Background on Server Rendering at Airbnb

Historically, Airbnb has been a Rails app. A few years back that started to change, we began using Rails simply as a data layer, and all render logic started migrating into JavaScript in the form of React. In order to maintain server rendering, we created and open sourced Hypernova, a JavaScript Rendering as a Service… service.

Taking this a step further, we introduced client side routing and route based code-splitting with React Router v3 as part of our architecture revamp. This is what enabled the smooth page transitions, and smaller initial page loads.

Enter React Router v4

Server rendering + code splitting boils down to a single requirement. In order for them to work together you need to be able to match against your current route before rendering.

(The rest of this post is fairly code heavy, if you aren’t already familiar with RRv4, take some time to read over the excellent React Router documentation. Also, it wouldn’t hurt to look over the webpack code-splitting documentation.)

The problem then, is that React Router v4 switched from a centralized route configuration (with a getComponent function for async loading) to a decentralized version. Routes are now defined inline like so:

Defining routes in this manner means you won’t know which routes/components are needed to render your page until you are actually rendering. To demonstrate why this is a problem, imagine we’re async loading the About component.

When we server render, we’ll want to render all of the content, so the html for the About component will be generated and inserted into the DOM tree. On the client side though, we won’t know to match the /about route nor would we know to load the About component, until after we’ve already entered the render cycle. This will cause a client/server mismatch error since without having the About component loaded, the client won’t create the same html as the server. This also likely means a flash of content and a wasted render, meaning a worse experience for your users.

Re-Centralizing Routes

To address the unique problems with inlining routes, they’ve created react-router-config. This let’s you continue to define your routes in a centralized location, and match against them before triggering your initial render. Using the library, our routes definition might look something like this:

This is the basic route structure we’ll be using in the rest of the post.

Decentralizing Our Re-Centralized Routes

(I know what you’re thinking…)

Using react-router-config is great, but there is still a bit of work left to do. react-router-config doesn’t seem to have support for loading components in an async manner, and child routes are required to be too explicit. Notice that all the path values are defined as full paths.

This can get a little unwieldy, and limits reuse. To address this issue, we’ve implemented a mapping function that allows components to define child routes.

Doing this gives us a little bit more freedom during development. The grandchild route no longer needs to know its full path, and can be inserted into your central configuration at any location to create deeply nested routes.

This is extremely powerful, especially in large code bases. Routes can be reused in multiple locations, and component logic can be route agnostic, so their reusability increases as well. This is what makes our current transition into a large single page app (SPA) possible, as we can add additional routes without bloating our core flow. As our SPA grows over time to include more product pages, we can rest assured that any individual page only contains what is needed.

Defining an Async Route

We need to ensure that all the components, for whichever route a user hits, are loaded before we make the call to render.

To start, we’ll create an async component definition, then we’ll change our grandchild route to use the new component. Note the static load function, this will be used later to ensure that we’re ready to render.

Also, let’s update our grandchild route to leverage the new helper function.

Ensuring the Routes are Ready

Now we’re exporting a route config that contains a route with component that defines a static load function. All that’s left to do before rendering is ensure that everything is loaded.

Putting it all Together

Now that everything’s in place, we’re finally ready to render our application. This is what everything looks like when tied together (minus the definitions for the helper functions ensureReady, generateAsyncComponent, and convertCustomRouteConfig)

Demo!

This post is a bit heavy with code, I thought it would be helpful to see all of this in action. Check out the demo repository to see everything put together, and feel free to explore the code!

What’s Next

We’re always looking for ways to improve, both in our core products and in the open source libraries that we depend upon.

  • Opening issues/pull requests into react-router-config in the hopes of making this process even simpler for others.
  • Investigating how to minimize hydration size as more pages are pulled into our SPA.



Source link