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.