Michal Zalecki
Michal Zalecki
software development, testing, JavaScript,
TypeScript, Node.js, React, and other stuff

Render React portals on the server

React works, in what I would call, homogeneous manner. A tree of components is going to be rendered in the given component using a render or recently introduced hydrate function. You are not supposed to change DOM elements created by React or at least do not change components which can return true from shouldComponentUpdate. But what if you need to change an element outside of the React realm? Well, portals are the way to go!

Portals make it possible to escape React mounting DOM container in an elegant way. This problem had been addressed a long time before React DOM 16th introduced an official API to create a portal with createPortal. An example of such library is react-portal or react-gateway.

Managing title and metadata from React

A notorious problem with single page applications is a poor or the lack of support for tools which are not especially great in dealing with JavaScript. Google's crawlers are doing pretty good job in parsing JavaScript-generated content but if you would like to enhance your social media feed you need to be able to generate Open Graph tags on the server.

The goal for today is to create a wrapper for a ReactDOM's createPortal in such way that portals can be rendered on the server.

TL;DR: Use React Portal Universal library.

The first try

Let's forget about the server-side for now and just implement it on the client and see what happens. With following Head component we can modify the content of a head element adding a title and a meta description.

const Head = ({ children }) => createPortal(children, document.querySelector("head"));

const Article = ({ title, description, children }) => (
  <article>
    <Head>
      <title>{title}</title>
      <meta name="description" content={description} />
    </Head>
    <h1>{title}</h1>
    {children}
  </article>
);

You can add Open Graph tags analogically.

Each time you render a single article a new title and a meta description are going to be set. This is great and covers most of the use cases for a library like react-helmet. Which, by the way, is an awesome piece of library solving my problems for the past two years.

The current implementation of createPortal fails when it comes to rendering on the server. The core problem with accommodating React's portals for using in NodeJS is that createPortal expects a DOM node which is not suitable for a NodeJS environment. Trying to render an aforementioned piece of code on the server results in an error:

ReferenceError: document is not defined

The second try

The first thing we need to address is to prevent the server from calling document.querySelector inside a Head component. There are a few ways to tell whether current code is run by the server or by the browser. One of the solutions is to look for a window object.

function canUseDOM() {
  return !!(typeof window !== 'undefined' && window.document && window.document.createElement);
}

We would like to avoid per-component checks and avoid duplication. Let's write our wrapper for createPortal.

function createUniversalPortal(children, selector) {
  if (!canUseDOM()) {
    return null;
  }
  return ReactDOM.createPortal(children, document.querySelector(selector));
}

I would not call it a support yet, but we are heading in the right direction. Now, we are able to use our Head component on the server. This approach is not very fruitful though. Instead of rendering a portal in the element pointed by the selector we just skip rendering altogether.

The third try

To be able to render portals on the server we need two things. The first is the ability to tell the server what components and where should be rendered. The second is the actual way to render them statically in the correct container. We are going to store all server-rendered portals as tuples of React components with corresponding selectors.

// CLIENT

export const portals = [];

function createUniversalPortal(children, selector) {
  if (!canUseDOM()) {
    portals.push([children, selector]); // yes, mutation (҂◡_◡)
    return null;
  }
  return ReactDOM.createPortal(children, document.querySelector(selector));
}

On the server, we can now access portals array and iterate over each portal to render it into a string using renderToStaticMarkup provided by ReactDOM. To append such string into the correct container we can use cheerio. Cheerio is a library for working with HTML strings on the server and provides a subset of jQuery API to do that.

I do not want to go now into great details on how to implement a server-side rendering for a React application. You can read more about the SSR here.

// SERVER

const { load } = require("cheerio");
const { portals } = require("client.js");

function appendUniversalPortals(html) {
  const $ = load(html);
  portals.forEach(([children, selector]) => {
    $(ReactDOMServer.renderToStaticMarkup(children)).appendTo(selector)
  });
  return $.html();
}

const body     = ReactDOMServer.renderToString(<App />));
const template = fs.readFileSync(path.resolve("build/index.html"), "utf8");
const html     = template.replace("<div id=\"root\"></div>", `<div id="root">${body}</div>`);
const markup   = appendUniversalPortals(html);

res.status(200).send(markup);

Now, the server should augment HTML rendered out of React application with the static output of each portal. This implementation is not flawless either. It works on the first render but portals keep aggregating between renders. We need to flush portals on each render.

// CLIENT

const portals = [];

function createUniversalPortal(children, selector) { ... }

export function flushUniversalPortals() {
  const copy = portals.slice();
  portals.length = 0;
  return copy;
}

We don't need to export portals anymore. The server can call flushUniversalPortals on each render. We want to keep things separate, flushUniversalPortals should be defined as a part of the client-side code and access a portals scope.

// SERVER

const { flushUniversalPortals } = require("client.js");

function appendUniversalPortals(html) {
  const $ = load(html);
  flushUniversalPortals().forEach(([children, selector]) => {
    $(ReactDOMServer.renderToStaticMarkup(children)).appendTo(selector)
  });
  return $.html();
}

When you try to reload a page you may notice another problem. Portals are there but as soon as react application renders, portals are going to be added so we'll end up with two titles and two meta descriptions.

This problem is not exclusive to this implementation. As I already said, cratePortal is not implemented with the server use case in mind and does not try to reuse existing DOM tree like render or hydrate functions. If we can mark which nodes were added statically we can then remove them on the client side.

// SERVER

function appendUniversalPortals(html) {
  const $ = load(html);
  flushUniversalPortals().forEach(([children, selector]) => {
    const markup = ReactDOMServer.renderToStaticMarkup(children);
    $(markup).attr("data-react-universal-portal", "").appendTo(selector)
  });
  return $.html();
}
// CLIENT

function removeUniversalPortals() {
  if (canUseDOM()) {
    document.querySelectorAll("[data-react-universal-portal]").forEach((node) => {
      node.remove();
    });
  }
}

// somewhere later in the application
removeUniversalPortals();

Where you call removeUniversalPortals is up to you and your implementation. I recommend calling it just before rendering the application. This should be a reasonable default allowing for rendering an application without problems with reusing existing HTML.

React Portal Universal

Slightly changed implementation is now available as a React Portal Universal library.

npm install react-portal-universal

It is important to make sure that React application code is using the same instance of the library as code responsible for handling rendering on the server. In other words, there must be only one instance of the portals variable in the process. The problem occurs when you import appendUniversalPortals from node_modules on the server but use a bundle with its own instance to render an application.

The cleanest solution is to mark react-portal-universal as an external dependency in your bundler of choice. Here is how to do this in webpack.

const config = {
  externals: ["react-portal-universal"],
};

Summary

This is very generic implementation which does not make almost any assumptions on how you would like to use it. You can now leverage portals and provide good enough support for clients which do not run JavaScript like some crawlers and browsers.

Photo by jesse orrico on Unsplash.