Mixing React and htmx

A recent project required a complex HTML user interface with multiple interdependent elements. With the goal of using hmtx, I implemented the initial prototype using custom elements, but it grew in complexity quite fast. In the end, I found this to be one occasion in which React provides a clean and elegant way to structure code and data flow. Not content with giving up on htmx for the rest of the application, I set out to combine both libraries. In this blog post I would like to walk you through the resulting setup.

The basic idea is to use a custom element that wraps the React root. In the spirit of hypermedia driven applications, the goal is to let the regular DOM always reflect the application state and to let React render to the shadow DOM of the custom element. Using a MutationObserver, the element updates the shadow DOM as the regular DOM changes. Either driven by htmx or React.

The running example for this post will be a simple React component that displays a message

<react-root>
    <react-element type="MyApp" props='{"message": "World"}'/>
</react-root>

Here, the react-root element is our wrapper and the react-element specifies the type and props of the root component. It is equivalent to the JSX <MyApp message="hello world"/>.

To define the react-root element, we start with an autonomous custom element that extends from HTMLElement and then fill out the details step by step below

customElements.define("react-root", class extends HTMLElement {
    /* ... */
});

The constructor attaches the shadow DOM and sets up an observer for any changes to the children or to their attributes

constructor() {
    super();
    this.root = null;

    this.attachShadow({mode: "open"});

    new MutationObserver(() => this.render()).observe(
        this,  {
            subtree: true,
            childList: true, 
            attributes: true, 
            attributeFilter: ["props", "type"],
        });
}

Every time the children are updated the render method is called, which updates the react root by by re-rendering it with the current type and the current props

render() {
    if(this.root == null) {
        this.root = ReactDOM.createRoot(this.shadowRoot);
    }
                            
    const element = window[this.type];
    if(element == null) {
        throw new Error(`Could not find component ${this.type}`);
    }

    this.root.render(
        React.createElement(
            element, 
            {root: this, ...this.props},
        ),
    );
}

Note, how the root element injects itself as the root prop. This way the element can be accessed from within React, for example to send events to the rest of the page or update the props.

The current type and the current props are reflections of the corresponding properties of the first react-element contained within our custom element. This can be accomplished with the getters

get type() {
    const el = this.querySelector("react-element");
    return (el != null) ? el.getAttribute("type") : null;
}

get props() {
    const el = this.querySelector("react-element");
    return (el != null) ? JSON.parse(el.getAttribute("props")) : null;
}

Similarly, the custom element also implements the corresponding setters, so these properties can easily be modified programmatically. In addition the custom element also cleans up the react root once it is disconnected from the DOM. See the full source for details.

Our demo MyApp component reads

function MyApp({message, root}) {
    const onClick = () => root.dispatchEvent(
        new Event("my-react-event", {bubbles: true}));

    const e = React.createElement;
    return e("div", {}, 
        e("div", {}, `hello ${message}`),
        e("button", {onClick}, "Trigger DOM event from React");
}

The div displays the message that is set in the props. The button triggers a custom event. Now we can instantiate the app in HTML and connect it with htmx

<react-root id="root" hx-get="/update" hx-trigger="my-react-event">
    <react-element type="MyApp" props='{"message": "World"}'></react-element>
</react-root>
<button hx-get="/update" hx-target="#root">Update</button>

The updates via htmx can either be triggered from outside React via the "Update" button or from inside React via the custom my-react-event. In both cases, htmx will replace the react-element inside the react-root with the server response. The react-root element then notices the change and triggers a re-render. Importantly, React applies here its diffing logic and updates the DOM only for modified parts of the tree. For example, the button in the component will never be updated.

Another option is to the set props on the react-root element itself from JavaScript. Again, this will change the attributes on the react-element and then trigger a re-render. The MyApp component for this setup reads

function MyApp({message, root}) {
    const onClick = () => {
        root.props = {message: "from react"};
    };

    return React.createElement("div", {}, 
        React.createElement("div", {}, `hello ${message}`),
        React.createElement("button", {onClick}, "Set props from React"));
}

You can find a complete demo here. It showcases the different update modalities and also multiple independent react roots. Use "Show Source" to look at the implementation. As always, feel free to reach out to me on twitter @c_prohm with feedback or comments.