Code.Art.Web

Code.Art.Web

Profile Picture

Lorefnon

Universal data population with React Router and Reflux

Abstract

An opinionated approach for populating data into React/Reflux applications that works on server side and client side with no preassumptions about the backend APIs serving the data.

Universal rendering and data population

A common pattern when building React based applications, as elaborated in the official docs, is to fetch data in componentDidMount lifecycle hooks of components. However this is not useful when we are rendering on server because ReactDOMServer.renderToString is a synchronous operation and does not provide a way for us to wait for ajax calls in lifecycle hooks of components to complete.

This post outlines an alternative solution for applications built around the flux pattern and React Router without resorting to any additional dependencies and without any assumptions about how the APIs are structured (unlike Relay which expects GraphQL).

Our example setup

The examples in this post use Reflux (a flux variant) and Koa (for rendering on the server using Node.js) however the ideas can be translated to other flux variants or node servers without much effort.

Let us start with a minimal middleware to render a React application in Koa:

import renderReactComponents from './middlewares/react_render'

const app = new Koa()

// ... assicate other middlewares
app.use(renderReactComponents)

app.listen(3000)

Our implementation of renderReactComponents is heavily inspired from the React Router docs on server side rendering:

// server.js

import React from 'react'
import { renderToString } from 'react-dom/server'
import { match, RouterContext, createMemoryHistory } from 'react-router'
import routes from './routes'
import { isEmpty } from 'lodash'
import Reflux from 'reflux'

// Koa middleware that attempts to match current URL against
// our routes and handles redirection, rendering and error handling

const renderReactComponents = async (context, next) => {
  try {
    const { redirectLocation, renderProps } = await matchRoutes(context)
    if (redirectLocation) {
      handleRedirect(context, redirectLocation)
    } else {
      renderBody(context, renderProps)
    }
  } catch (error) {
    await handleError(context, error)
  }
}

export default renderReactComponents

// Private:

// Match url in context against our routes
const matchRoutes = async (context) => {

  const history = createMemoryHistory()

  return new Promise((resolve, reject) => {

    // We will see what the routes factory returns below
    const routes = routes(history)
    const location = context.url
    match({ routes, location }, (error, redirectLocation, renderProps) => {
      if (error) {
        // Unable to match any route
        reject(error)
      } else {
        // Route matched
        resolve({ redirectLocation, renderProps })
      }
    })
  })
}

const handleError = async (context, error) => {
  console.error(error.stack)
  context.status = 500
  context.body = 'Something went wrong. Please try again later.'
}

const handleRedirect = async (context, location) => {
  const { pathname, search } = location
  context.redirect(redirectLocation.pathname + redirectLocation.search)
}
// Inject the react string into an HTML layout
// and return the complete page HTML
//
const renderBody = (context, renderProps) => {
  context.body = `
    <html>
      <head>
        <!-- add client side scripts here -->
      </head>
      <body>
        <div id='app'>
          ${renderToString(<RouterContext {...renderProps} />)}
        </div>
      </body>
    </html>`
}

Our routes.js file will expose a factory for returning the hierarchy of routes:

// routes.js

// ... imports

export default history => (
  <Router history={history}>
    <Route path="/" component={App}>
      <IndexRoute component={Home} />
      <Route
        path="products"
        component={ProductsContainer} />
    </Route>
  </Router>
)

If we observe the code above then it becomes obvious that matching of routes and actual rendering of the component tree are two separate steps and before we even call renderToString we already have the hierarchy of the components we need to render. This is something we can utilize to our advantage.

However before we dive into that, let us take a step back and investigate how we would integrate Reflux with React router to facilitate unidirectional data flow.

React Router and unidirectional data flow:

React Router provides us with an onEnter hook (among others) which is triggered when we navigate to specific routes. In this onEnter hook we can trigger asynchronous actions.

// routes.js

// ... imports

export default (history) => (
  <Router history={history}>
    <Route path="/" component={App}>
      <IndexRoute component={Home} />
      <Route
        path="products"
        component={ProductsContainer}
        onEnter={onVisitProductsList}/>
    </Route>
  </Router>
)

const onVisitProductsList = () => initQueryProducts({ ...some parameters... })

Our asynchronous actions would typically have listeners which would perform the ajax requests and dispatch them through child actions:

If you are coming from Facebook's implementation of flux, the actions play the combined role of action creators as well as the dispatchers. Since we can directly listen to the actions (which are just functions) the need for a central dispatcher is eliminated.

// actions.js
import { createAction } from 'reflux'

export const initQueryProducts = createAction({ asyncResult: true })

initQueryProducts.listen((params) =>
  fetchProducts(params) // Perform ajax request using your favorite ajax library
    .then(initQueryProducts.completed)
    .catch(initQueryProducts.failed))

The verbosity of the above example can be simplified using Reflux-promise but for now we stick to the more explicit version.

Our stores would subscribe to initQueryProducts.completed action and would update their data when the action is triggered.

import { createStore } from 'reflux'
import { initQueryProducts } from './actions'

// Store containing our products collection
export const productsStore = createStore({

  listenables: {
    finishQueryProducts: initQueryProducts.completed
  },

  // Triggered by initQueryProducts.completed:
  onFinishQueryProducts(products) {
    this.data = products

    // Notify components about the updated store state:
    this.trigger(products)
  }

})

So when the ajax request is finished, the store will be notified through initQueryProducts.completed action and once the store has been updated, it will trigger the updated state to listeners. Components which are listening to the store would be notified about the updated state and they will re-render to present the updated data to users.

  [onEnter hook of Route]
      |
      V
  [async action] --> [ajax request] --> [async child action]
                                               |
                                               V
                            [component] <-- [store]

Keeping track of async actions

Our flow above works perfectly well on client. However on the server, once the component tree has been constructed we have to wait till the async actions triggered in the route hooks have finished before we can obtain the string representation of the component tree.

If we figure out a way to wait on all these actions then our existing flux setup would ensure that all the components have been bootstrapped with the initial data on the server itself.

In the approach proposed below we defer the responsibility of aggregating these actions to the onEnter handlers:

// routes.js

// ... imports

// Note the addional parameter actions - this is an array which will be
// populated with the actions we need to wait on before rendering
//
export default (history, actions) => (
  <Router history={history}>
    <Route path="/" component={App}>
      <IndexRoute component={Home} />
      <Route
        path="products"
        component={ProductsContainer}
        onEnter={onVisitProductsList(actions)}/>
    </Route>
  </Router>
)

const onVisitProductsList = (actions) => () => {
  // populate an action to be waited upon before we render
  actions.push(initQueryProducts.completed)
  initQueryProducts({ ...some parameters... })
}

Now we would have to modify our matchRoutes implementation to pass the actions array and wait on the actions:

const matchRoutes = async (context) => {
  const history = createMemoryHistory()
  return new Promise((resolve, reject) => {
    const actions = []
    const routes = routes(history, actions)
    const location = context.url
    match({ routes, location }, (error, redirectLocation, renderProps) => {
      if (error) {
        reject(error)
      } else {

        // Wait on the actions before rendering
        Reflux
          .joinTrailing(actions)
          .listen(() =>
            // Now that all actions have finished - resolve the promise
            resolve({ redirectLocation, renderProps })
          )
      }
    })
  })
}

However our implementation still has an issue. Reflux.joinTrailing is not intended to handle 0 or 1 actions - however in our case it is quite possible for certain endpoints to not trigger single actions or no actions at all. So some additional boilerplate is required to handle these cases:

const matchRoutes = async (context) => {
  const history = createMemoryHistory()
  return new Promise((resolve, reject) => {
    const actions = []
    const routes = routes(history, actions)
    const location = context.url
    match({ routes, location }, (error, redirectLocation, renderProps) => {
      const resolveMatch = () => resolve({ redirectLocation, renderProps })
      if (error) {
        reject(error)
      } else if (actions.length == 0) {
        resolveMatch()
      } else if (actions.length == 1) {
        actions[0].listen(resolveMatch)
      } else {
        Reflux.joinTrailing(actions).listen(resolveMatch)
      }
    })
  })
}

Initial data population in client

Our implementation still has an issue. When the client side javascript code boots up we would not have the data available and the client side stores would start up empty, and will load the data all over again.

Besides extra requests, this will result in a poor user experience because the server rendered UI will be replaced with UI rendered with empty stores and eventually again re-rendered when the stores have the data.

React aptly warns us against this:

Warning: React attempted to reuse markup in a container but the checksum was invalid. This generally means that you are using server rendering and the markup generated on the server was not what the client was expecting.

React injected new markup to compensate which works but you have lost many of the benefits of server rendering.

Instead, figure out why the markup being generated is different on the client or server:

If we revisit our matchRoutes implementation above, we observe that in matchRoutes we already have the responses for the actions we waited upon. So from there we can save them to the client.

First of all we augment our route listeners with a unique cacheKey specific to the set of route params:

const onVisitProductsList = () => initQueryProducts({ _cacheKey: 'products:all' })

Our matchRoutes implementation can now construct a registry using this _cacheKey parameter:

import React from 'react'
import { renderToString } from 'react-dom/server'
import { match, RouterContext, createMemoryHistory } from 'react-router'
import { isEmpty, isArray, reduce, escape } from 'lodash'
import Reflux from 'reflux'
// ....

const matchRoutes = async (context) => {
  const history = createMemoryHistory()
  const actions = []
  const routes = routes(history, actions)
  const location = context.url

  return new Promise((resolve, reject) => {
    match({ routes, location }, (error, redirectLocation, renderProps) => {
      if (error) reject(error)
      else listenOnActions(actions, (responses) =>
        resolve({
          redirectLocation,
          renderProps,
          registry: buildRegistry(responses)
        })
      )
    })
  })
}

// Aggregate action responses using _cacheKey
const buildRegistry = (responses) =>
  reduce(responses, (registry, resp) => {
    if (resp._cacheKey) registry[resp._cacheKey] = resp
    return registry
  }, {})

const listenOnActions = (actions, callback) => {
  if (actions.length == 0) {
    callback([])
  } else if (actions.length == 1) {
    actions[0].listen((resp) => callback([resp]))
  } else {
    Reflux.joinLeading(...actions).listen(callback)
  }
}

Our renderBody implementation can now inject this registry into the DOM for consumption into the client.

const renderBody = (context, renderProps, registry) => {
  context.body = `
    <html>
      <head>
        <script id="data-bootstrap" type="text/data">
          ${escape(JSON.stringify(registry))}
        </script>
      </head>
      <body>
        <div id='app'>
          ${renderToString(<RouterContext {...renderProps} />)}
        </div>
      </body>
    </html>`
}

Here is our client side bootstrapper responsible for setting up the router and rendering the top level React component:

// client.js

import 'babel-polyfill'
import { unescape } from 'lodash'
import routes from './routes'
import { browserHistory } from 'react-router'
import { render } from 'react-dom'
import Reflux from 'reflux'
import RefluxPromise from "reflux-promise"
import Promise from 'bluebird'

Reflux.use(RefluxPromise(Promise))

render(routes(browserHistory, []), document.getElementById('app'))

We can expose the injected registry through a proxy module:

import isNode from 'detect-node'

const registry = isNode ? {} :
  JSON.parse(unescape(
    document
      .getElementById('data-bootstrap')
      .innerText
      .trim()
  ))

export default registry

Now our synchronizers will have to be made aware of this cacheKey and they should be able to use the same to fetch from the bootstrapped registry on client when feasible:

import { memoize, extend } from 'lodash'
import axios from 'axios'
import registry from './registry'

export const fetchProducts = query('products')

export const query = (url) => (params) =>
  hasCacheEntry(params) ?
    queryCache(params._cacheKey) :
    queryRemote(url, params)

const apiRoot = () => {
  const root = '/api'
  if (isNode) {
    return `http://localhost:${process.env.PORT}${root}`
  } else {
    return root
  }
}

const apiUrl = (url) => `${apiRoot()}/${url}`

const hasCacheEntry = (params) =>
  params._cacheKey && registy[params._cacheKey]

const queryCache = (key) =>
  Promise.resolve(registy[key])

const queryRemote = (url, params) =>
  axios
    .get(apiUrl(url), { params })
    .then(({ data }) => augmentCacheKey(params, data))

augmentCacheKey = ({ _cacheKey }, data) =>
  extend({ _cacheKey }, data)

Conclusion

This concludes our implementation overview. To summarize we were able to reuse our entire flux implementation on server as well as client - during the route matching phase we aggregated the asynchronous actions and waited for the stores to be populated before we rendered the components. On the client while bootstrapping our application we used the data bootstrapped by the server to hydrate the stores at the beginning which the rendered components picked up during the initial rendering.

Please provide any suggestions for improvements or feedback regarding any issues in the comments below.

Alternatives

Following are some alternative solutions that exist to solve similar problems. They may be especially useful if the application does not follow a flux-like pattern.

comments powered by Disqus
Separator line
Separator line
Lorefnon

Full stack web developer and polyglot programmer with strong interest in dynamic languages, web application development and user experience design.


Strong believer in agile methodologies, behaviour driven development and efficacy of open source technologies.


© 2013 - 2015 Gaurab Paul


Code licensed under the The MIT License. Content and Artwork licensed under CC BY-NC-SA.


The opinions expressed herein are my personal viewpoints and may not be taken as professional recommendations from any of my previous or current employers.


Site is powered by Jekyll and graciously hosted by Github