Universal / Isomorphic Web App
I've been working in the world of client side applications for a while now and have really enjoyed using React and Redux. Together their simplicity has allowed me to code in a very deterministic fashion, knowing that if my unit tests pass the page will render. Though working solely in the client has some limitations, and since we own the server it feels a waste to not take advantage of it when rendering pages. The most apparent example comes about when any page needs to load data prior to rendering. When the HTTP request hits the server we could load that data. But instead we send back a small HTML skeleton and the JavaScript makes a subsequent request to load the data. It would be great if we could have knowledge of the UI's responsibilities during that initial request to the server.
When putting together this blog I wanted to do some things programmatically, and ended up choosing React components for the blog posts themselves. These are rendered on the server and plain HTML is sent to the browser. There wasn't really a need to build in client side support, since the pages have no interaction. Implementing this gave me an early insight into the world of universal web applications. Even though it wasn't full universal, I was able to understand both server side and client side rendering individually. My next goal was to combine them in a single application.
One of the most difficult pieces of building a universal application is getting all the various technology pieces to "play nice" with each other. Most of the starter kits I had researched were either too big, or didn't combine the technologies I wanted to use. So taking bits and pieces, I assembled an application which hopefully illustrates a simple example of a universal (client and server rendering) web application. The application has the following main technologies:
- React
- React Router 4
- Redux
- CSS Modules (using Sass)
- Express
- Webpack 2
- Babel
- ESLint
The web application source code can be found here: https://github.com/dylants/universal. The remainder of this post reviews some of the high level concepts explored within this application.
Route Configuration
Within this application, we have both client side routes and server side routes. I have chosen to name them after the technologies which use them, so React routes and Express routes. Let's focus first on the client side routes, or React routes.
React Routes
React Router is used to drive the routing on the client side. Its route definition is quite simple — an array of objects. Below is the configuration for our routes within the application:
import App from '../containers/app/app.container'; import Home from '../containers/home/home.container'; import Page1 from '../containers/page1/page1.container'; import Page2 from '../containers/page2/page2.container'; export const routes = [ { component: App, routes: [ { path: '/', exact: true, component: Home, }, { path: '/page1', exact: true, component: Page1, }, { path: '/page2', exact: true, component: Page2, }, ], }, ];
This states that we have 3 routes within our application: Home
, Page1
, and Page2
. A wrapping component (App
) contains all the subcomponents. In addition we've taken advantage of the exact
flag to denote that these routes will only trigger when an exact URL match occurs. This is important since our root route (/
) would trigger for all routes without this flag.
Express Routes
In addition to the React routes we've defined above, our application supplies some APIs which help populate data needed by those views. There's also the server side route which provides us a hook to render the client side view. Using Express routing we've constructed the following routes file:
import { loadPage1Data } from '../controllers/page-data.controller'; import render from '../controllers/render.controller'; module.exports = (router) => { router.route('/api/page/1').get(loadPage1Data); // if at this point we don't have a route match for /api, return 404 router.route('/api/*').all((req, res) => res.status(404).send({ error: `route not found: ${req.url}`, }), ); // all other routes are handled by our render (html) controller router.route('*').all(render); };
Here we've created an API for a GET
request of page 1's data, which is handled by the imported controller function. There are also two catch-all routes. The first is positioned after all the API routes have been defined, and responds with a 404
when a request is made to some undefined API. The second is a global match which is placed after everything else, and handles rendering on the server. This allows us to fall back to the React routes for routes not defined here.
Rendering on the Server
React Router comes with a set of tools to help you in constructing your routes, two of which are matchRoutes
and renderRoutes
found in react-router-config
. The server takes advantage of both of these functions when rendering HTML.
Match Routes
The render controller is running on the server, and unlike the client, does not have access to the React component lifecycle. Therefore we have to establish a pattern for loading initial data for the components to use. We do this in part through the matchRoutes
function. This helps us find which component(s) will be rendered, and then from those, load the data. Once the data has been loaded we can render the views as we would normally.
import _ from 'lodash'; import createMemoryHistory from 'history/createMemoryHistory'; import { matchRoutes } from 'react-router-config'; import configureStore from '../config/store'; import { routes } from '../routes/react-routes'; export default function render(req, res, next) { // create a new history and redux store on each (server side) request const history = createMemoryHistory(req.url); const store = configureStore(undefined, history); // determine the list of routes that match the incoming URL const matches = matchRoutes(routes, req.url); // build up the list of functions we should run prior to rendering const promises = matches.map(({ route }) => { // for each, return the fetchData Promise or a resolved Promise const fetchData = _.get(route, 'component.WrappedComponent.fetchData'); return fetchData ? fetchData({ store }) : Promise.resolve(); }); ... }
The above code sets up a new Redux store on each request (so each server side request is a blank slate — keep in mind this is fine, since the client should take over for future requests and retain the Redux state). It then finds the list of components which match the incoming URL. From those components, it grabs the fetchData
function which it runs sending it the Redux store. If the fetchData
function does not exist, a resolved Promise is used. The list of all these Promises is stored in our promises
variable.
So some of the components will have defined the static fetchData
function, while others will not, and instead we'll use the resolved Promise. In our example application, we have the Page1
component which defines it's fetchData
function as below:
import React, { Component } from 'react'; import { loadPage1Data } from '../../actions/page.actions'; class Page1 extends Component { static fetchData({ store }) { return store.dispatch(loadPage1Data()); } ... }
With the passed in store
we're able to get access to the Redux dispatch
which allows us to fire off an action. In this case, it's the loadPage1Data
action.
Render Routes
After the render controller has matched the routes and loaded the data (or at least, collected a set of Promises), it can then proceed to render the React routes:
import React from 'react'; import { Provider } from 'react-redux'; import { StaticRouter } from 'react-router'; import { renderRoutes } from 'react-router-config'; import { renderToString } from 'react-dom/server'; import { routes } from '../routes/react-routes'; export default function render(req, res, next) { ... // build up the list of functions we should run prior to rendering const promises = ... // load all the data necessary to render the route return Promise.all(promises) .then(() => { // get the HTML content for our server side rendering const htmlContent = renderToString( <Provider store={store}> <StaticRouter history={history} location={req.url} context={{}}> {renderRoutes(routes)} </StaticRouter> </Provider>, ); // get the state from the store to send it to the client // http://redux.js.org/docs/recipes/ServerRendering.html#security-considerations const reduxState = JSON.stringify(store.getState()).replace(/</g, '\u003c'); // return the rendered index page with included HTML content and redux state return res.render('index', { reduxState, htmlContent, }); }) .catch(err => next(err)); }
The above code waits for all the promises to complete and then proceeds to render the HTML content. It does this using a Redux Provider
and a React Router StaticRouter
, along with the renderRoutes
call on the React routes. This HTML content can then be returned to the user as server side rendered React components. However, the client side will take over in our universal application, and it needs to be aware of the data we've loaded and stored in our Redux store. So in addition to the HTML content, we also send the Redux store in the response for the client to parse on startup.
Rendering on the Client
When the client initializes, it can take the Redux store that was populated by the server as its initial state. Then when the React component lifecycle takes place, the Redux action which would normally load the data now sees that the data already exists. Instead of performing a separate HTTP call, the client can simply do nothing. When React renders the components in its virtual DOM, it can verify that the rendered HTML matches. This allows it to skip a replacement and leave the rendered HTML.
Below is the client configuration file:
/* global window, document */ import React from 'react'; import ReactDOM from 'react-dom'; import createBrowserHistory from 'history/createBrowserHistory'; import { ConnectedRouter } from 'react-router-redux'; import { Provider } from 'react-redux'; import { renderRoutes } from 'react-router-config'; import configureStore from './store'; import { routes } from '../routes/react-routes'; const initialState = window.__REDUX_STATE__; delete window.__REDUX_STATE__; const history = createBrowserHistory(); const store = configureStore(initialState, history); ReactDOM.render( <Provider store={store}> <ConnectedRouter history={history}> {renderRoutes(routes)} </ConnectedRouter> </Provider>, document.getElementById('app'), );
The code above grabs the Redux store off the window
and then uses it as the initial state. The renderRoutes
along with the React routes are both once again used to render the React components for the given URL. Here we're using React Router's ConnectedRouter
to link into Redux.
Best of Both Worlds
Using the pattern established above, the server receives the initial request from the browser and is able to load any initial data necessary and send rendered HTML to the user. This saves the client from having to send another request right back to the server while displaying a "loading" page to the user. (This pattern can also be tweaked so that certain actions which take longer occur only after the user is shown HTML, again with the "loading" animation.) Overall, it avoids the multiple requests which hinder client side only applications by rendering on the server, but still providing the quick interactions of client side rendering after the initial response.