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.