Better Apps with React Server-Side Rendering


So with all the extra effort required… why use server-side rendering at all? Let’s take a look at what we wanted out of our app and how SSR unlocked a ton of value for us.

Our Needs

At Riot, our products get big really fast. We need systems that allow us to add features without having to make major changes to our stack or application framework. For this reason, we need formal ways to add components that load and manage data from remote services. So just firing off a web request anywhere in our component tree is a bad idea, because it would mean waking up one day and realizing there’s a web app that sends 8 different Ajax requests on load. That’s not great.

Did we mention that we want things to be fast? While building our Search site, we wanted to make sure it would feel like a tool. That means we really can’t wait around for a bunch of client-side Ajax requests to resolve. We (and our customers) want data now - so we need something easy to manage. These requirements pushed us toward our independent data service. Instead of maintaining data within the application, we started maintaining a data store outside of it!

Our SSR Solution

Building the data store outside of the application was our key strategy. We created a Redux store outside the app and let it sit in memory while we fire a series of asynchronous redux thunks to populate its state. When those thunks finish, we pass our Redux store to the app and synchronously render it.

All of our app data basically boils down to this one method:

await services.store.dispatch(loadDataForLocation())

This turned out to be a huge advantage! It’s very easy to test and it’s also possible to drop this into a client-side app where it can load every time the HTML5 history state changes.

Because we could tie this directly to history changes, we got comfortable with the pattern /my-url => myReduxState. This is easy to test and reliable when it comes to UX.

A Little Context

So why are we making decisions here in the first place? The answer is simple - React Application Architecture isn’t settled science (err, engineering), so we knew it wouldn’t necessarily be straightforward, and any solution would require some creativity.

React’s (Lack of) Direction

With React, you can do… basically whatever you want. The freedom sounds great, until you realize that means that there’s not a canonical way to handle user auth, app services, or history management.

This is actually okay, because there isn’t a single right approach for these problems. But one of our most valuable strategies going into this was actively having an approach to each problem before we started! There’s a ton of literature and boilerplates available (here are some of my favorites) that explain how to spin up an app, and most of them can give React engineers some great directions for their projects. They just usually don’t provide a full-on pattern for managing user auth, global state, and SSR. Which means there’s a lot of invention left to do.

Planning Ahead

We settled on Axios for our request client because service consumption and data management work identically on both node and the browser, and we settled on Redux for our data store because it’s extremely well supported and easy to use. But you could also implement Relay with GraphQL, Apollo, or even remotely-bound document databases that you load with higher order components.

If you use Create React App, you have a lot of freedom. You can implement any of the options above, and you can use them anywhere inside your app (no matter how deep in your component tree ಠ_ಠ). But be warned - not establishing rules or following patterns can make scaling your application really difficult.

Specifically, if global data and state management happen deep inside a React tree, you could end up with a scenario where the only reliable way to test services and data management in your app is via Enzyme or integration testing. It’s a huge pain when you suddenly realize you need to load an entire UI just to test form submission. The temptation to do everything in your components is always there, but with browser apps it’s also easy to do, which can cause some serious problems down the line.

Benefits

We like that our app is fast and we like being able to test our data store independently. This showed us that SSR with universal rendering didn’t only give us fancy new features - it made organizing the app a lot easier.

We didn’t just benefit from the ease of unit testing data stores either. When we released our internal search engine, we knew exactly how much traffic our UX could take because we were able to lean on existing headless load testing tools. It’s a lot easier to load test your app when you can just spam it with GET requests.

The overall positive impact on user experience also seriously impressed us. The number one comment I get from users of the search engine is that they love how fast it is. Funnily enough, our services aren’t actually much faster than our other apps, but the instantly loading front-end feels so snappy that you can’t tell.

It’s also a nice improvement for mobile users. While it still takes a long time to load those webpack bundles of Javascript, the app is available with data while you’re waiting for that payload. Most mobile users don’t even realize the front-end Javascript hasn’t kicked in yet. And, if for some reason they’re on a potato phone or the Javascript just doesn’t load in time, they can still experience the app like it’s a normal website!

Drawbacks

While synchronous rendering unlocks a ton of value, it can also be a huge constraint. Data needs to be ready before load, so we can’t count on loading it inside app components. Our only reliable options are:

  • Load data before the scene gets rendered (on the server)
  • Load data when the URL changes (on the client)

While at face value this seems inconvenient to work around, we saw this as a challenge to make healthier systems. Burying global state management inside React components is hard to manage over time, and it’s even worse as you add additional routes and features to your app. We completely avoid the typical front-end app catastrophe: “Oh hey, we’re mounted the user profile view - I guess that means six different components are requesting user data. It’s not a DDOS if you do it on purpose.”

To solve for this, we use Redux for global state management and directly tied its logic to routing. This means we don’t even have to think about the app. We just have to think about routes - our data store was on its own little island.

This meant we had to tolerate a little context switching. Components were selected from Redux and Redux was loading depending on the route you were on. We ultimately decided we’re okay with that tradeoff. It means that at most, we would only ever have to know about routes, selectors, and the component that consumed the selector. It’s not perfect, but it’s never scaled to more than three different files.

Now You Try

Let’s walk through how we set this up so you can try it out for yourself. We'll go over setting up our server for server-side rendering, creating entry points for our browser, and building some data loaders.

Loading Data First

First, let’s solve for the primary SSR problem: how do we load our data without loading our app?

You’ll need three things:

  1. A way to load your data
  2. A way to store your data
  3. A way to know when you need to load more data

Conveniently enough, those problems get solved by three global objects:

  1. Axios instance
  2. Redux
  3. React Router History module

We’re going to consider these global per user request, which means each object can access the other objects and all components can access them. However, these objects should work without the React UI, so they’ll never be able to access components. This allows us to load data without rendering the app using these objects.

For Redux, we use thunk and set these global objects as our extra argument. For Axios, this object is assigned to every request config via an interceptor. And for History, these services are in scope when we add our location listener.

Entry Points

With this in mind, we can write two entry points. One for the browser (which we bundle with webpack), and one for the server (which is hit on every H document request as a Koa middleware).

In the Browser

The browser endpoint is really straightforward. It has to do the following:

  • Initialize history
  • Create our Axios Client
  • Load the Redux state using the global state set to window.__REDUX_STATE__
  • Hydrate the React app using ReactDOM.hydrate

Here’s what the code looks like:

const initialState = window.__REDUX_STATE__ const services = {}
services.history = createHistory()
services.client = configureClient(() => services)
services.store = configureStore(initialState, services) let prevLocation = null
services.history.listen((location) => { // you really should put this inside your app // that way you can catch errors, etc loadDataForLocation(prevLocation) prevLocation = location;
}) ReactDOM.hydrate( <App {...services} />, document.getElementById('react-container'),
)

This looks a lot like most single page web apps. The notable difference is the loading of Redux outside of the app and the reference to the global default state. Where does that global state come from? Glad you asked!

On the Server

The server does similar tasks, but making them work together requires a different approach. Inside asynchronous Koa middleware, we:

  1. Initialize history from the Request URL
  2. Create our Axios Client
  3. Pass the initialized history to our AWESOME STANDALONE DATA LOADER
    - Wait for this loader to load Redux state via asynchronous thunks for any given url
  4. Pass this hydrated Redux store to our app and synchronously render it
  5. Render the outer HTML page, including page meta tags
    - We also serialized our Redux state and set it to a global variable
  6. Send the React app to the user as a 200 HTML document

It looks like this:

const render = async (ctx, next) => { if (!ctx.accepts('html')) { return next() } const initialState = {} const services = {} services.history = createHistory() services.client = configureClient(() => services) services.store = configureStore(initialState, services) await services.store.dispatch(loadDataForLocation()) const app = ReactDom.renderToString(<App {...services} />) const html = ReactDom.renderToStaticMarkup( <Html store={services.store} app={app} />, ) ctx.body = `<!DOCTYPE html>${html}` return next()
}

Patterns We’re Using

These global objects are really dependencies we’re injecting into our app. This is actually really nice! It means you can test your dependencies outside of your app and you can inject test dependencies.

These globals also need to be global to the React app we’re making. This means we’ll need to use React’s context and provider system to pull this off. These global objects are great for context though because they never change during the lifecycle of your app - so you won’t have to worry about unwanted component updates, etc.

Conclusion

Key Learnings

Patterns, good! Buried global data fetching, bad!

SSR feels so much better than we initially thought it would. Showing users your content instead of the app spinner of mediocrity - err, the loading indicator - is a refreshing experience. It was a huge surprise to us when our number one comment on our Search app was “it’s fast.” The services aren’t that much faster than other services we maintain. It just feels fast. That’s a pretty big win for us, because we want Rioters to enjoy using our tools.

Global services are also actually a great idea. Browser states are global, so data based on the browser state should be too. There’s zero chance of redundant requests when you load everything in one place and you can find out immediately if you’re not getting the data you need by inspecting the Redux state on load.

Surprises

First of all, building this was easier than we expected! A new route meant adding a new data loader and a new scene that selected data from Redux. And… that’s it. This scales remarkably well because unless you accidentally make up the same route for different pages, routes don’t collide with each other.

Our other big surprise was that performance benefits were less helpful than strong application patterns. While we were happy that the app felt fast to our users, the real benefit to our team was just how manageable adding content was.

Going Forward

Yes to SSR and universal rendering! All of our larger scale apps going forward will be built with this in mind. For anything that requires a larger number of user features and a sufficiently complicated data store, this is going to be how we’d like to build our UI.

Thanks for checking out this article. Be sure to check out my github for more info! If you have any questions, please reach out in the comments below.