Functional custom elements the easy way
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 aboutasync 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
