Web component browser APIs aren't that many, and not that hard to grasp (if you don't know about them, have a look at Google's Learn HTML section and MDN's Web Components guide); but creating a web component actually requires taking care of many small things. This is where web component libraries come in very handy, freeing us of having to think about some of those things by taking care of them for us. Most of the things I'll mention here are handled one way of another by other libraries (GitHub's Catalyst, Haunted, Hybrids, Salesforce's LWC, Slim.JS, Ionic's Stencil) but I'll focus on Google's Lit and Microsoft's FAST here as they probably are the most used web component libraries out there (ok, I lied, Lit definitely is, FAST not that much, far behind Lit and Stencil; but Lit and FAST have many things in common, starting with the fact that they are just native web components, contrary to Stencil that compiles to a web component). Both Lit and FAST leverage TypeScript decorators to simplify the code even further so I'll use that in examples, even though they can also be used in pure JS (decorators are coming to JS soon BTW). I'll also leave the most apparent yet most complex aspect for the end.
Let's dive in!
Registration
It's a small detail, and I wouldn't even consider it a benefit; it's mostly syntactic sugar, but with FAST at least it also does a few other things that we'll see later: registering the custom element.
In vanilla JS, it goes like this:
class MyElement extends HTMLElement {}
customElements.define('my-element', MyElement);
With Lit:
@customElement('my-element')
class MyElement extends LitElement {}
And with FAST:
@customElement({
name: 'my-element',
// other properties will come here later
})
class MyElement extends FASTElement {}
Attributes and Properties
With native HTML elements, we're used to accessing attribute (also known as content attributes in the HTML spec) values as properties (aka IDL attributes) on the DOM element (think id
, name
, checked
, disabled
, tabIndex
, etc. even className
and htmlFor
although they use sligthly different names to avoid conflicting with JS keywords) with sometimes some specificities: the value
attribute of <input>
elements is accessible through the defaultValue
property, the value
property giving access to the actual value of the element (along with valueAsDate
and valueAsNumber
that additionally convert the value).
Custom elements have to implement this themselves if they want it, and web component libraries make it a breeze.
They help us reflect properties to attributes when they are modified (if that's what we want; and all while avoiding infinite loops), convert attribute values (always strings) to/from property values, or handle boolean attributes (where we're only interested in their absence or presence, not their actual value: think checked
and disabled
).
Let's compare some of these cases, first without library:
class MyElement extends HTMLElement {
// Attributes have to be explicitly observed
static get observedAttributes() {
return [ 'reflected-converted', 'reflected', 'non-reflected', 'bool' ];
}
#reflectedConverted;
// In a real element, you'd probably use a getter and setter
// to somehow update the element when the property is set.
nonReflected;
attributeChangedCallback(name, oldValue, newValue) {
switch (name) {
case 'reflected-converted':
// Convert the attribute value
this.reflectedConverted = newValue != null ? Number(newValue) : null;
break;
case 'non-reflected':
this.nonReflected = newValue;
break;
// other attributes handled in accessors below
}
}
get reflectedConverted() {
return this.#reflectedConverted;
}
set reflectedConverted(newValue) {
// avoid infinite loop with attributeChangedCallback
if (newValue !== this.#reflectedConverted) {
this.#reflectedConverted = newValue;
if (newValue == null) {
this.removeAttribute('reflected-converted');
} else {
// Here we let the browser automatically convert to string
this.setAttribute('reflected-converted', newValue);
}
}
}
get reflected() {
return this.getAttribute('reflected');
}
set reflected(newValue) {
if (newValue == null) {
this.removeAttribute('reflected');
} else {
this.setAttribute('reflected', newValue);
}
}
get bool() {
return this.hasAttribute('bool');
}
set bool(newValue) {
this.toggleAttribute('bool', newValue);
}
}
Now with Lit:
class MyElement extends LitElement {
@property({ attribute: 'reflected-converted', type: Number, reflect: true})
reflectedConverted?: number;
@property({ reflect: true })
reflected?: string;
@property({ attribute: 'non-reflected' })
nonReflected?: string;
@property({ type: Boolean })
bool: boolean = false;
}
And with FAST:
class MyElement extends FASTElement {
@attr({ attribute: 'reflected-converted', converter: nullableNumberConverter })
reflectedConverted?: number;
@attr reflected?: string;
@attr({ attribute 'non-reflected', mode: 'fromView' })
nonReflected?: string;
@attr({ mode: 'boolean' })
bool: boolean = false;
}
Early-initialized properties
Another thing with properties is that they could be set on a DOM element even before it's upgraded: the script that defines the custom element does not need to be loaded by the time the browser parses the custom tag in the HTML, and some script might access that element in the DOM before the script that defines it has loaded; only then will the element be upgraded: the class instantiated to take control of the custom element.
When that happens, you wouldn't want properties that would have been set on the element earlier to be overwritten by the upgrade process.
Without a library, you would have to take care of it yourself with code similar to the following:
class MyElement extends HTMLElement {
constructor() {
super();
// "upgrade" properties
for (const propName of ['reflectedConverted', 'reflected', 'nonReflected', 'bool']) {
if (Object.hasOwn(this, propName)) {
let value = this[propName];
delete this[propName];
this[propName] = value;
}
}
}
}
Again, libraries do that for you, automatically, based on the previously seen declarations.
Responding to property changes
The common way to respond to property changes is to implement a setter. This requires also implementing a getter though, as well as storing the value in a private field. When changing the value from attributeChangedCallback
, make sure to also use the setter and not assign directly to the backing field.
To respond to changes to the nonReflected
property in the above example, one would have to write it like so:
#nonReflected;
get nonReflected() {
return this.#nonReflected;
}
set nonReflected(newValue) {
this.#nonReflected = newValue;
// respond to change here
}
Both Lit and FAST provide their own way of doing this, though most of the time this is not really needed given that most reaction to change is to update the shadow tree, and Lit and FAST have their own ways of doing this (see below for more about rendering).
With Lit, you listen to changes to any property and have to tell them apart by name, a bit similar to attributeChangedCallback
but batched for several properties at a time:
@property({ attribute: 'non-reflected' })
nonReflected?: string;
// You could also use updated(changedProperties), depending on your needs
willUpdate(changedProperties: PropertyValues<this>) {
if (changedProperties.has("nonReflected")) {
// respond to change here
}
}
With FAST, you can implement a method with a Changed
suffix appended to the property name:
@attr({ attribute 'non-reflected', mode: 'fromView' })
nonReflected?: string;
nonReflectedChanged(oldValue?: string, newValue?: string) {
// respond to change here
}
Shadow DOM and CSS stylesheets
The most efficient way to manage CSS stylesheets in Shadow DOM is to use so-called constructable stylesheets: construct a CSSStyleSheet
instance once (or soon import
a CSS file), then reuse it in each element's shadow tree through adoptedStyleSheets
:
const sheet = new CSSStyleSheet();
sheet.replaceSync(`
:host { display: block; }
:host([hidden]) { display: none; }
`);
class MyElement extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.adoptedStyleSheets = [ sheet ];
}
}
With Lit, you'd rather use this more declarative syntax:
class MyElement extends HTMLElement {
static styles = css`
:host { display: block; }
:host([hidden]) { display: none; }
`;
}
and similarly with FAST:
const styles = css`
:host { display: block; }
:host([hidden]) { display: none; }
`;
@customElement({
name: 'my-element',
styles,
})
class MyElement extends FASTElement {}
Constructable stylesheets currently still require a polyfill in Safari though (this is being added in Safari 16.4), but both Lit and FAST take care of this for you.
Rendering and templating
The most efficient way to populate the shadow tree of a custom element is by cloning a template that has been initialized once.
That template could be any document fragment but the <template>
element was made specifically for these use-cases.
You would then retrieve nodes inside the shadow tree to add event listeners and/or manipulate it in response to those inside events or to attribute and property changes (see above) from the outside.
const template = document.createElement('template');
template.innerHTML = `
<button>Add</button>
<output></output>
`;
class MyElement extends HTMLElement {
#output;
constructor() {
super();
// … (upgrade properties as seen above) …
this.attachShadow({ model: 'open' });
this.shadowRoot.append(template.content.cloneNode(true));
// Using an <output> element makes it easier
// We could also create a text node and append it ourselves
this.#output = this.shadowRoot.querySelector('output');
this.shadowRoot.querySelector('button').addEventListener('click', () => this.count++);
this.#output.value = this.count;
}
// … (count property, with attribute changed callback and converter) …
set count(value) {
// … (reflect to attribute or whatever) …
this.#output.value = value;
}
}
customElements.define('my-element', MyElement);
Things get much more complex when you want to conditionally render some subtrees (the easiest probably being to toggle their hidden
attribute), or render and update a list of elements.
This is where Lit and FAST (and a bunch of other libraries) work much differently from the above, introducing the concept of reactive or observable properties and a render lifecycle based on a specific syntax for templates allowing placeholders for dynamic values, a syntax to register event listeners right from the template, and composability.
With Lit, that could look like:
@customElement('my-element')
class MyElement extends LitElement {
@property count: number = 0;
render() {
// No need for an <output> element here, though we could
// (and it would possibly even be better for accessibility)
return html`
<button @click=${this.#increment}>Add</button>
${this.count}
`;
}
#increment() {
this.count++;
}
}
and with FAST:
// No need for an <output> element here, though we could
// (and it would possibly even be better for accessibility)
const template = html<MyElement>`
<button @click=${x => x.count++}>Add</button>
${x => x.count}
`;
@customElement({
name: 'my-element',
template,
})
class MyElement extends FASTElement {
@attr({ converter: numberConverter }) count: number = 0;
}
The way Lit and FAST work is by observing changes to the properties and scheduling an update everytime it happens.
With Lit, the update (also called rerender) will call the render()
method of the component and then process the template. The rerender is scheduled using a microtask such that it can batch changes to multiple properties into a single rerender.
With FAST, the update is instead scheduled using requestAnimationFrame
(achieving the same batching as Lit) and will call every lambda of the template that needs to be: FAST tracks which dynamic part uses which properties to only reevaluate those parts when a given property changes.
In the examples above, any change to the count
property, either from the outside or in response to the click of the button, schedules a update.
And in FAST's case, only the x => x.count
lambda is called and the corresponding DOM node updated.
In Lit's case, the button's click listener would also be evaluated, but determined to be the same as before so no change would be performed.
The html
tagged template literal (both in Lit and FAST, also in other libraries such as @github/jtml
) will use a <template>
under the hood to parse the HTML once and reuse it later, just like the css
seen earlier uses a constructable stylesheet. It puts markers in the HTML (special comments, elements or attributes) in place of the dynamic parts so it can find them back to attach event listeners and inject values, making it possible to surgically update only the nodes that need it, without touching anything else (FAST actually being even more surgical by tracking the properties used in each dynamic part).
With Lit's render()
method returning such a template and called each time a property or internal state changed, its programming model looks a bit like React, rerendering and returning a new JSX each time a prop or state changed;
while FAST's approach looks a bit more like Angular (or Vue or Svelte) where each component is associated with a single template at definition time.
Other niceties
Lit and FAST also provide some helpers to peek into the shadow tree: to get a direct reference to some node, or the <slot>
s' assigned nodes.
Lit also pioneers reactive controllers that allow code reuse between components through composition, where the controller can hook into the render lifecycle (i.e. trigger a rerender of the component that uses the controller).
The goal is ultimately to make them reusable across frameworks too.
Some have already embraced it, like Haunted with its useController()
that allows using reactive controllers in Haunted components, or Apollo Elements that's built around reactive controllers. Lit also provides a useController()
React hook as part of its @lit-labs/react
package (that also makes it easier to use a Lit component in a React application by wrapping it as a React component), and there are prototypes for several frameworks such as Angular or Svelte, or even native web components through a mixin.
FAST developers are interested in supporting reactive controllers too, but those currently don't quite match with the way FAST updates the rendering.
The Lit team provides reactive controllers to manage contextual values, easily wire asynchronous tasks to rendering, wrap a bunch of native observers (mutation, resize, intersection, performance), or handle routing. Others are embracing them too: Apollo Elements for GraphQL already cited above; James Garbutt has a collection of utilities for Lit, many of them being reactive controllers usable outside Lit; Nano Stores provide reactive controllers; Guillem Cordoba has controllers for Svelte Stores; etc.
Conclusion
Web component libraries are really helpful to streamline the development of your components. While you could develop custom elements without library, chances are you'd be eventually creating your own set of helpers, reinventing the wheel (there are more than enough ways to build web components already). Understand what each library brings and pick one.