Front-end education for the real world. Since 2018.





Functional custom elements the easy way

Ginger

Topic: JavaScript

When you need a sprinkling of JavaScript on your web page, there is no better answer than Custom Elements (or Web Components). You get a lot of “framework goodies” for free, built in to the language, and you don’t need to include any frameworks that bloat your web page size into the hundreds of kilobytes, or even megabytes.

The downside is that custom elements are quite verbose. If you want to define a Custom Element, you have to extend a class that has the HTMLElement class somewhere in its prototype chain. If you used React before functional components came into being, this is not a big deal to you. But if you have grown used to, and love, the way functional components are written, it is a pain to deal with.

To help with this, I have written multiple variations of a define utility function. It takes a name and a function, and in return, you get a more functional way to define custom elements.

What do we need?permalink

Custom Elements come with a handful of lifecycle callback methods, these are connectedCallback, disconnectedCallback, adoptedCallback. You can get reactive goodies by adding static get observedAttributes() to your element. This will enable the attributeChangedCallback method that runs every time an attribute whose name is in the array returned by observedAttributes changes.

To define a Custom Element you need two things: a valid element name and a component definition.

The component definition is normally a class that extends HTMLElement directly or has it somewhere in the prototype chain. A valid element name is any string, all lowercase, with at least one “-” hyphen in the mix. So my-button, your-extremely-long-and-unweildy-element-name, or even just x-el are all valid. My-button will throw an error, as will just el.

Our utility function should accept a valid element name and component definition function that we can apply the Custom Element to. Since Custom Elements are class based, we can think of our function body as the constructor. In essence, our define function looks like this:

Code language
js

const define = (name, definition) => {
  customElements.define(name, class extends HTMLElement {
    constructor() {
      super();
      definition.apply(this);
    }
  })
}

this will point to the current Custom Element instead of something confusing.

Next, we need to add the callback methods. The simplest solution is to return an object that has methods mapped to the callbacks. Unfortunately, you cannot do something like this:

Code language
js

customElements.define(name, class extends HTMLElement {
  constructor() {
    const { connected } = definition.apply(this);
    this.connectedCallback = connected.bind(this);
  }
})

Instead, we can create private properties to “house” our callback methods, then call those properties from the Custom Element’s callback methods.

At this point, our define utility function looks like this:

Code language
js

const define = (name, definition) => {
  customElements.define(name, class extends HTMLElement {
    #connected;
    #disconnected;
    #adopted;
    #attributeChanged;
    constructor() {
      super();
      const { connected, disconnected, adopted, attributeChanged, props } = definition.apply(this) ?? {};
      this.#connected = connected?.bind(this);
      this.#disconnected = disconnected?.bind(this);
      this.#adopted = adopted?.bind(this);
      this.#attributeChanged = attributeChanged?.bind(this);
    }
    connectedCallback() {
      this.#connected?.();
    }
    disconnectedCallback() {
      this.#disconnected?.();
    }
    adoptedCallback() {
      this.#adopted?.();
    }
    attributeChangedCallback(...args) {
      this.#attributeChangedCallback?.(...args);
    }
  });
}

What all of this means is that our definition should return an object that has those related keywords. Thanks to the ?. optional chaining and the ?? nullish coalescing operators, we don’t need to return anything from our definition if we don’t need to use any callbacks. However, our definition cannot return a primitive or an object that can’t be destructured into those properties.

Code language
js

function MyButton() {
  const logTarget = ({ target }) => console.log(`The clicked element was: `, target);
  return {
    connected() {
      this.addEventListener('click', logTarget, true);
    },
    disconnected() {
      this.removeEventListener('click', logTarget, true);
    }
  }
}

But what about attributes?permalink

Unfortunately, even though observedAttributes can be defined as a static getter, you can’t return a dynamic variable from it. This means we can’t destructure a variable from our returned object and assign it to a variable that is returned by observedAttributes like so:

Code language
js

const define = (name, definition) => {
  let _props = [];
  customElements.define(name, class extends HTMLElement {
    // this will always be the empty array above
    static observedAttributes = _props;
    constructor() {
      super();
      const { props, attributeChanged } = definition.apply(this);
      this.#attributeChanged = attributeChanged.bind(this);
      _props = props;
    }
    attributeChangedCallback() {
      // this will never be called
      this.#attributeChanged?.();
    }
  })
}

To make sure the attributes we want to observe are known in time, I’ve opted to add a .attrs to the function when I need to use the attributeChangedCallback method. This predefines the attributes to watch, making them available when the element is constructed.

Code language
js

function MyButton() {
  // ...
}
MyButton.attrs = ['toggled'];
define('my-button', MyButton);

Then our define function would need a static property added.

Code language
js

const define = (name, definition) => {
  customElements.define(name, class extends HTMLElement {
    // ...
    static observedAttributes = definition.attrs;
  })
}

If we make changes to an attribute whose name is in our definition.attrs array, the attributeChangedCallback method will run.

What else can we do?permalink

This is where we get into the final touches and the “nice-to-haves”.

What if there is already an element with the passed name?

We can’t have two my-button’s, which means defining a second element with the same name will throw an error. To fix this we should wrap our customElements.define with an if (!customElements.get(name)) conditional.

What if you open source your code and someone doesn’t know about the name requirements?

We can create a localName variable that is equal to the name run through a regex to add a dash before any capital letters and then make it lowercase.

What if you don’t want to pass a name at all and instead want to use the definition function’s name?

Sure! Why not? We could even bundle all that together into our define function!

Code language
js

const define = (definition) => {
  let localName = definition.name.replace(/(.)([A-Z])/g, '$1-$2').toLowerCase();
  if (localName.indexOf('-') < 0) localName = `x-${localName}`;
  if (!customElements.get(localName)) {
    customElements.define(localName, class extends HTMLElement {
      // ...
    })
  }
}

A note about event handlingpermalink

While this is less necessary than the other things mentioned above, I am a fan of creating a $listen function to pass into the definition. The goal of this $listen function is to give component authors an easy way to listen for events and not worry about cleaning them up in the disconnectedCallback method.

It takes all the same arguments as addEventListener, and uses an AbortController to remove the listeners once the element is disconnected.

Code language
js

// inside of our custom element constructor
const ac = new AbortController();
// default to delegating event listeners to the custom element
const $listen = (evt, handler, options = true) => {
  let defaultOptions = { signal: ac.signal };
  if (typeof options === 'boolean') {
    defaultOptions.capture = options;
  } else {
    defaultOptions = Object.assign(options, defaultOptions);
  }
  this.addEventListener(evt, handler, defaultOptions);
}
this.ac = ac;

Then we can update our disconnectedCallback method:

Code language
js

// inside our custom element definition
disconnectedCallback() {
  this.#disconnected?.();
  // all event listeners with this signal in their options
  // will remove themselves once they receive the abort command
  this.ac.abort();
}

Then we can use it through out our component definition:

Code language
js

define(function Button({ $listen }) {
  let i = 0;
  $listen('click', () => {
    this.textContent = `I have been clicked ${++i} times.`;
  });
});

But…butpermalink

What about shadowRoot? What about <template shadowrootmode="open|closed">? What about templating with JSX? What about async function definition’s? What about…

Look, I get it, you want to encapsulate your component’s markup and love dealing with the styling in the shadowRoot. You see <label htmlFor={id}> and you don’t groan in dismay. You found a use-case for an async constructor. More power to you!

This utility function isn’t meant to be a framework replacer or an All-In-One solution. It’s meant to make writing custom elements as simple as writing a function. There’s nothing stopping you from adding this.attachShadow({ mode: 'open' }) to your definition or to use the <template shadowrootmode="open"> in your HTML. If you want to use JSX, there are plenty of micro libraries (such as uhtml) out there that can give you that particular nugget of DX.

To make life easier for you, here’s our final function for you to use however you like:

Code language
js

const define = (definition) => {
  let localName = definition.name.replace(/(.)([A-Z])/g, "$1-$2").toLowerCase();
  if (localName.indexOf("-") < 0) localName = `x-${localName}`;
  if (!customElements.get(localName)) {
    const ac = new AbortController();
    customElements.define(
      localName,
      class extends HTMLElement {
        static observedAttributes = definition.attrs ?? [];
        #connected;
        #disconnected;
        #adopted;
        #attributeChanged;
        constructor() {
          super();
          // inside of our custom element constructor
          const ac = new AbortController();
          // default to delegating event listeners to the custom element
          const $listen = (evt, handler, options = true) => {
            let defaultOptions = { signal: ac.signal };
            if (typeof options === "boolean") {
              defaultOptions.capture = options;
            } else {
              defaultOptions = Object.assign(options, defaultOptions);
            }
            this.addEventListener(evt, handler, defaultOptions);
          };

          this.ac = ac;

          const { connected, disconnected, adopted, attributeChanged, props } =
            definition.apply(this, [{ $listen }]) ?? {};

          this.#connected = connected?.bind(this);
          this.#disconnected = disconnected?.bind(this);
          this.#adopted = adopted?.bind(this);
          this.#attributeChanged = attributeChanged?.bind(this);
        }
        connectedCallback() {
          this.#connected?.();
        }
        disconnectedCallback() {
          this.#disconnected?.();
          this.ac.abort();
        }
        adoptedCallback() {
          this.#adopted?.();
        }
        attributeChangedCallback(...args) {
          this.#attributeChangedCallback?.(...args);
        }
      },
    );
  }
};

Enjoyed this article? You can support us by leaving a tip via Open Collective


Newsletter