How to Create and Export Web Components From a React Application

If you want to skip this post and go straight to the code, it is available on GitHub.

Microfrontends are an increasingly popular way to build software, allowing complex applications to be broken down into smaller, manageable pieces. In turn, that enables these microfrontends to be developed and deployed independently. While this isn’t a silver bullet to solve all problems with large software systems and team collaboration, it is another tool that we can strategically apply.

Another less common use case is to allow incremental adoption of a new piece of software in a legacy software system. My team and I were working on a re-write of one, and realized we could solve some acute needs of the old application by gradually releasing a few pieces of our new application. With minimal changes to the legacy application, and by leveraging Web Components, we were able to replace a few workflows and provide immediate value, without having to release our whole application.

Web Components to the Rescue

From the docs on MDN:

Web Components is a suite of different technologies allowing you to create reusable custom elements — with their functionality encapsulated away from the rest of your code — and utilize them in your web apps.

As developers, we all know that reusing code as much as possible is a good idea. This has traditionally not been so easy for custom markup structures — think of the complex HTML (and associated style and script) you’ve sometimes had to write to render custom UI controls, and how using them multiple times can turn your page into a mess if you are not careful.

Web Components aims to solve such problems — it consists of three main technologies, which can be used together to create versatile custom elements with encapsulated functionality that can be reused wherever you like without fear of code collisions.

While it seems like Web Components are aimed at reducing HTML duplication, we can also register React (and other modern JS libraries) components. Then, another application (or just a plain HTML page) can then import and use that Web Component. With this approach, components are encapsulated within a shadow DOM and can’t interfere with the rest of the application.

A Brief Aside: Module Federation

Another solution for building microfrontends is module federation, a feature of Webpack 5, but with counterparts in other build tools, like vite-plugin-federation. This allows marking certain dependencies as remote modules, which basically tells the build system “don’t bundle this in at build time, you’ll get it at runtime”. We won’t go into it in this post, but it’s another popular approach to build microfrontends.

Creating our Web Component

React includes tools for creating and registering a Web Component, and we can use them in conjunction with our build tools.

import ReactDOM from "react-dom/client";

const Greeting: React.FC<{ name: string }> = (props) => {
  return <p className="greeting">Hello, {props.name}</p>;
};

class GreetingWebComponent extends HTMLElement {
  connectedCallback() {
    const shadow = this.attachShadow({ mode: "open" });
    const mountPoint = document.createElement("span");

    // Option 1: Inline styles -------------------
    const style = document.createElement("style");
    style.textContent = `
      .greeting {
        color: pink;
        font-size: 72px;
        font-family: 'Comic Sans MS';
      }
    `;

    shadow.appendChild(style);
    // -------------------------------------------

    // Option 2: Link Stylesheet -----------------
    const link = document.createElement("link");
    link.setAttribute("rel", "stylesheet");
    link.setAttribute("href", "/path/to/your/styles.css");

    shadow.appendChild(link);
    // -------------------------------------------

    shadow.appendChild(mountPoint);

    const name = this.getAttribute("name") ?? "World";
    const root = ReactDOM.createRoot(mountPoint);
    root.render(<Greeting name={name} />);
  }
}

if (!window.customElements.get("custom-greeting")) {
  window.customElements.define("custom-greeting", GreetingWebComponent);
}

A few things are going on in here, though some are just a byproduct of the React and Web Component API:

  • We must declare the class and extend the HTMLElement.
  • We need to create a mountPoint and specify that we’re attaching it to the shadow DOM.
  • We need to register our Web Component (without redefining it if we already have).
  • For prop-like parity, we can use the HTML “attributes” feature to allow passing data down.
  • Because the component is in a Shadow DOM, we need to manually inject our styles in. There are two common approaches, outlined in the code.

And with that, we can start using our web component! If using TypeScript, we will need to declare some types:

// custom-components.d.ts
declare global {
  namespace JSX {
    interface IntrinsicElements {
      "custom-greeting": React.DetailedHTMLProps<
        React.HTMLAttributes<HTMLElement> & {
          name?: string;
        },
        HTMLElement
      >;
    }
  }
}
export {};

And we can use it like:

import "./greeting-web-component";

function App() {
  return (
    <div>
      <custom-greeting name="Web Component!" />
    </div>
  );
}

Now, simply declaring and using a web component in the same React application doesn’t add much value, unless isolating the component via the Shadow DOM is desired. The real power comes from exporting it and using it in another application.

Exporting a Web Component

Below is a portion relevant Webpack configuration, but feel free to refer to the GitHub repo for more details. This may not be relevant to your build configuration, whether it’s Webpack, Vite, Parcel, Rollup, ESBuild, Snowpack, Next.js, Rome, or something else 😅. I can’t confirm that every build tool will support this style, but I’ve utilized this approach on Webpack and Vite projects. Below, I’ve tried to highlight a few of the important differences between our client & standalone Webpack configurations.

module.exports = {
  // much of this is shared with webpack/client.config.js, but for simplicity, we are removing any chunking / minimizing / code splitting / other optimization and we have no need for HtmlWebpackPlugin or the devServer.
  entry: {
    app: ["./src/greeting-web-component.tsx"],
  },
  output: {
    path: path.resolve(__dirname, "../dist/greeting"),
    publicPath: `${JSON.stringify(config.get("server.publicHost"))}/greeting`, // looks like http://localhost:3000/greeting when running locally
    filename: "client.js",
  },
};

A few noteworthy things:

  • We declare a new entry point for our standalone web component, so we don’t need to import the whole application – just the relevant pieces.
  • We declare an output path for the assets to be generated to.
  • We declare a publicPath to let Webpack know where to import any additional assets. If the file is chunked and needs to import other JS or CSS, it will fetch anything from the publicPath (the location on the server or CDN where it will be deployed).

Hosting the Web Component

It would be possible to just bundle the Web Component code into your existing application, but an even better approach would be to host it on a web server or CDN. This allows for easier deployment, and unlocks isolated deployments. In other words, new changes can be pushed to the web component without deploying the host application.

Here’s a really simple Express server to host the dist directory where the assets were built.

const port = process.env.PORT || 3001;

let app = express();

// other API endpoints

// serve the dist directory, where our greeting component is built
app.use(express.static("./dist/"));

app.listen(port, () => {
  console.info("up and running on port", port);
});

Finally, we can put this all together and use our web component hosted on a server like:

<html>
  <head>
    <!-- By importing this here, it will automatically register the component, and we can use it below  -->
    <script src="http://localhost:3001/greeting/client.js"></script>
  </head>

  <body>
    <custom-greeting name="Web Component" />
  </body>
</html>

Including a script tag will fetch the JavaScript and register the Web Component, which allows us to run our component later in the page. Of course, we can follow a similar pattern in other languages and JavaScript frameworks, but I find that it’s easiest to boil this down to the most simple case and stick with HTML when testing this out.

Wrapping up

Web components are a powerful tool that we can use to build modular and maintainable web applications. While it’s not the only solution to build microfrontends, it has many benefits and allows for great isolation between components. It’s also a fantastic way to incrementally release new features in a web application, without worrying about interfering with other parts of the codebase.

Conversation
  • Anubhav jha says:

    How the hell do you manage adding css in the web component?

    • Your Average Dev says:

      That’s exactly what I would like to know. But now it’s 2024, so I guess we’re not getting an answer…

      • Dan Kelch Dan Kelch says:

        While it is, in fact, 2024, I can answer your question!

        There are a few ways to inject styles into your web component, I included two options in the following example:

        import ReactDOM from "react-dom/client";
        import { Greeting } from "./greeting";
        
        class GreetingWebComponent extends HTMLElement {
          connectedCallback() {
            const shadow = this.attachShadow({ mode: "open" });
            const mountPoint = document.createElement("span");
        
            // Option 1: Inline styles -------------------
            const style = document.createElement("style");
            style.textContent = `
              .greeting {
                color: pink;
                font-size: 72px;
                font-family: 'Comic Sans MS';
              }
            `;
        
            shadow.appendChild(style);
            // -------------------------------------------
        
            // Option 2: Link Stylesheet -----------------
            const link = document.createElement('link');
            link.setAttribute('rel', 'stylesheet');
            link.setAttribute('href', '/path/to/your/styles.css');
        
            shadow.appendChild(link);
            // -------------------------------------------
        
            shadow.appendChild(mountPoint);
        
            const name = this.getAttribute("name") ?? "World";
            const root = ReactDOM.createRoot(mountPoint);
            root.render(<Greeting name={name} />);
          }
        }
        
        if (!window.customElements.get("custom-greeting")) {
          window.customElements.define("custom-greeting", GreetingWebComponent);
        }
        

        Most likely, you’re probably looking for option 2, since it’s not common to declare inline CSS like in option 1, but it might be a simple approach if your needs are small.

        Hope this helps!

  • Comments are closed.