How to create microfrontends with Web Components in React

Vladimir Zotov's photo
Vladimir Zotov
Updated 2023-11-13 路 6 min
Table of contents

We often hear about microfrontends, this happens when your company plans to have multiple teams build a big new web project. The company does not want teams to fight over a single monstrous SPA.

It decides to break the project down into several mini-apps hosted by a parent app. Each team then can work in isolation, pick their own tech stack, preferred flavor of agile and so on.

How it works

The parent app is agnostic of the technologies/frameworks used in the mini-apps/micro-frontends. We need an API to integrate these microfrontends and there are a few popular choices. In this article we'll use Web Components as the common language between microfrontends and their parent app.

Web components track their own loading, unloading and changes to properties and HTML attributes. Another huge benefit is CSS encapsulation (more on that below).

But of course we will have to integrate our root React component into a Web component. As for the parent app, it is quite simple - render my-child-app tag and load an accompanying script bundle of the child app (by adding script tags).

Web components 101

Making a primitive web component was surprisingly easy (considering the notoriously clunky DOM APIs). You can read about the API here.

First we define a class inheriting from HTMLElement. Then we register it using window.customElements.define("evil-plan", EvilPlanElement) :

/** src/index.jsx */
import React from "react";
import ReactDOM from "react-dom";
// Name of our class doesn't matter.
class EvilPlanElement extends HTMLElement {
// Happens every time an instance of this element is mounted
// (can be called again when moved from one container element to another)
connectedCallback() {
ReactDOM.render(
<button onClick={() => alert("one million dollars!")}>
Hold the world ransom for...
</button>,
this
);
}
}
const tagName = "evil-plan";
if (!window.customElements.get(tagName)) {
// prevent rerunning on hot module reloads
// register to be rendered in place of every <evil-plan> tag
window.customElements.define(tagName, EvilPlanElement);
}

Then we can use it like any other HTML element:

<evil-plan ransom="a single dollar"></evil-plan>

Here is a complete working example.

馃挕 Make sure you define a custom element with a prefix, e.g.聽myproject-button.
馃挕 Cannot be a self-closing tag.
馃挕 Currently, there is no API to remove or redefine custom elements. If you call the window.customElements.define twice, it will throw an error:

Uncaught DOMException: CustomElementRegistry.define: 'evil-plan' has already been defined as a custom element. You would not care about it in production.

But in dev mode you probably have a Hot Module Replacement (HMR) that will try to rerun your modules on every change you make to the source files.

Parameterisation

Child apps always end up needing a few parameters from their parent, like an authentication function to get an access token for some API.

You can intercept changes to JS properties defined on an HTML element and to HTML attributes (string-only values):

import React from "react";
import ReactDOM from "react-dom";
import { App } from "./App";
class EvilPlanElement extends HTMLElement {
_ransom = "one million dollars!";
get ransom() {
return this._ransom;
}
// every time this JS property is changed on a DOM element like this `document.querySelector('...').ransom = 'blah';`
set ransom(value) {
this._plan = value;
this.render();
}
// only changes to these HTML attributes will trigger `attributeChangedCallback` method
static get observedAttributes() {
return ["ransom"];
}
attributeChangedCallback(name, oldValue, newValue) {
// when someone changes HTML or does element.setAttribute('ransom', '1 million dollars')
switch (name) {
case "ransom":
// it doesn't have to match JS property name on the element
// newValue is always a string
this._ransom = newValue;
return;
default:
// do nothing
return;
}
}
render() {
// renders your App within this element
ReactDOM.render(<App ransom={this.ransom}></App>, this);
}
connectedCallback() {
// every property assigned to the element before your component was defined, is available now
this.render();
}
}
const tagName = "evil-plan";
// condition to prevent rerunning on hot module reloads
if (!window.customElements.get(tagName)) {
window.customElements.define(tagName, EvilPlanElement);
}

Bundling

Let鈥檚 bundle it up! I鈥檓 using a shiny new library esbuild:

yarn add -D esbuild


Then add/update build command in your package.json:

"scripts": {
...
"build": "esbuild src/index.jsx --outfile=build/bundle.min.js --bundle --minify --define:process.env.NODE_ENV='production' --target=chrome58,firefox57,safari11,edge16"
}
yarn build
...
Done in 0.57s

CSS story

One of the selling points of web components is CSS isolation. It is not enabled by default, but you can do it using ShadowRoot.

The latter is an optional node, that can be placed into any element. If you want a global style, say h1 { color: red } to be only applied inside your component, append a <link> tag into a shadow root node.

In React you can use a package react-shadow.

Even built-in elements have a shadow root. For example <input type="number"> has spin-button elements inside it:

Input number
Input number

If you use create-react-app to set up your app, by default your CSS will be injected into <head>. But the shadow root of our component ignores all of the styles that are not inside of it!

The easiest solution is to use CSS-in-JS, for example @emotion/react package. To configure where to inject your styles, use <CacheProvider> component (confusing name, I know).

Wrap it around your root component and any of the calls to css helper function will inject and cache styles in the shadow root and not in <head>.

Comparison with single-spa

One of the popular libraries for building microfrontends is single-spa.

It has several wrapper packages to integrate child apps built with the most popular frameworks: React, Angular, etc. To load a child app in the parent use a Parcel object.

It is pretty hard decide what to pick for an actual project: Web Components or single-spa.

Here are the pros and cons:

Single SPA

馃憤 Pros

  • Popular, has several maintainers
  • Nice documentation (though a bit confusing/overwhelming)
  • Child app wrapper is small and easy to use
  • CRA-like project bootstrapper configures webpack so you don't have to

馃憥 Cons

  • Custom framework
  • Too much Webpack magic
  • Relies on SystemJS to load child app scripts into parent (SystemJS is not that popular anymore, native modules are only coming in Webpack 5)
  • No out-of-the-box way to make CSS-modules work (you can use CSS-in-JS instead)

Web components

馃憤 Pros

  • Well-documented
  • Built into all major browsers except IE and Edge 16-18 (before Edge migrated to use Chromium under the hood). For legacy browsers use this polyfill.

馃憥 Cons

  • Web Component API/lifecycle is not straightforward
  • You support it (I only found two complete solutions, e.g. this package)
  • Must manually load chunks based on your app's asset-manifest.json, produced by CRA build command

Recent articles

Guide to fast Next.js

Insights into how Tinloof measures website speed with best practices to make faster websites.
Seif Ghezala's photo
Seif Ghezala
2024-01-15 路 13 min