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's undefined if you're using an arrow function. In
fact, one of the reasons React wanted to move to SFC and not use the old
Component classes was to remove the component instances that
React had to instantiate and manage, effectively getting rid of
this.
-
In Vue,
this is pretty useless in practice, because it's by
default undefined 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 binding
this 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, so this 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:
Click to access parent property
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:
lorem ipsum...
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)
lorem ipsum...
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: