micro-frontends
Tutorials |

Using Framework Agnostic Web Components in your React Application

Jannik

December 1, 2020

Whether you are building an application on the principles of micro frontends or just want to use a dynamically loaded web component in your React application, this blog post will guide you through the necessary steps to load the assets required to run and embed the component in your application.

On Web Components

If you are not yet familiar with web components, the Mozilla Developer Network article on the topic is probably a good place to start learning about them. In short: Web Components allow the developer to encapsulate HTML, JavaScript and CSS in a way that isolates it from the host HTML document and makes it readily reusable in the DOM via a custom script tag. Wondering what's so great about that? Applications or utility components that are properly packaged into a web component can be easily and declaratively embedded in your HTML and the web component will take care of instantiating itself. Implementation details of your host application do not matter, what matters is that all the features required to run web components are supported by your targeted browsers. This makes web components framework-agnostic. If a developer creates a component using Angular or Vue, you can easily use this component in your React application without any special logic. This becomes interesting in the context of micro frontends where parts of the user interface can be developed by separate teams and even using separate libraries or frameworks, but in the end all parts are brought together smoothly to form one interface.

See for yourself

The concepts described so far are on display in an example application here. The application loads and embeds three web components with the same content but implemented using Vue, React and plain JavaScript.

Defining web components

To create a web component only few steps are required:

  • Create a custom HTML element.
  • For scoped styles, use the Shadow DOM – the driving force behind the encapsulation of web components
  • Register previously created element in the browsers custom elements registry, making it available via a custom HTML tag.

Creating a custom element is as easy as creating a class which extends any already existing HTML elements.

class MyCustomElement extends HTMLElement {
  connectedCallback() {
    this.innerHTML = "Hello, World!";
  }

  disconnectedCallback() {
    this.innerHTML = "";
  }
}

On the class we define two properties:

  • connectedCallback – This function is called when the custom element we are creating here is inserted into the DOM. We use this function to instantiate the actual content of the element, "Hello, World!" in this case.
  • disconnectedCallback – This function is called when the custom element is removed from the DOM. We use this function to perform any necessary cleanup actions otherwise we might end up with memory leaks.

Let's have a look at an implementation which allows us to do so much more:

export const makeCustomElement = ({
  mount,
  styles = "",
  unmount,
}: {
  mount: (targetElement: HTMLElement) => void;
  styles: string;
  unmount: (targetElement: HTMLElement) => void;
}) => {
  return class Element extends HTMLElement {
    connectedCallback() {
      this.attachShadow({ mode: "open" });
      const template = document.createElement("template");

      template.innerHTML = `
            <style>
                ${styles}
            </style>
            <div id="mount"></div>
            `;

      if (this.shadowRoot) {
        this.shadowRoot.appendChild(template.content.cloneNode(true));

        mount(this.shadowRoot.getElementById("mount")!);
      }
    }

    disconnectedCallback() {
      unmount(this);
    }
  };
};

Let’s unravel what’s happening here. The function takes an object with three properties — mount, unmount and styles — and returns a class which extends the browser's HTMLElement.

In the connectedCallback function we use this.attachShadow({ mode: "open" }); to attach a Shadow DOM tree to the element to enable scoped styles for this element. This step is important if you do not want to have conflicting style rules between your custom element and the host document. You could alternatively write namespaced styles that will only be applied in the custom element. Using the Shadow DOM to achieve this makes your element usable anywhere without having to think about conflicting or overwriting CSS rules.

Then we create a template containing the styles inside a style tag and a DOM node which is our mounting point for the content this element is going to render. Finally the template is cloned and appended to the elements shadowRoot node and the mount function is called with our mounting point element.

The disconnectedCallback is much simpler, as we only call the passed unmount function, which means any cleanup logic has to be defined elsewhere.

By accepting mount and unmount functions the above implementation is entirely framework-agnostic and can be used to create custom elements which render anything. That's all it took for our previous example application to create web components which render React, Vue and plain JavaScript code.

To create a web component which renders a React application we can use the function defined above like this:

import React, { useState } from "react";
import ReactDOM from "react-dom";
import { makeCustomElement } from "../lib/makeCustomElement";
import styles from "!!raw-loader!../styles/shadow.css";

const App = () => {
  const [counter, setCounter] = useState(0);

  return (
    <div className="wrapper">
      <button onClick={() => setCounter(counter + 1)}>Increment</button>
      <h1 className="title">{counter}</h1>
      <button onClick={() => setCounter(counter - 1)}>Decrement</button>
    </div>
  );
};

const ReactApp = makeCustomElement({
  mount: (el) => {
    ReactDOM.render(<App />, el);
  },
  styles,
  unmount: (el) => {
    ReactDOM.unmountComponentAtNode(el);
  },
});

window.customElements.define("composer-react", ReactApp);

We call makeCustomElement and define the mount and unmount functions to render and cleanup the React application. Additionally, as we use Webpack as a bundler, we load our styles into a string using Webpack's raw-loader and pass it into our function as well. Afterwards we register the element with the custom element's registry by writing:

window.customElements.define("composer-react", ReactApp);

This makes the element usable in our HTML via the custom composer-react tag. We've also created examples for Vue and plain JavaScript.

Loading web components

You could of course create web components right in your application code and immediately use them but usually you'd either include web components statically or dynamically via script tags. Let's go into more detail for the case of dynamically loading web components at application runtime. This approach may be interesting in cases where you want to lazy load the components or only know which components you need to load at runtime. Imagine, for example, a dashboard application where each widget is developed by separate teams. Each widget is defined as a web component and can even be hosted on a different server. As long as the web component is defined properly and you know the location of the JavaScript file necessary to run the component you are good to go!

The following logic for dynamically loading scripts can also be applied to loading stylesheets. You'll find the implementation for both scripts and stylesheets here if you need it.

const scriptIsLoaded = (src: string) => {
  const scripts = Array.from(document.querySelectorAll("script"));

  return scripts.find((script) => script.src === src);
};

const loadScript = async (src: string) => {
  const jsLoaded = scriptIsLoaded(src);

  return new Promise<void>((resolve, reject) => {
    if (jsLoaded) {
      if (jsLoaded.getAttribute("data-loaded") === "true") {
        resolve();
      } else {
        jsLoaded.addEventListener("load", () => resolve());
      }
    } else {
      const script = document.createElement("script");

      script.type = "text/javascript";
      script.src = src;

      document.head.appendChild(script);

      script.onload = () => {
        script.setAttribute("data-loaded", "true");
        resolve();
      };

      script.onerror = (event) => {
        script.remove();
        reject(event);
      };
    }
  });
};

export const loadAssets = async (assets: { js: string }) => {
  await loadScript(assets.js);
};

There’s a bit to unravel here:

Since it’s not a given that we are only embedding an interface once in our composition layer, before we attempt to load a remote script we perform some checks first. These checks are necessary for making sure we only load a resource once:

  • Has the script been loaded before or is it currently loading? This is done by checking if a script tag with the same source as the script we are trying to load already exists in the DOM. If it does that means the script is either loading or has already finished loading and has been executed.
  • To know if the script — if present in the DOM — has finished loading, we set a custom data attribute data-loaded on the script element when the script loads. Every time we attempt to load a script we attach a new event listener to the script tags load event to know when it is done loading. That way, if we try to load the same script twice, the Promises created by calling loadAssets twice resolve simultaneously.

We use the loadAssets function in our example application via a React hook to make information on loading or error states available to where we embed the web component.

import { useState, useCallback, useEffect } from "react";
import { loadAssets } from "../lib/loadAssets";

export const useStaticAssets = ({ js, css }: { js: string; css?: string }) => {
  const [loadAttempts, setLoadAttempts] = useState(1);
  const [loadingAssets, setLoadingAssets] = useState(false);
  const [assetLoadingError, setAssetLoadingError] = useState(false);
  const handleTryAgain = useCallback(() => {
    setLoadAttempts(loadAttempts + 1);
  }, [setLoadAttempts, loadAttempts]);

  useEffect(() => {
    setLoadingAssets(true);
    loadAssets({ js, css })
      .then(() => {
        return setLoadingAssets(false);
      })
      .catch(() => {
        setLoadingAssets(false);
        setAssetLoadingError(true);
      });
  }, [js, css, loadAttempts]);

  return {
    loading: loadingAssets,
    attempts: loadAttempts,
    error: assetLoadingError,
    retry: handleTryAgain,
  };
};

The hook doesn't only provide loading functionality but also allows to attempt to reload the assets if errors have occured.

Using web components

Now let's get to actually using our dynamically loaded web components. For this to work we assume that for each web component we load or use, we know the HTML tag and the locations of the required assets. This info could be provided by the server serving the static assets for the custom elements. It's similar to a service discovery server for backend services.

The following implementation of the example application uses React but can be rewritten using Vue or plain JavaScript:

import { Alert, AlertTitle, Flex, Spinner } from "@chakra-ui/core";
import React, { FC, memo, useRef, useMemo } from "react";
import { useStaticAssets } from "../hooks/useStaticAssets";

const EmbeddableUi: FC<
  Readonly<{
    uiDefinition: {
      id: string;
      tag: string;
      js: string;
      css?: string;
    };
  }>
> = ({ uiDefinition }) => {
  const targetRef = useRef<HTMLElement>();
  const { loading, error } = useStaticAssets({
    js: uiDefinition.js,
    css: uiDefinition.css,
  });

  const content = useMemo(() => {
    if (loading) {
      return <Spinner size="lg" />;
    } else if (error) {
      <Alert>
        <AlertTitle>Failed to load ${uiDefinition.id}</AlertTitle>
      </Alert>;
    }

    return React.createElement(uiDefinition.tag, {
      ref: targetRef,
    });
  }, [error, loading, uiDefinition]);

  return (
    <Flex
      bg="#fafafa"
      border="2px dashed #aaaaaa"
      padding={4}
      justifyContent="center"
      alignItems="center"
    >
      {content}
    </Flex>
  );
};

export default memo(EmbeddableUi);

There are four things to note here:

  • The component receives a the HTML tag, asset paths and additional info as a prop.
  • It passes the asset paths to our previously defined hook useStaticAssets.
  • Depending on a loading and error state it renders different content.
  • It uses the custom HTML tag to render the web component to the DOM.

As described in https://micro-frontends.org you could use skeleton UIs for the loading state of the web components or a spinner as in the example application. The browser caches the assets of the web components so loading times are only an issue on first load or after the cache invalidates.

Additional Thoughts

The approach described in this blog post has proven useful in scenarios where requirements included dynamically loadable interfaces hosted on different machines, and for projects developed by multiple teams that may have differing languages of choice. Each interface implementation must only adhere to a specific protocol for embedding the interface: creating the element with makeCustomElement and registering it via a unique tag.

I hope you've learned something from our experiences using web components. The outlined implementation is of course not the only way to go about this problem but it's a good building block to get your project off the ground.

Links

web components

custom elements

shadow dom

micro frontends

react

vue

Peerigon logo