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.