DEV Community

Thomas Broyer
Thomas Broyer

Posted on • Originally published at blog.ltgt.net

The benefits of Web Component Libraries

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);
Enter fullscreen mode Exit fullscreen mode

With Lit:

@customElement('my-element')
class MyElement extends LitElement {}
Enter fullscreen mode Exit fullscreen mode

And with FAST:

@customElement({
    name: 'my-element',
    // other properties will come here later
})
class MyElement extends FASTElement {}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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 (this.hasOwnProperty(propName)) {
                let value = this[propName];
                delete this[propName];
                this[propName] = value;
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 ];
    }
}
Enter fullscreen mode Exit fullscreen mode

With Lit, you'd rather use this more declarative syntax:

class MyElement extends HTMLElement {
    static styles = css`
        :host { display: block; }
        :host([hidden]) { display: none; }
    `;
}
Enter fullscreen mode Exit fullscreen mode

and similarly with FAST:

const styles = css`
    :host { display: block; }
    :host([hidden]) { display: none; }
`;

@customElement({
    name: 'my-element',
    styles,
})
class MyElement extends FASTElement {}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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++;
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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.

Oldest comments (4)

Collapse
 
vegegoku profile image
Ahmad K. Bawaneh

A read worth the time, excellent and thank you.
How do you see the relation between J2CL and web-components in the future? or should we consider J2CL for business logic only and not for UI logic? do we expect the possibility to be able to optimize web-components?

Collapse
 
tbroyer profile image
Thomas Broyer

I wouldn't use Java to create web components (YMMV). To consume components, generate JsInterop interfaces (maybe even widgets) from a custom element manifest, or a .d.ts.

Collapse
 
vegegoku profile image
Ahmad K. Bawaneh

Makes sense, but I would love to learn about why not?

Thread Thread
 
tbroyer profile image
Thomas Broyer

I don't think there's any web component library that's (directly) usable from J2CL, so you'd first have to make one. It might be possible to use Lit or FAST from J2CL but you'd likely want to first create some tooling to make the calls to tagged templates more readable; e.g. from an interface similar to SafeHtmlTemplate:

interface MyTemplate extends LitHtmlTemplates {
     @Template("<span class=\"{3}\">{0}: <a href=\"{1}\">{2}</a></span>")
     TemplateResult messageWithLink(TemplateResult message, SafeUri url, String linkText, String style);
}
Enter fullscreen mode Exit fullscreen mode

to

static final String[] STRINGS = { "<span class=\"", "\">", ": <a href=\"", "\">", "</a></span>" };
// …
public TemplateResult messageWithLink(TemplateResult message, SafeUri url, String linkText, String style) {
    return Lit.html(STRINGS, style, message, url.asString(), linkText);
}
Enter fullscreen mode Exit fullscreen mode

assuming a definition like:

@JsMethod
public native TemplateResult html(String[] strings, Object... values);
Enter fullscreen mode Exit fullscreen mode

But I don't see much use for J2CL or GWT nowadays (J2CL might be useful for sharing logic with the backend or a native app, the same way Google uses it, but not really for building UI). I mean, even Vaadin is pivoting with Hilla. That's my personal opinion though, and I won't try to push it through anyone's throat. Feel free to disagree, but I'm not interested in arguing about any of it.