DEV Community

Gil Fink
Gil Fink

Posted on • Originally published at Medium on

Wrapping React Components Inside Custom Elements

Web Components

This week I had the pleasure of speaking in the ReactNext 2019 conference. My talk was called “I’m with Web Components and Web Components are with Me” and it was all about consuming Web Components in React apps and wrapping React components with custom elements. In this post I’ll explain the second part and why you might want to do it. When the talk video will be available online, I’ll embed it in this post.

React and Web Components

In React documentation there is a section about React and Web Components. In that section, it is mentioned that React and Web Components are complementary to each other. While React is a view engine that is responsible to keep the DOM in sync with the app’s data, Web Components provide strong encapsulation for the creation of reusable HTML components. But in the real world most of the companies that I consult for don’t use the two options together and why is that?

  • Developers are still suspicious about the Web Components API and prefer to use a proven framework/library instead.
  • Web Components API is still not implemented in some of the browsers, which means that in order to use them we need to load a polyfill code.
  • As developers, we are used to frameworks/libraries goodies such as data binding, reactivity, lazy loading and more. In Web Components we need to craft everything and the boilerplate is sometimes cumbersome.

So why to invest in Web Components at all? I already wrote a post about that in 2017 which is called “Why I’m Betting on Web Components (and You Should Think About Using Them Too)” and you can read about my thoughts there. To summarize what I wrote — Web Components can help you decouple the implementation of your component from the framework/library and help you create a boundary between the components and their consuming app. They are also suitable for design system building which can be consumed by any framework/library.

Wrapping React Component inside a Custom Element

Now that we understand a little bit why would we want to use Web Components, let’s talk about how to use Web Components API to wrap a React component.

Note: All the code is written in TypeScript but don’t worry because it is easy to translate it to vanilla JavaScript.

We will start with a simple collapsible panel written in React:

import * as React from 'react';

interface IProps {
  title: string;
}

interface IState {
  isCollapsed: boolean;
}

export default class CollapsibleReact extends React.Component<IProps, IState> {
  state: Readonly<IState> = {
    isCollapsed: false
  };

  public toggle = () => {
    this.setState(prevState => ({
      isCollapsed: !prevState.isCollapsed
    }));
  }

  public render() {
    const { isCollapsed } = this.state;
    const { title, children } = this.props;
    return (
      <div style={{ border: 'black dashed 1px' }}>
        <header onClick={ this.toggle } style={{ backgroundColor: 'blue', color: 'white' }}>{title}</header>
        <section hidden={isCollapsed}>
          {children}
        </section>
      </div>
    );
  }
}

The component includes a collapsible section and a header element that when clicked on toggles between collapsed and shown states. If we want to wrap this component inside a custom element we will have to take care of a few things:

  • Pass the title and children props.
  • Re-render when the title prop is changing.

We will start by creating the custom element class and by defining it in the CustomElementRegistry:

export default class CollapsiblePanel extends HTMLElement{

}

window.customElements.define('collapsible-panel', CollapsiblePanel);

Our class will include 2 members the title and mount point, which will be responsible to hold the mounting point in the DOM:

mountPoint: HTMLSpanElement;
title: string;

Now let’s talk about the main implementation point — mounting the React component. We will use the custom element’s connectedCallback life cycle event to do that:

connectedCallback() {
  this.mountPoint = document.createElement('span');
  const shadowRoot = this.attachShadow({ mode: 'open' });
  shadowRoot.appendChild(this.mountPoint);

  const title = this.getAttribute('title');
  ReactDOM.render(this.createCollapsed(title), this.mountPoint);
  retargetEvents(shadowRoot);
}

In the connectedCallback , we will create a span which is going to be our mounting point. Then, we will use the attachShadow function to create a shadow root which will be our boundary between the app and the React component. We will append the mounting point to the shadow root. After we set all the ground, we will use ReactDOM to render the React component (using the createCollapsed function that you will see in a minute). Last but not least, we will use a function called retargetEvents which is part of the react-shadow-dom-retarget-events module. We will get to why I’m using retargetEvents later in this post so keep on reading :).

Let’s look at the createCollapsed function:

createCollapsed(title) {
  return React.createElement(CollapsibleReact, { title }, React.createElement('slot'));
}

The function is getting the title which will be used by the React component. Then, the function uses React’s createElement function to create the CollapsibleReact component instance. The createElement also receives the props object as a second argument and the children prop as third argument. In order to pass the children as expected I use the HTML slot element to make a bridge between the wrapping component children and the wrapped component children.

Now that we finished the mounting of the wrapper component, the next step is to re-render the component if the title changes. For that, we will use an observed attribute and the attributeChangedCallback custom element life cycle event. Here is how they are used in the component:

static get observedAttributes() {
  return ['title'];
}

attributeChangedCallback(name, oldValue, newValue) {
  if(name === 'title') {
    ReactDOM.render(this.createCollapsed(newValue), this.mountPoint);
  }
}

When the title changes we use ReactDOM render function again. Since we saved the mounting point, ReactDOM will do all the re-rendering heavy lifting and will calculate the diffs for us.

The custom element’s entire implementation:

import * as React from 'react';
import * as ReactDOM from 'react-dom';
import * as retargetEvents from 'react-shadow-dom-retarget-events';
import CollapsibleReact from './collapsible-react';

export default class CollapsiblePanel extends HTMLElement {
  static get observedAttributes() {
    return ['title'];
  }

  mountPoint: HTMLSpanElement;
  title: string;

  createCollapsed(title) {
    return React.createElement(CollapsibleReact, { title }, React.createElement('slot'));
  }

  connectedCallback() {
    this.mountPoint = document.createElement('span');
    const shadowRoot = this.attachShadow({ mode: 'open' });
    shadowRoot.appendChild(this.mountPoint);

    const title = this.getAttribute('title');
    ReactDOM.render(this.createCollapsed(title), this.mountPoint);
    retargetEvents(shadowRoot);
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'title') {
      ReactDOM.render(this.createCollapsed(newValue), this.mountPoint);
    }
  }
}

window.customElements.define('collapsible-panel', CollapsiblePanel);

Re-targeting React Events

Why did I use the retargetEvents function? React event system is relying on synthetic events, which are wrappers on top of the browser native events. All the events in React are pooled and will be registered on the document itself. That behavior can be very problematic when you use shadow DOM. In shadow DOM, the shadowed DOM fragment exists in its own DOM fragment. That means that React events won’t work inside the shadowed part. The retargetEvents function helps to register the events inside the shadow DOM and to make them work as expected.

Testing The Wrapper

Now we can test the wrapper component. I used an Angular application to consume the component and this is the code I used in the application main HTML:

<div style="text-align: center">
  <h1>
    Welcome to {{ title }}!
  </h1>
  <img width="300" alt="Angular Logo" src="...">
</div>
<collapsible-panel [title]="title">  
  <ul>  
    <li>Web Components rules</li>  
    <li>Look I'm wrapping a React component</li>  
  </ul>  
</collapsible-panel> 
<router-outlet></router-outlet>

The result of running the app:

Angular app hosting a React wrapped component

Summary

In this post, I used the Web Components API to wrap a React component and to consume it from an Angular app. This is, of course, one way to do this and I can think about how to make it more automatic but this is a subject for another post :)

Let me know what do you think in the comments.

Top comments (0)