The Differentiating Property of Web Components
Published 2024-11-09, About 12 minute read.
About two months ago a Ryan Carniato wrote an inflammatory piece explaining that Web Components Are Not The Future.
Ryan's piece made the internet rounds, and it seems almost everyone had something to say. I appreciate the more measured responses by Lea Verou and Nolan Lawson
It made me reflect on the pains and pleasures I've experienced as my team is architecting a platform that relies heavily on web components. The whole discussion can be reduced down to one fundamental property of web components. Whether this property is good or bad largely depends on what you need to accomplish, but it is inescapable and has far-reaching consequences.
Web components are tightly bound to the DOM
So let's dive in.
Web Components are tightly bound to the DOM
Until very recently I had trouble articulating this but it is very much felt as you start getting used to using web components. . The post Liskov's gun: The parallel evolution of React and Web Components gave me some language to talk about this. In the article, Baldur Bjarnason says, "Web Components are defined in terms of alterations to the DOM". I make an even stronger statement that web components, by design, are tightly bound to the DOM.
But to illustrate this, let's rewind and go back to javascript development circa 2014. This is when I was first beginning the transition from teacher to software engineer. I was bright-eyed, bushy-tailed, and manically devouring new frameworks like the rest of the javascript world.
But if you can journey back to this era of development, before frameworks abstracted a large amount of browser APIs and DOM manipulations, you'd remember that "component" design was basically creating a class that instantiated itself on a "mounting point," or an element that would contain the markup for your component.
This class was a pure Javascript construct. As far as the browser was concerned, it could have everything or nothing to do with the DOM. If it was dealing with the DOM, it was a small bundle of functions that searched for things in the DOM, and would attach listeners and react to inputs. The state of the object was stored in this class, and the view, the template, the HTML itself carried nearly no state. It was an empty view that remained inert until Javascript enabled dynamic functionality. (There were some exceptions to this- think media elements or form elements. But by themselves they didn't do too much outside of showing media, or making form action requests back to the server.)
So to create something like a counter you would provide the smallest units of HTML in order that Javascript, like a marionettist, would be able to pull strings and create interactions. This separation of concerns was revered as an ideal that front end engineers should asipre to uphold.
How about an example?
To give a concrete example, let's create a counter here!
That was a lot of fun to do. There's just one glaring omission in my example... let me fix that.
I miss those dollar signs, don't you?
Notice that the class could be spun up with any element. Yes, the class is
expecting particular markup for it to work, but it doesn't require markup to
actually run. It's a floating class that reaches into the DOM selectively when
it needs to. The this
context in the class is really just the
class instance.
Let's talk a bit about this
You'll also probably remember that binding this
to event handlers
was routine and a common concern. The this
concern was the stuff
of tech interviews, and probably rightly so. this
could refer to
the javascript class (if you're in a method or the constructor of the class),
or the element that is the target of the event (if you're in the scope of an
event handler function that was triggered) or the window (if you are outside
of the class.)
This is one thing that frameworks have nearly removed from our purvue: we
don't have to worry so much about this
. Here are some examples in
different frameworks:
-
In react, there's almost no use using
this
unless you were using the old class component API. If you follow the standard stateless functional components,this
is just the window, or it'sundefined
if you're using an arrow function. In fact, one of the reasons React wanted to move to SFC and not use the oldComponent
classes was to remove the component instances that React had to instantiate and manage, effectively getting rid ofthis
. -
In Vue,
this
is pretty useless in practice, because it's by defaultundefined
in the<script>
tags: Check it out - It's basically the same in Svelte: Check it out
-
In Ember, reaching into
this
in the template will get you the component the template is related to, or in the case of a route template the controller for that route. Ember does the work of bindingthis
to the instance involved- so while Ember doesn't get rid of this concern completely, it does allow you to think of the component as a union of view + controller, sothis
has been simplified.
So what about web components?
Let's compare this with how a counter might be written in a vanilla web component:
What's the big deal? It doesn't look too different from the vanilla counter above. And it's true, there's still a lot of wiring up handlers to handle events and so on.
But there's a critical difference: this
in this component is a
DOM element. When the browser sees <my-counter>
in the DOM,
it runs our class as a DOM element in DOM lifecycles. The class instance is
instantiated by the browser when this element is created by HTML. (And here is
also an important point: there are lifecycle methods now when this instance is
actually connected to a DOM/Fragment or disconnected.)
We have completely tied our component class to the DOM. We depend on it being connected to the DOM to be meaningful.
There are a lot of really nice advantages to this:
- The inner template/DOM that your component needs to manage is encapsulated. You don't have to worry about what would happen if the html that the class is trying to work with is malformed. Generally, the web component makes it's own DOM internally.
- You don't have to manually write the "mounting" logic to make your component seek out proper html and mount.
- You have automatic connected and disconnected hooks that you can use. In other words, your component is aware when it's entering the DOM and when it's being removed from the DOM. This was a sticky point for vanilla components. You used to have to manually tear down listeners and such before you removed the component. (At least, I'm not aware of being able to detect DOM element removal unless you use a mutation observer)
-
You inherit all methods, properties, etc. from
HTMLElement
This is particularly nice for isolated, small, or federated pieces of user interface. But this coupling brings about framework problems...
Paint point 1: Renderless web components aren't possible
Since web components are tightly bound to the DOM because the component is an HTML Element, you can't have renderless components. Sure, you can create components that wrap other things and don't affect the DOM of its slotted contents, but that's still not renderless. For example:
my-wrapper
still very much exists
As I discussed in a very long-winded post elsewhere, accessing live javascript values in memory through the DOM like this is perilous and inconvenient. It turns out that the children of the wrapper end up with the responsibility of knowing what's in the parent element instance, and have to reach out for it manually. And this reaching out, traversing upwards through possibly any number of nodes and shadow roots, is awkward to say the least.
So while you can create provider components, the idea of a "virtual" component is completely missing. If you are going to tie yourself to a DOM element being created and then placed in the DOM, you are inherently unable to only create an "abstract" component that works behind the scenes.
Here is a concrete example of how using a framework allows you to create these renderless components to provide functionality without adding any elements to the DOM: in the vue js playground.
Pain point 2: Web components don't have a "scope"
Along with not having renderless components, you can't expose live javascript values to a "scope" for other components to consume.
Take, for example, a react component has a pattern called "render props." Because components are "just functions" you can pass non-string-values to and fro in the template. This allows for inversion of control. You can have components that pass control up to the consumer using the component, and the component can even give the controller specific pieces to work with. The classic example would be a list: sometimes you just want something to iterate throught a list, but the person using your "list" component should be able to determine how the list is actually rendered.
At base, the reason that this works is that the JSX you're looking at in the return statements for React components aren't the DOM. Those tags are a nice way to write out very nested objects. And because this JSX is only a blueprint of what should be rendered into the DOM, we have the ability to use "just javascript" on any level in that blueprint.
The same thing happens in other frameworks too, even if it's not jsx. Any templating language or construct used by frameworks creates a model of what the DOM should look like. You are not working with the DOM directly. Because of this detached abstraction you can pass objects, functions, classes, basically anything that's live javascript anywhere in that template.
So while frameworks have the ability to "scope" values in a template much like Javascript functions do, web components are islands, instantiated and placed in the DOM with no conception of what's above or below them. If a web component wants to provide some real live value, it can't be passed like you can in the templates of frameworks. Instead, it has to be put as a property on the web component for someone to locate and take at run time, kind of like a pie placed on a window sill for people to come along and take.
But, slots! Right?
Slots are a great feature of the shadow DOM, and yes, this allows limited inversion of control.
You'll quickly run into issues trying to implement things the way you're used to in frameworks, though. Let's say you want to create a popover, and the consumer can provide a button trigger and the content of the popover. Something like this:
You might already see a few things you wish you could do. For one, you wish the text of the trigger could change based on the state of the popover. Also, you might want to be provided the handler to attach where you want it attached. Something like this... (Totally fake code below, not real markup)
Alas we have to abandon those patterns, because without adopting an abstraction that handles a template, I know of no way to create this kind of API for a web component with plain HTML.
Yeah, but you have attributes, right?
Yep. But attributes can only be strings. We have been spoiled for a decade or so with frameworks that never had to rightly differentiate between what an attribute is versus a property of an element.
Basically, if you want to pass anything into a web component via an attribute, it must be serialized as a string. Attributes cannot take live javascript values. This means that any attempt to create an attribute for a web component that inverts control like the react list component above is not possible. (And I mean in a sane and safe way. There may be some frankenstein of javascript in html that would get this to happen, but I would seriously doubt that any attempt is truly a good pattern and/or safe from scripting hacks.)
Pain point 3: Templating, especially template interpolation, is just not possible yet.
This is why Lit exists. It gives us a light framework that gives us the
templating functionality while melding it with web component's restrictions.
Because Lit uses the html
helper, you can pass live javascript
variables to children components. But be aware: this is a framework
abstraction! This is not vanilla web components.
In an ideal world, we should eventually have some sort of templating construct in vanilla HTML. I created a concept stackblitz just to see what that might look like to be able to interpolate live javascript data into the contents from a slot. Example below, but be warned, this is just a toy demo and is not anywhere near robust enough to actually do in the real world:
Well, crap. Should we not use web components then?
Emphatically, web components are powerful and have great use cases
The reason I write so much about these nuances is to try to clarify why some people are having trouble with web components, not to say that web components are just bad.
We are still figuring out web components, and I think many great solutions will be found and implemented by browsers very soon!
In my experience, here are where web components are especially powerful:
- Building component libraries,
- Creating larger UI pieces that are pretty encapsulated- like a code playground
- Creating presentational components that are re-used often (look at the inspector on reddit for example)
- Needing to publish small parts of UI in a micro-framework-ish way
If you want to see a great post with event more good use cases check out this summary by Dave Rupert.
Welp, that's enough for now.
Add a comment below or find me on Bluesky or Mastadon. I also have an RSS feed here