After six years of writing React professionally, I have decided that I really prefer Web Components to React, Angular, Vue, or any of their ilk. Web Components are the browser's native model for defining components; they don't need huge libraries to "route around" deficiencies in the browser nor do they need special context or event types; they just use the browser's own event dispatcher, and context is managed by putting stopPropogation on the context handler's DOM Node.

I recently spent some time trying to port my old jQuery/Backbone demo, Fridgemagnets, to a Web Component model.

I started out by using Lit-Element. Lit is a very small toolkit from Google which creates a bundle of only 5KB, much smaller than React's 40. I had good reasons: due to the history of Web Components, the API is awkward with very long method names and an annoyingly complex dance to convert text to a DOM Node. Lit streamlines a lot of that, but it also adds something that I usually find annoying: all your events that cause a state change in a component are batched and propagated at once, debounced to prevent refresh pauses. Lit wants to make web components "reactive," so your state is stored in monitored attributes.

For the most part, I can live with that. But in the conversion to FridgeMagnets, I hit a headache.

The problem

Fridgemagents is a simulation of those little word magnets that you stick on your 'fridge (or any ferrous surface) and can re-arrange at will to create poems. My wife and I have a huge collection of these, including a whole batch of oversized ones we used to teach our kids how to read. "Go ahead, make up a poem" is a lot easier for a six year old when they've got a bucket of words and just find a few silly phrases to string together.

In the simulation, every tile is tilted in a range from zero to 15° off true, like they would be. When you "pick up" (click on) a tile, the tile "lifts" (gets larger) and tilts slightly again; and when you drop it, it gets a new tilt and, as a bonus, a bunch of stars or hearts "shoot out" from underneath it before quickly fading away, the "heart-splash" effect. The effect was fairly trivial to encode in jQuery: just add a bunch of star icons into the tile container one z-index below the tile, animate them away with JavaSript, and call this.remove() at the end so they'd self-destruct.

The problem's problem

My first try at doing this with Lit failed miserably. Rather than use JavaScript, I used CSS transforms for the animation, like a modern person. But whenever I dropped a tile, every tile on the board wiggled, its tilt changing in response to... something. I tried a variety of changes, but couldn't get it to work. Every tile kept wiggling.

Lit, like React and most other component libraries, believes there is a state of your system, and that what you see is a reflection of that state. But I'm a fan of HATEOAS (Hypertext As The Engine of Application State); I firmly believe the DOM is sufficient on its own to host most of the state and you should only be storing the state in an alternative format if you need facilitate complex processing or address performance issues.

I reasoned that Lit was the problem; in its effort to be more like React, it was monitoring the parent component's local DOM (the shadow DOM object) for mutation events and "resetting" the content to match the current state. Here's what it looked like in LIT:

for (let i = 0; i < numberOfStars; i++) {
    stars.push(`<heart-symbol style="top: ${pos.top}px; left: ${pos.left}px; z-index: ${theZ - 1}"></heart-symbol>`);
}
shadowRoot.innerHTML += hearts.join('');

This is the code that adds the hearts to the tile board's Shadow DOM all at once ; once the browser renders this new DOM object, the hearts begin their animation, "shooting out" from underneath the tile, spinning gently and fading away.

But why did every tile wiggle when I did this?

The answer, and the solution

I ended up re-writing the components in pure Web Component, with no library at all. Which worked fine, although it was a lot more code to manage the state and the rendering loops.

The wiggle was still there.

Dammit.

But in the course of the conversion, I uncovered the actual problem. Look at the heart-symbol definition: it has the top, left, and z-index set, but the spin and angle and speed of the "explosion" effect was internal to the HeartSymbol web component.

The same is true of the tiles; because drag-and-drop is enabled, the tiles' positions were managed by the word-tile host container's style attribute. But the tilt effect was "just decoration," so it was randomly set on a tile's inner Shadow DOM whenever a tile component was created or received a drag-start or drag-end event.

So this line caused the headache:

shadowRoot.innerHTML += hearts.join('');

Every word tile had its top, left, z-Index, and word content at construction, but tilt was randomly assigned at rendering. And that line told the browser to re-parse and re-render every component on the board.

The solution was to avoid the innerHTML entirely. Instead, I used the DOMParser to activate my heart-symbol object, then extract it from the new parsed fragment and inject it directly into the board's shadow root without touching innerHTML. This causes a re-flow to consider the new elements, but not a re-render:

 const parser = new DOMParser();
 for (let i = 0; i < count; i++) {
     const heart = parser.parseFromString(
             `<heart-symbol style="top: ${top}px; left: ${left}px; z-index: ${theZ - 1}"></heart-symbol>`,
             "text/html"
     ).querySelector('heart-symbol');
     if (heart) {
         hearts.push(heart);
     }
 }
 this.shadowRoot.append.apply(this.shadowRoot, hearts);

The problem wasn't Lit at all; it was my forgetting the browser's own native behavior when modifying HTML as text.

Conclusion

Web Components would be the true future of web development, and may be so for real, if React's massive market share could be overcome, but it may always be the Betamax to React's VHS. It is a stronger contender for web-native, and in conjunction with Project Fugu, provides a much better native experience on phones and embedded devices than even React Native.

But it has its weaknesses, and this is one of them: using the browser's native powers means understanding the browser's native behavior, and playing with innerHTML means telling the browser that the content of innerHTML needs to be re-parsed and re-played. In my case, the inner state of a component caused only a visually annoying effect but in other cases the experience could be much more dire.

Imagine a single page application session where the user has twenty or thirty cards, each of which has its own fetch() that turns an attribute key into a set of internal values to be displayed; touching innerHTML would cause all those cards to re-render and re-fetch, spamming your server badly.

I strongly recommend using Web Components if you can. They're "close to the metal," but Lit makes it easy to work with them and, like learning C or Assembly, you're working with the APIs that the platform itself uses. It's actually less to learn for the same or better functionality, which means you get to "functional" much faster than you would with React or some other library. But like every new programming environment, there are pitfalls, and innerHTML, despite seemingly right there as part of the native browser package, is one of them.