Edge Side Includes (ESIs) in React using higher-order components

Although React is a great way of doing component-based UI development, when creating web applications that are somewhat complicated or need to perform at scale, we may find ourselves running into situations where React seems to be less than helpful.

One such situation that I've been working with recently is getting our React application to support Edge Side Includes.

However, as I've discovered, with a little bit of work we can ensure our React application is able to gain the benefits of ESIs in an elegant and maintainable fashion.

Edge Side Includes (ESIs)

Edge Side Includes (aka ESIs) are a way of caching parts of the page so that effort is offloaded from your application to an edge server, using a service such as Akamai.

For example, consider a <CustomerList /> React component whose job is to render a list of customer firstnames; the rendered HTML for such a a component might look like the following:

<div>
  <ul>
    <li>Jane</li>
    <li>John</li>
    <li>Abigail</li>
  </ul>
</div>

The component JavaScript could look like the following:

// customerList.js
export default class CustomerList extends React.Component {
  constructor(props) {
    super(props);

    return fetch('https://data.example.com/customer_list')
    .then((res) => res.json())
    .then((data) => {
      this.state = {
        data        
      };
    })
    .catch((error) => {
      console.log('Error: ', error)
    })
  }

  static name = 'CustomerList';

  render() {
    return (
      <div>
        <ul>
          { this.state.data.map((customer) => (
            <li>{ customer.firstname }</li>
          )) }
        </ul>
      </div>
    );
  }
}

The important thing to grasp here is that such a list would require our application to fetch the customer firstnames each time it was called, and if our web application receives a lot of interest, this could result in many thousands of calls being made each minute to the data source to obtain this information.

Although one could argue that the application itself could cache such data internally, this is not really the job of the application, and can even become problematic when we run a cluster with multiple copies of our application (e.g. using Docker containers), as different nodes in the cluster could end up having different cached copies of the data, and the result rendered to the user would therefore depend on which node in the cluster served the page to them.

ESIs are very useful in large-scale web applications as not only can they reduce the load on the backend data source, they can also result in lower costs for serving traffic, as the cost of pages served via Edge Servers can be very cheap.

In order to implement an ESI, instead of the above HTML, our application would render an esi:include tag instead, e.g.

<esi:include src-"https//api.example.com/CustomerList?field=html" onerror="continue" />

Then, when the Edge Server sees this tag, it will call to http//api.example.com/CustomerList?field=html to obtain the content, and replace the <esi:include /> with the retrieved content.

In terms of reducing the load on our data source, this of course would be pointless if the Edge Server had to perform this data fetch for every page that it processes; typically, the content being ESIed (https//api.example.com/CustomerList?field=html) will be served with a cache header indicating that the Edge Server can cache it for a certain period of time, and need not refetch it until this cache period expires.

React

Although it may not be obvious at first, ESIs pose a few problems for our React application.

We need a component API

The first is that our <CustomerList /> component content has to be able to be served as a separate bit of content, and not simply as part of the entire page that our application is rendering.

This means that our application needs to provide a separate API that can be queried for a given component, returning just the HTML (and possibly CSS) for this component.

This is actually reasonably simple to create, as the following illustrates:

// componentServer.js
'use strict';

import React              from 'react';
import { renderToString } from 'react-dom/server';
import Helmet             from 'react-helmet';
import jp                 from 'jsonpath';

// create webpack context containing all components
let requireComponent = require.context(
  './', // context folder
  true, // include subdirectories
  /\/[^\.]+\.js$/ // RegExp
);

export default function(req, res) {
  let sendJson        = req.headers.accepts === 'application/json';
  let componentName   = req.params.componentName;
  let body            = req.body;
  let componentProps  = body.props || {};
  let returnField     = body.field;
  let Component;

  if (req.method === 'GET') {
    componentProps = req.query.props
      ? JSON.parse(req.query.props) 
      : componentProps;

    returnField = req.query.field;
  }

  if (typeof returnField !== 'string' && returnField != void 0) {
    return sendError(res, 400, '"field" prop was not a string.', sendJson);
  }

  try {
    Component = requireComponent(`./${componentName}.js`);
  } catch (e) {
    console.error(`Component ${componentName} not found`); // eslint-disable-line no-console
  }

  if (!Component) {
    return sendError(res, 404, 'A component with that name does not exist.', sendJson);
  } else {
    let html;

    try {
      html = renderToString(
        <Component {...componentProps} />
      );
    } catch (e) {
      console.log('Error:', e.message); // eslint-disable-line no-console
    }

    let header = Helmet.rewind();

    // payload header should only contain strings
    Object.keys(header)
    .forEach((attributeName) => {
      header[attributeName] = header[attributeName].toString();
    });

    let payload = {
      html,
      css: Component.css,
      header,
    };

    const contentTypes = {
      css: 'text/css',
    };

    if (returnField) {
      let field = jp.query(payload, returnField)[0];
      return res
      .type(contentTypes[returnField] || 'text/html')
      .status(200)
      .send(field)
      .end();
    }

    if (sendJson) {
      return res
      .status(200)
      .json(payload);
    }

    // return rendered HTML if JSON was not requested
    payload.state = JSON.stringify(payload.state);
    res
    .status(200)
    .render('component_api_layout', payload);
  }
};

Note that we can ask separately for either the html (https//api.example.com/CustomerList?field=html) or the css (https//api.example.com/CustomerList?field=css) of our component.

We need to think about our rendering

Now that we have a means of allowing our Edge Server to fetch the content to replace the <esi:include /> tag, we have to ensure that our component renders appropriately (i.e. differently) in the various contexts in which it will be called:

  • when called via our component API, we expect the component to render the full HTML and / or CSS

  • when called to render serverside via a call to our main application (i.e. when it's constructing the page of which our <CustomerList /> is one part), we expect our component to render an <esi:include /> tag, e.g <esi:include src-"https//api.example.com/CustomerList" onerror="continue" />, rather than rendering the actual HTML of the <CustomerList />

  • when called to render clientside once our page hits the browser, we expect out component to render the full HTML and / or CSS of our <CustomerList />

The last of these requires some thinking about; we WILL be re-rendering clientside whether we like it or not, as the data-react-checksum generated during the server render (i.e. which took into account the serverside-rendered <esi:include />) will be invalid clientside, as the <esi:include /> has been replaced on the page's journey to the browser with the real markup of our <CustomerList /> by the Edge Server.

As React detects that the data-react-checksum is no longer valid, it will re-render the DOM, and will call our <CustomerList /> to re-render clientside.

It's worth noting that, in the recently-released React 16, data-react-checksum is no longer used, and therefore it may be possible to avoid the clientside re-render immediately that the page is loaded, but you'll still want to be able to cope with clientside re-rendering of the <CustomerList /> at some point.

An exercise in futility?

So, given that clientside our <CustomerList /> needs to render the full HTML, this makes the whole of the article to this point sound like an exercise in futility; what is the point of using an Edge Server to reduce the load on our data source from serverside calls by our application, if we simply end up calling the data source clientside from the browser?

The answer is that, clientside, we have to be a little smarter, in that we don't get our <CustomerList /> component to render the full HTML, but rather we get it to re-use the DOM that was injected by the Edge Server.

Thus, the only time that our <CustomerList /> component actually calls the data source and renders fresh HTML containing the firstnames of our customers is when the Edge Server calls our component via our new component API (i.e. https//api.example.com/CustomerList?field=html).

If we set caching headers on the https//api.example.com/CustomerList?field=html response for, say, 180 seconds, then our component will only be asked to do a data fetch every three minutes or so, rather than on every request.

An ESI higher-order component

So, now that we've worked out the mechansim to get ESI content to appear, we need to implement it.

One naive way would be to amend each component we wish to ESI, such that it renders in different contexts according to the logic described above.

However, this means that, should we have many components that we wish to ESI, we will have to make similar changes in each component, and be prepared to change all of the components whenever we wish to make modifications.

A better approach is to use a higher-order component, which allows us to wrap our <CustomerList /> with the ESI logic, while not having to modify <CustomerList /> at all.

Our main app component would look somewhat like follows where, instead of rendering <CustomerList />, it would now be rendering <WrappedCustomerList />:

// app.js 
import React               from 'react';
import WithEdgeSideInclude from 'withEdgeSideInclude';
import CustomerList        from 'customerList';

const WrappedCustomerList = WithEdgeSideInclude(CustomerList),

class Page extends React.Component {
  constructor(props) {
    super(props);
  }

  render() {
    return (
      <WrappedCustomerList />
    );
  })
}

In the above, we are using <WithEdgeSideInclude /> to wrap our <CustomerList /> component, and in doing so <WithEdgeSideInclude /> will be responsible for deciding whether <CustomerList /> renders its full HTML, or whether something else should be rendered instead.

Our <WithEdgeSideInclude /> higher-order component will look something like the following:

// withEdgeSideInclude.js
'use strict';

import React  from 'react';
import config from 'config';

let SUPPRESS_ESI = config.suppressEsi;
let BUNDLE_TYPE = config.bundleType;

module.exports = function withEdgeServerInclude(WrappedComponent) {
  return class extends React.Component {
    constructor(props) {
      super(props);
    }

    static name = 'WithEdgeServerInclude';

    shouldComponentUpdate() {
      // Only allow rerendering if we're not using an ESI
      return ! SUPPRESS_ESI
        ? false
        : true;
    }

    getExistingHtml(selector, property) {
      const element = global.document.querySelector(selector);
      return element
        ? element[property]
        : null;
    }

    render() {
      // Don't re-render the included ESI client-side, but instead
      // re-use the edge-server-rendered DOM.
      if (BUNDLE_TYPE === 'client') {
        const css = this.getExistingHtml(`#${WrappedComponent.name}__styles`, 'innerHTML');
        const html = this.getExistingHtml(`.${WrappedComponent.name}`, 'outerHTML');

        if (html && css) {
          return (
            <div>
              <style
                id={`${WrappedComponent.name}__styles`}
                dangerouslySetInnerHTML={{ __html: css }}
              />
              <div dangerouslySetInnerHTML={{ __html: html }} />
            </div>
          );
        }
      }

      // Render an esi:include unless we're configured to suppress
      if (! SUPPRESS_ESI) {
        return (
          <div>
            <style
              id={`${WrappedComponent.name}__styles`}
              dangerouslySetInnerHTML={{ __html: `<esi:include src="https//api.example.com/${WrappedComponent.name}?field=css" onerror="continue" />` }}
            />
            <div
              dangerouslySetInnerHTML={{ __html: `<esi:include src="https//api.example.com/${WrappedComponent.name}?field=html" onerror="continue" />` }}
            />
          </div>
        );
      }

      // Render the full component
      return (
        <WrappedComponent {...this.props} />
      );
    }
  }
};

Note the following about withEdgeSideInclude.js:

  • We use a config boolean, SUPPRESS_ESI, to determine whether we wish to render the full HTML of our wrapped component; this is helpful for local development, where we want to see the rendered HTML and not the <esi:include /> (as requests to localhost will not be routed via our Edge Server, and therefore any <esi:include /> will not be replaced by the full HTML).
    In this case, the relevant part of the render function is below:

      // Render the full component
      return (
        <WrappedComponent {...this.props} />
      );
    
  • If we are rendering serverside, we render the <esi:include >s. In this case, the relevant part of the render function is below; note that in this case we use two <esi:include >s, one for the CSS and one for the HTML.

      // Render an esi:include unless we're configured to suppress
      if (! SUPPRESS_ESI) {
        return (
          <div>
            <style
              id={`${WrappedComponent.name}__styles`}
              dangerouslySetInnerHTML={{ __html: `<esi:include src="https//api.example.com/${WrappedComponent.name}?field=css" onerror="continue" />` }}
            />
            <div
              dangerouslySetInnerHTML={{ __html: `<esi:include src="https//api.example.com/${WrappedComponent.name}?field=html" onerror="continue" />` }}
            />
          </div>
        );
      }
    
  • If we are rendering clientside, we re-use the HTML and CSS that was injected by the Edge Server.
    In this case, the relevant code is below:

      getExistingHtml(selector, property) {
        const element = global.document.querySelector(selector);
        return element
          ? element[property]
          : null;
      }
    
      ...
    
      // Don't re-render the included ESI client-side, but instead
      // re-use the edge-server-rendered DOM.
      if (BUNDLE_TYPE === 'client') {
        const css = this.getExistingHtml(`#${WrappedComponent.name}__styles`, 'innerHTML');
        const html = this.getExistingHtml(`.${WrappedComponent.name}`, 'outerHTML');
    
        if (html && css) {
          return (
            <div>
              <style
                id={`${WrappedComponent.name}__styles`}
                dangerouslySetInnerHTML={{ __html: css }}
              />
              <div dangerouslySetInnerHTML={{ __html: html }} />
            </div>
          );
        }
      }
    

Note that in the clientside rendering, we grab the DOM elements that were injected by the Edge Server, and simply render these using dangerouslySetInnerHTML, rather than render them fresh.
In this way we avoid a clientside call to our data source.

Conclusion

As the above examples have outlined, we can gain the substantial benefits of ESIs in React applications with two particular modifications to our application:

  • a component API, to allow the Edge Server to retrieve the HTML and CSS for a specific component

  • a <WithEdgeSideInclude /> higher-order component, which wraps the component we wish to ESI, and determines whether the wrapped component should be allowed to do the data fetch and render its HTML, or whether an <esi:include /> should be rendered (serverside) or whether the Edge Server injected DOM should be re-used (clientside).

Although, at least in React 15, we can't easily avoid the clientside rerender caused by the invalid data-react-checksum, we can ensure that we don't lose the benefits of ESIs during the clientside rerender.