Building a shared JavaScript codebase that runs on both the client and the server
April 2016 — Fox Sports Australia — @fknussel
... or some notes on why we need to talk about isomorphic JavaScript
Markup is rendered by the application server using a server-side language such as PHP, Ruby, Java, etc. Then JavaScript gets initialized when the browser parses the document, and it's mainly used to enhance the user experience.
The first page of the products carousel is rendered by the application server. Upon pagination, subsequent requests are rendered by the client.
This results on a blurring of the lines of responsibility and also a duplication of efforts, which is not cool.
The application server sends a payload of assets, JS scripts and templates to the client. From there the client takes over only fetching the data it needs to render views.
This improves the rendering of pages since we haven't got the overhead of fetching and parsing an entire new document when users request a new page or submit data.
However, we have slower initial page load times as users need to wait for data to be fetched before the page can be rendered. So instead of seeing content immediately when the pages load they get an animated spinner at best.
In an SPA there is a clear line of separation between the server and client responsibilities. The API server responds to data requests, the application server supplies the static resources, and the client runs the show.
SPAs are not SEO friendly by default. The problem stems from the fact that SPAs leverage the hash fragment for routing (history API).
We could also outsource the problem to a third party provider, such as BromBone or PreRender.
The application first page load should be optimized, i.e. the critical rendering path should be part of the initial response
... because slow initial page load times have a direct impact on businesses.
In 1999, the average user was willing to wait 8 seconds for a page to load. By 2010, 57% of online shoppers said that they would abandon a page after 3 seconds if nothing was shown source
Page load times ultimately impact a company's "bottom line" (aka: conversion rate). Both Amazon and Walmart have reported that for every 100 milliseconds of improvements in their page load, they were able to grow incremental revenue by up to 1% source
The app should be able to be indexed by search engines
... because, surprise surprise, we want people to find us.
The application should be responsive to user interactions
... because this is 2016 and we can do better than full page reloads all the times ¯\_(ツ)_/¯
Charlie Robbins is commonly credited for coining the term "isomorphic JavaScript" in a 2011 blog post titled "Scaling Isomorphic Javascript Code".
The term was later popularized by Airbnb's Spike Brehm in a 2013 blog post titled "Isomorphic JavaScript: The Future of Web Apps".
This means this is state-of-the-art stuff.
#!
workaround required
// server.js
var server = express();
server.use(function (req, res) {
var markup = ReactDOMServer.renderToString(<App />);
var html = injectIntoHtml({ app: markup });
res.send(html);
});
// client.js
ReactDOM.render(<App />, document.getElementById('app'));
// server.js
server.use(function (req, res) {
var state = {};
fetchData(function (err, data) {
state.data = data;
var exposedState = 'window.__STATE__=' + JSON.stringify(state) + ';';
var markup = ReactDOMServer.renderToString(<App data={data} />);
var html = injectIntoHtml({ app: markup, state: exposedState });
res.send(html);
});
});
// client.js
var state = window.__STATE__;
ReactDOM.render(<App data={state.data} />, document.getElementById('app'));
On the server, we can simply get the route by matching the request URL against our route manifest:
// server.js
server.use(function (req, res) {
var route = matchPath(req.url);
var markup = ReactDOMServer.renderToString(<App route={route} />);
var html = injectIntoHtml({ app: markup });
res.send(html);
});
On the client, we use the path from the browser's URL (location API) to match a route:
// client.js
function render(route) {
ReactDOM.render(<App route={route} />, document.getElementById('app'));
}
// first render
var route = matchPath(getCurrentPath());
render(route);
// re-render on browser location change
addLocationChangeListener(function (path) {
var route = matchPath(path);
render(route);
});
On the server, we match the route and attach it to the app state. We then fetch the required data, render the app and send the markup back.
// server.js
server.use(function (req, res) {
var state = {};
var route = matchPath(req.url);
state.route = route;
fetchData(route, function (err, data) {
state.data = data;
var exposedState = 'window.__STATE__=' + JSON.stringify(state) + ';';
var markup = ReactDOMServer.renderToString(<App route={route} data={data} />);
var html = injectIntoHtml({ app: markup, state: exposedState });
res.send(html);
});
});
// client.js
var state = window.__STATE__;
function render() {
ReactDOM.render(
<App route={state.route} data={state.data} />,
document.getElementById('app')
);
}
// first render, server already fetched all data for us
render();
// re-render on browser location change, we do need to fetch new data here
addLocationChangeListener(function (path) {
var route = matchPath(path);
state.route = route;
render(); // render immediately with no/old data, maybe display loading spinner
// fetch data in the background then re-render
fetchData(route, function (err, data) {
merge(state.data, data);
render();
});
});
Useful for development (debugging)
The server just needs to send the serialized config in an empty HTML page (that contains a link to the JS app bundle), and the client will take care of the rest.
// config.js
var config = {
DISABLE_ISO: process.env.DISABLE_ISO
};
// server.js
server.use(function (req, res) {
if (config.DISABLE_ISO) {
var exposedConfig = 'window.__CONFIG__=' + JSON.stringify(config) + ';';
var html = injectIntoHtml({
config: exposedConfig
});
res.send(html);
} else {
var state = {};
// check session, fetch data, render app, expose state/config, send html...
}
});
// client.js
var config = window.__CONFIG__;
api.useConfig(config);
var state;
// first render
if (config.DISABLE_ISO) {
state = {};
var route = matchPath(getCurrentPath());
state.route = route;
// render immediately with no data (can show a loading spinner)
state.data = {};
render();
// fetch data in the background then re-render
fetchData(route, function (err, data) {
merge(state.data, data);
render();
});
} else {
state = window.__STATE__;
render();
}
// add browser location change listener...
What if we "turned off" the client-side portion? That would probably mean disabling JavaScript in the browser, and "going back" to the good old request/response cycle where every route is rendered on the server.
The bare-minimum of Progressive Enhancement is to be able to go from one route to another with JS turned off.
This means using actual link tags (<a>) with their href attribute defined (this is also important for accessibility).
When JS is enabled, you can intercept the click event on the <a> elements to prevent a page refresh and handle the routing on the client.
var Link = React.createClass({
render: function () {
return (
<a {...this.props} onClick={this.handleClick}>{this.props.children}</a>
);
},
handleClick: function(e) {
e.preventDefault(); // It's all about intercepting when JS is enabled
navigateTo(this.props.href); // Otherwise it fallbacks to href
}
});
var CreateContact = React.createClass({
getInitialState: function () {
return { loading: false };
},
render: function() {
<form action="/contacts/create" onSubmit={this.handleSubmit}>
<input ref="name" name="name" />
<button type="submit" disabled={this.state.loading}>Create</button>
{this.state.loading ? 'Loading...' : null} // Enhancement if JS is enabled
</form>
},
handleSubmit: function (e) {
e.preventDefault();
this.setState({ loading: true });
var name = this.refs.name.getDOMNode().value;
navigateTo('/contact/create?name=' + urlEncode(name));
}
});
For this to work properly, we'll need to add hooks to the routing logic (just before the route change) so we can process the form data and then redirect to a new route when done.
// routes.js
var routes = {
'contacts': { path: '/contacts' },
'contact-new': { path: '/contact/new' },
'contact-create': {
path: '/contact/create',
before: function (params, query, done) {
createContact(query, function () {
done({ redirect: true, path: '/contacts' });
});
}
}, // ...
};