7th January 2021 • 6 min read
How to create microfrontends with Web Components in React
Vladimir Zotov
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.
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).
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) :
1/** src/index.jsx */
2
3import React from "react";
4import ReactDOM from "react-dom";
5
6// Name of our class doesn't matter.
7class EvilPlanElement extends HTMLElement {
8 // Happens every time an instance of this element is mounted
9 // (can be called again when moved from one container element to another)
10 connectedCallback() {
11 ReactDOM.render(
12 <button onClick={() => alert("one million dollars!")}>
13 Hold the world ransom for...
14 </button>,
15 this
16 );
17 }
18}
19
20const tagName = "evil-plan";
21
22if (!window.customElements.get(tagName)) {
23 // prevent rerunning on hot module reloads
24 // register to be rendered in place of every <evil-plan> tag
25 window.customElements.define(tagName, EvilPlanElement);
26}
Then we can use it like any other HTML element:
1<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):
1import React from "react";
2import ReactDOM from "react-dom";
3import { App } from "./App";
4
5class EvilPlanElement extends HTMLElement {
6 _ransom = "one million dollars!";
7
8 get ransom() {
9 return this._ransom;
10 }
11
12 // every time this JS property is changed on a DOM element like this `document.querySelector('...').ransom = 'blah';`
13 set ransom(value) {
14 this._plan = value;
15 this.render();
16 }
17
18 // only changes to these HTML attributes will trigger `attributeChangedCallback` method
19 static get observedAttributes() {
20 return ["ransom"];
21 }
22
23 attributeChangedCallback(name, oldValue, newValue) {
24 // when someone changes HTML or does element.setAttribute('ransom', '1 million dollars')
25
26 switch (name) {
27 case "ransom":
28 // it doesn't have to match JS property name on the element
29 // newValue is always a string
30 this._ransom = newValue;
31 return;
32 default:
33 // do nothing
34 return;
35 }
36 }
37
38 render() {
39 // renders your App within this element
40 ReactDOM.render(<App ransom={this.ransom}></App>, this);
41 }
42
43 connectedCallback() {
44 // every property assigned to the element before your component was defined, is available now
45 this.render();
46 }
47}
48
49const tagName = "evil-plan";
50
51// condition to prevent rerunning on hot module reloads
52if (!window.customElements.get(tagName)) {
53 window.customElements.define(tagName, EvilPlanElement);
54}
Bundling
Let’s bundle it up! I’m using a shiny new library esbuild:
1yarn add -D esbuild
Then add/update build command in your package.json:
1"scripts": {
2 ...
3 "build": "esbuild src/index.jsx --outfile=build/bundle.min.js --bundle --minify --define:process.env.NODE_ENV='production' --target=chrome58,firefox57,safari11,edge16"
4}
1yarn build
2...
3Done 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
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>.
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