Isomorphic JavaScript


Building a shared JavaScript codebase that runs on both the client and the server


April 2016 — Fox Sports Australia — @fknussel

Agenda

  • Why: the problem
    1. Brief review of the evolution of webapps
    2. About why we need isomorphic apps

  • What: the solution
    1. What isomorphic means and why it's a powerful solution

  • How: implementation details and code examples
    1. Rendering on both the client and the server using React, Node and Express

The evolution of webapps

... or some notes on why we need to talk about isomorphic JavaScript

Building a product carousel

Classic webapps

Classic webapps

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.

Classic webapps pros and cons

  • Easily indexed by search engines because all of the content is available when the crawlers traverse the application.
  • Initial page load is optimized because the critical rendering path markup is rendered by the server, which improves the perceived rendering speed.
  • We have full page reloads on transitions (╯°□°)╯︵ ┻━┻
    It requests, receives, and parses a full document response when users submit a form or navigate to a new page, even if only some of the page information has changed.

AJAX-powered webapps

AJAX-powered webapps

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.

AJAX-powered webapps pros and cons

  • Bad SEO.
  • Initial page load is optimized.
  • We no longer need full page reloads on transitions, but...
    • Division and replication of the UI / View layer.
    • Makes the application difficult to follow and maintain—one cannot easily derive how an application ended up in a given state.
    • Two UI codebases makes it highly probable that bugs get introduced when a feature is added or modified.
    • Need for more tests.

Single Page Apps

SPA: performance

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.

SPA: well defined roles

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.

SPA and web crawlers

SPAs are not SEO friendly by default. The problem stems from the fact that SPAs leverage the hash fragment for routing (history API).

$$$ Alternatives

We could also outsource the problem to a third party provider, such as BromBone or PreRender.

So, let's recap

#1: Performance

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.

#1: Performance (cont'd)

Initial page load times have become more critical than ever before

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

#2: SEO

The app should be able to be indexed by search engines

... because, surprise surprise, we want people to find us.

#3: Optimized page transitions

The application should be responsive to user interactions

... because this is 2016 and we can do better than full page reloads all the times ¯\_(ツ)_/¯

The Origins

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.

Eye-so-what?! ™

  • Isomorphic JavaScript applications are defined simply as applications that share the same JavaScript codebase between the browser client and the web application server.
  • Such applications are isomorphic in the sense that they take on equal (iso) form or shape (morphic) regardless of which environment they are running on, be it the client or the server.

Taking the best out of both worlds

This is how it works

  1. On first page load, serve real server-rendered HTML.
  2. Client-side JS app bootstraps on top of server-rendered HTML rather than bootstrapping into an empty div.
  3. From that point on, it's a client-side JS app.

Benefits review

  • Single code base for the UI with a common rendering life cycle. No code duplication or blurring of responsibilities.
  • Optimized page load by rendering the first page on the server. No waiting for network calls and displaying loading indicators before the first page renders.
  • SEO support using fully qualified URIs by default, no more #! workaround required
  • Optimized page transitions in modern browsers that support the history API, gracefully degrades to server rendering for clients that don't support the history API
  • Free progressive enhancement!

Rendering in both the client and the server: Different use cases

1. No state shared between client and server


// 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'));
    

Templating: Using Handlebars

Handlebars

2. Sharing state

  • Scenario: let's say we wanna display some data fetched from an API.
  • We wanna have the client pick up where the server left off.
  • Avoid hitting the same API endpoint twice.
  • After the app loaded on the browser, the first render doesn't destroy the DOM generated by the server, rather "hooks" onto it → DOM DIFF = ∅
  • Serialize state on the server and pass it over to the client (aka: Rehydration).
  • This allows us to initialize the app on the client in the exact same state it was on the server before being sent back in HTML form.

2. Sharing state (cont'd)


// 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'));
    

3. Handling routing

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);
});
    

3. Handling routing (cont'd)

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);
});
    

4. Sharing state and handling routing simultaneously

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);
  });
});
    

4. Sharing state and handling routing simultaneously (cont'd)


// 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();
  });
});
    

5. Turning isomorphism off through shared configs

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
};
    

5. Turning isomorphism off through shared configs (cont'd)


// 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...
  }
});
    

5. Turning isomorphism off through shared configs (cont'd)


// 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...
    

6. (Free) Progressive Enhancement

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.

6. (Free) Progressive Enhancement (cont'd)

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.

6. (Free) Progressive Enhancement (cont'd)


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
  }
});
    

6. (Free) Progressive Enhancement (cont'd)


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));
  }
});
    

6. (Free) Progressive Enhancement (cont'd)

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' });
      });
    }
  }, // ...
};
    

Links and Resources