Hajime, the duck guy

Monday, April 29, 2024, by Hajime Yamasaki Vukelic

Notes on integrating custom elements into a React project

From time to time I write about integrating vanilla code into React applications. My primary motivation for this is simplifying the code. While state manipulation and declarative UI work well for very simple cases, the complexity increases exponentially for more complex cases. I generally draw a line somewhere where the effect hook grows beyond a few methods calls. If I need to handle complex event sequences, then I won't even try to make it work within the confines of the idiomatic React patterns. To me, it's just not worth the trouble.

Since complex widgets requires native DOM manipulation code anyway, I prefer to put it where it makes more sense: outside the standard React paradigm — in custom elements. This allows me to do the simplest thing that will get the feature going without needing to work around React API limitation.

Or at least that would be the theory.

Custom elements in React apps

There are three patterns for using custom elements within React apps that I know of:

  • Custom elements with shadow DOM, which are treated as a black box.
  • Custom elements wrapping normal DOM elements rendered by the custom elements themselves, which is also treated as a black box.
  • Custom elements wrapping DOM elements created by React.

If you would like my unsolicited advice, stay away from the first option, at least until AOM (accessibility object model) is fleshed out and widely adopted. As of this writing, AOM is still an unofficial draft, and has no browser support. Without the AOM it is almost impossible to implement complex controls that are 100% accessible. (Not to mention that, even with AOM in place, it will still require a lot more code to do so.)

The first two options shield our code from the peculiarities of React's handling of the DOM elements, and, in particular, the way it handles events. So, in a way, they are 'safe' options if we want to keep our expectations about how native DOM code should work.

However, both of those options also have a drawback. They make it harder to pass information from React to the components. This is because React, when it sees a custom element, switches from setting props as properties to setting them as attributes. This is not a bad design decision since it's generally impossible to guarantee that the custom element will be able to handle properties at all, but the downside is that we can only pass strings as prop values.

The third option has the benefit of not having the drawback of the first two, but exposes us to the non-standard behaviors in React-created nodes. Still, it lets us take full advantage of what React is actually good at while allowing us to leverage the native DOM code for the things React sucks at. In a way it is the best of both worlds (supposing that we believe React has anything good to offer to begin with, which is a point disputed by some veteran developers). Despite the possible obstacles, I normally go with this option, as I believe it offers the least resistance in this particular context — we are able to come up with designs that feel the most natural.

Example

Let me first show you what I mean by the third option.

function CustomInput({label, ...inputProps}) {
  return (
    <label>
      <span>{label}</span>
      <x-combobox>
        <input {...inputProps}/>
        <ul role="listbox">
          <li role="option">Monday</li> {/* I'm from Europe, ok? */}
          <li role="option">Tuesday</li>
          <li role="option">Wednesday</li>
          <li role="option">Thursday</li>
          <li role="option">Friday</li>
          <li role="option">Saturday</li>
          <li role="option">Sunday</li>
        </ul>
      </x-combobox>
    </label>
  )
}

In the example code, the role of rendering the input as well as the options is delegated to React, while the custom element <x-combobox> deals with the behavior. As far as the React app is concerned, we are working with a plain input. (The implementation of the <x-combobox> element is far beyond the scope of this article, so I will refer you a pen that shows how it works without the React-specific modifications.)

This combobox widget works as follow.

The options in the listbox are selectable, just like in a standard select list. Expand the list, select an option, that type of thing.

The text input will hold the value of the selected option. Additionally, as we type into the input, the options serve as auto-complete candidates (sort of like a <datalist>).

The input is auto-completed using the first matching option and the option text replaces the input text as we type. To allow us to continue typing when this happens, the portion of the text after the previous location of the cursor — the completed part — is selected, and our next key press simply overwrites the selected text. This is similar to how autocomplete works in some IDEs.

There's actually a bit more nitty-gritty under the hood to make the experience smoother for screen reader users, but that's not relevant to this article.

The caveats

When working with this kind of setup, here are some caveats that we might want to keep in mind.

Control over attributes

The first thing we're likely to encounter in this type of code is the issue of control over the attribute. We have to be careful not to modify attributes that are controlled from the React side and vice versa.

Since the whole point of using custom elements is reduction of moving parts in the React part of the code, I generally like to under-specify on the React side, and take control most of the attributes in the custom element code. I use the following rule of thumb:

Any attributes that change as a result of the user action should be specified by the custom element, and everything else should be specified by React.

There are cases where this separation cannot be made, such as the value prop on controlled inputs, but those are exceptions.

Control over nodes

Since React is rendering all of the custom element subtree, the custom element should generally avoid manipulating it. We cannot remove or add nodes, change their order, etc. We generally treat the subtree structure as beyond our control.

This requires some care when we do event handling or make elements appear and disappear.

For event handling, we use delegated event handlers rather than attach event handlers to individual elements. For instance:

// NOTE: `this` refers to the custom element node
this.querySelector('[role=listbox]')
  .addEventListener('pointerdown', function (ev) {
    var option = ev.target.closest('[role=option]')
    if (!option) return
    // now do something with the option
  })

In the example, the delegated event listener was attached to the node that is not likely to change over time.

Another example is hiding and showing of options that get filtered by the value of the input. We cannot filter the list using technique that physically take the elements out of the DOM tree, as that would throw React off. We either have to manage the option list through React (e.g., store the keyword in state and filter over the list items in JavaScript before rendering them) or figure out a different way to hide non-matching options (e.g., just use the hidden attribute).

Event handling

As you're probably aware, React does not use native events. It uses what it calls 'synthetic' events. This is supposed to work seamlessly so some of the nuances aren't well documented.

One thing we might run into while dealing with synthetic events is that, in some cases, the event handlers on the React side aren't invoked when the underlying nodes fire the event.

After a bit of debugging, I've discovered, for instance, that if we modify the input value after the input event is fired on a form control, the onChange event handler is not called. While this is not something you do very often, in the particular form control I was developing, I had the need to modify the input value to facilitate auto-completion. React apparently adds a setter to the input's value property and does something crazy with it, probably due to the suspense shenanigans.

The reason I sound a bit vague here is that React also does other things that prevents me from stepping through the code in order to find out exactly what it does. Don't ask. 😭

The long story short, the solution is to bypass the React's mechanisms for setting the input value.

var set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set

function setNativeInputValue(input, value) {
  set.call(input, value);
}

In the custom element code, instead of assigning the input value normally by saying input.value = x, we need to use the helper function and say setNativeInputValue(input, x).

If this doesn't work (and sometimes it doesn't), we may need to resort to handling the native event instead of the synthetic one. We can, for example, use a hook like this one:

function useNativeEvent(ref, eventType, callback, options = {}) {
  useEffect(function () {
    var node = ref.current
    node.addEventListener(eventType, callback, options)
    return function () {
      node.removeEventListener(eventType, callback)
    }
  }, [ref, eventType, callback, options])
}

Conclusion

I was mildly amused when Dan Abramov tweeted something to the effect that he is puzzled by why people say React isn't standards-compliant (compared to other frameworks) as React sometimes puts up a good fight when introduced to some native DOM code.

Having said that, it's actually not that bad. Once we figure out a few new tricks (and I mean, it's really just a few), it's mostly smooth sailing.

As I said before, since we're going to have to write native DOM code at some point anyway, might as well learn how to do it properly rather than try to hack around React APIs. If we're in agreement, I hope this article was helpful to you.

Posted in Programming tips
Back to top