einarvalur.co

Role a tight WebComponent

I was trying to build a SPA with WebComponents. There was a lot of trial and error, but in the end I settled on some ground rules that worked for me

constructor()

Only use the constructor to attach the shadowRoot (if you want to use it), set the template and bind function. That is, the constructor would look something like this:

export default class Component extends HTMLElement {
    constructor() {
        super();
        this.attachShadow({mode: 'open'});
        this.shadowRoot.innerHTML = `...some markup...`;

        this.handleEvent = this.handleEvent.bind(this)
    }

    handleEvent(event) {}
}
window.customElements.define('x-component', Component);

Because this Component has probable not been imported into DOM tree when the constructor runs, it's better not to do any fancy stuff in the constructor.

connectedCallback

This method runs when the component is connected/imported into DOM tree. This is the perfect place to set up any event listeners. Also, if the user has defined attributes on the component, this is the first place there the WebComponent is exposed to them. I therefor use the connectedCallback to check for attributes and set default values if attributes are not defined.

export default class Component extends HTMLElement {
    connectedCallback() {
        this.hasAttribute('property') && this.setAttribute('property', 'value');

        this.shadowRoot.querySelector('[data-button]').addEventListener('click', this.handleEvent);
    }
}

I really like this short circuit syntax for checking if the attribute has been set.

When querying the shadowRoot for element, I tend to use data-* rather than CSS class names or ids. CSS classes should only really be used for applying CSS rules, and you should be able to rename them without event listeners breaking. Even though id attributes will be local to only this shadowRoot and not exposed to other parts of the DOM, you can't query multiple elements on a single ID:

    this.shadowRoot.querySelectorAll('#id')

This wouldn't work. Therefor I use data-* to define any JS interaction with shadowDOM elements.

disconnectedCallback

While I haven't tested this out to the n-th degree, I think it's quite important to clean up after yourself when the component is removed. If not, the component could not be garbage-collected and you end up with an over-blown heap and a memory leak.

Obviously, if you attach an event to an element outside of the component, remove it:

export default class Component extends HTMLElement {
    handleEvent(event) {}

    connectedCallback() {
        window.addEventListener('resize', this.handleEvent);
    }

    disconnectedCallback() {
        window.removeEventListener('resize', this.handleEvent);
    }
}

But also any references to the internals of the component should be disconnected

export default class Component extends HTMLElement {
    animationFrame;

    animation() {
        // ...some animation stuff...
        this.animationFrame = requestAnimationFrame(this.animation);
    }

    connectedCallback() {
        this.animation();
    }

    disconnectedCallback() {
        cancelAnimationFrame(this.animationFrame);
    }
}

Without having tested this out completely, what I think will happen is that since requestAnimationFrame is a method on window, even though our WebComponent is removed from the DOM tree, there is still a reference to the Component.animate method and the Node will not be garbaged-collected.

attributeChangedCallback

If you want to listen to any attribute changes to the WebComponent, you need to set up the observedAttributes and attributeChangedCallback methods. This is where all state changes should be defined for the component.

export default class Component extends HTMLElement {
    static get observedAttributes() { return ['property']; }

    attributeChangedCallback(name, oldValue, newValue) {

        switch (name) {
            case 'property':
                //...
                break;
        }
    }
}

Just remember that attributeChangedCallback will be called for any attribute defined on the WebComponent when it is inserted into the document

<html>
    <head></head>
    <body>
        <x-component property="value"></x-component>

        <script type="module">
            class Component extends HTMLElement {
                constructor() {
                    super();
                    this.attachShadow({mode: 'open'});
                    this.shadowRoot.innerHTML = '<h1>hello world</h1>'
                }

                static get observedAttributes() { return ['property']; }

                attributeChangedCallback(name, oldValue, newValue) {
                    switch (name) {
                        case 'property':
                            console.log(name, newValue)
                            break;
                    }
                }
            }

            window.customElements.define('x-component', Component);
        </script>
    </body>
</html>

If you copy/paste this into a file and run in a browser, the console will print out property hallo. But if we don't define any attribute on the <x-component />, nothing will happen. That's why it is such a good idea to initialize any attributes in the connectedCallback method.

But, don't do this

export default class Component extends HTMLElement {
    static get observedAttributes() { return ['color']; }

    connectedCallback() {
        this.shadowRoot.querySelector('[data-element]').style.backgroundColor = this.hasAttribute('color') 
            ? this.getAttribute('color')
            : 'pink' ; // don't do this
    }

    attributeChangedCallback(name, oldValue, newValue) {
        switch (name) {
            case 'color':
                // ... nothing
                break;
        }
    }
}

it's far better to do this:

export default class Component extends HTMLElement {
    connectedCallback() {
        this.hasAttribute('color') && this.setAttribute('color', 'pink');   
    }

    attributeChangedCallback(name, oldValue, newValue) {
        switch (name) {
            case 'color':
                this.shadowRoot.querySelector('[data-element]').style.backgroundColor = newValue; // do this
                break;
        }
    }
}

This makes connectedCallback really clean and you know that the meat of the component is defined in one place: attributeChangedCallback

Getters and Setters.

Documentations from WebComponents go into great lengths to tell you that you should have a get/set pair for every attribute you define.

export default class Component extends HTMLElement {
    static get observedAttributes() { return ['property']; }

    get property () {}

    set property (value) {}
}

This is so you can use both types of API

const component = document.querySelector('x-component');

// this:
component.setAttrubute('property', 'value');

// or this
component.property = 'value';

And I guess that's a good idea. Just be consistent. HTML Attributes can only deal with string so your get/set should also only accept/return strings. The get/set pair should therefor only be an alias for setAttribute(value)/getAttribute()

export default class Component extends HTMLElement {
    get property () {
        return this.setAttribute('property');
    }

    set property (value) {
        this.setAttribute('property', String(value));
    }
}

Again, because attributeChangedCallback defines all the state-changes, there is no need to do any complicated things in the setter.

The attribute and the getter for href's anchor tag don't return the same thing:

const anchor = document.querySelector('a');

console.log(anchor.href) 
// http://domain.com/document.html

console.log(anchor.getAttribute('href')) 
// /document.html

Don't be that guy :)

If you are coming from the world of React where you can pass in complex objects to component as attributes:

const obj = {
    property: 'value';
};

<Component attr={obj} />

you might find it limiting to be able to only pass in strings as attributes. For that you do have the get/set methods. But then don't try to marry up the two things. Just treat them separate.

export default class Component extends HTMLElement {
    _property = {};

    get property () {
        return this._property;
    }

    set property (value) {
        this._property = value;
    }

    static get observedAttributes() { return ['property']; }

    attributeChangedCallback(name, oldValue, newValue) {
        switch (name) {
            case 'property':
                this._property = JSON.parse(newValue);
                break;
        }
    }
}

In this example the attribute and the private _property will get out of sync.

<html>
    <head></head>
    <body>
        <x-component property='{"one": 1}'></x-component>

        <script>
                const component = document.querySelector('x-component');

                console.log(component.getAttribute('property'));
                    // prints the string '{"one": 1}'

                console.log(component.property);
                    // prints the object {one: 1}

                component.setAttribute('property', '{"two": 2}');
                console.log(component.property);
                    // prints the object {two: 2}

                component.property = {three: 3};
                console.log(component.property);
                    //prints the object {three: 3}

                console.log(component.getAttribute('property'));
                    // prints the string '{"two": 2}'
        </script>
    </body>
</html>

It would be better to not use the _property at all and store the value as a string in the attribute mechanism with in the HTMLElement itself.

    class Component extends HTMLElement {
        constructor() {
            super();
            this.attachShadow({mode: 'open'});
            this.shadowRoot.innerHTML = '<h1>hello world</h1>'
        }

        get property () {
            return JSON.parse(this.getAttribute('property'));
        }

        set property (value) {
            this.setAttribute('property', JSON.stringify(value));
        }
    }

That fixes the sync issue, but what remains and can't be fixed is that the getter returns an object but the getAttribute returns a string.

The best thing is to not have private _property and the attribute be associated in any way.