Custom Element With React

20 Jan 2025 - Ben

In my previous post, I discussed how we can escape React out the top. The example there is nice, but feels like a lot of free floating code. We can make better islands of interactivity by using Custom Elements! In fact, we can even hide React as an implementation detail entirely this way. By making our entrypoint a Custom Element, we have something that’s easy to template/render server-side and still have all of the interactivity client side that we use React for.

Our App component is almost the same as before. Since we know we’ll have a Custom Element wrapping it, we don’t need the div tag. With that change, our Custom Element, TextSwitcher, looks like this:

const App = React.forwardRef(function (props, ref) {
  const [msg, setMsg] = useState(props.defaultText || "Hi there!");
  useImperativeHandle(ref, () => ({
    update(newMsg) { setMsg(newMsg) }
  }), [setMsg]);
  return msg;
});

class TextSwitcher extends HTMLElement {
  connectedCallback() {
    if(!this.reactRoot) this.reactRoot = ReactDOM.createRoot(this);
    this.render();
  }

  render() {
    this.reactRoot?.render(<App ref={this.copyRef.bind(this)} defaultText={this.getAttribute('text')} />;
  }

  copyRef(refAttr) {
    for(const [key, val] of Object.entries(refAttr)) {
        this[key] = val;
      } // could also do this.ref = refAttr if worried about colliding with another property.
  }
}

customElements.define('text-switcher', TextSwitcher);

Then we can add a <text-switcher id="world-switch"></text-switcher> element anywhere on our page. We no longer have placed anything on the window object, so we can place many of these on a single page! Notice that a text attribute on our component above provides the default text for our App component, so we can set the default when it first loads! We can update any of them by targeting/finding it, then calling it’s update method. You can change the text-switcher above by typing the following into your browser’s dev console:

const elem = document.querySelector('#world-switch');
elem.update('Hello World!");

Maybe this example is still a bit contrived. It wouldn’t be hard to change the text inside here without React or a Custom Element. Hopefully you can come up with places where you need the interactivity React provides. I think this approach would work there too. On pages where you need interactivity, but a lot of the page is static or basic form submitting, this approach would shine.

It might be overkill for something like a spreadsheet application, where the whole page is so reactive that you don’t need to add the Custom Element layer. If you wanted to though, you could serve a page with a single <spread-sheet /> element in the body that handled mounting React and doing all the things you’d normally do before rendering React to the web page. Still, it might be some work to get bundling and tooling working how you want with this approach.

Can you identify the principle I’m hoping to illustrate here? We can build web pages however we want and still take advantage of React. By making React into an implementation detail, instead of what’s driving our design decisions, we’ve opened up a lot more possibility to reuse these components, mix and match how we do reactivity, and generally take more advantage of all the features a browser already gives us. We can integrate with forms directly. We can port complicated controls to simpler websites because we have written them in a way that fits right into the markup.