Photo by Ricardo Gomez Angel on Unsplash
This gist is a collection of common patterns I've personally used here and there with Custom Elements.
These patterns are all basic suggestions that could be improved, enriched, readapted, accordingly with your needs.
Whenever we need to simulate some native attribute behavior, we should find an answer in one of these patters.
This pattern simulates input.checked
or button.disabled
like behavior, where the attribute doesn't really need to have a value, rather being present, or not.
This pattern works with both DOM APIs and direct property access, as in el.hasAttribute('checked')
or el.checked
.
class extends HTMLElement {
get checked() {
return this.hasAttribute('checked');
}
set checked(value) {
const bool = !!value;
// do nothing with same state
if (bool === this.checked) return;
if (bool)
this.setAttribute('checked', '');
else
this.removeAttribute('checked');
// eventually dispatch an event to propagate the change
// i.e. in the checked attribute case:
this.dispatchEvent(new Event('change', {bubbles: true}));
}
}
With HyperHTMLElement, this pattern is automatically provided via static get booleanAttributes() { return [...]; }
.
This pattern simply reflects attributes values on the node.
class extends HTMLElement {
static get observedAttributes() {
return ['value'];
}
attributeChangedCallback(name, old, value) {
// react to attribute changes
// from either the DOM attributes world, or the JS one.
// All attributes values will be either strings or null.
// Optionally avoid reacting if (old === value)
// or (value === null), meaning attribute was removed
// Optionally dispatch an attributechange event.
}
get value() {
return this.getAttribute('value');
}
set value(value) {
this.setAttribute('value', value);
// eventually dispatch an event to propagate the change
}
}
Please note, to delete
an attribute we need to el.removeAttribute('value')
to reflect this operation on the DOM.
Alternatively, removeAttribute
could be used in the setter when value
is either null
or undefined
.
Let's not forget that users have a dedicated namespace for data, which reflect automatically in the component.
class MyThing extends HTMLElement {
static get observedAttributes() {
return ['data-foo', 'data-bar', 'data-baz'];
}
attributeChangedCallback(name, oldValue, newValue) {
name = name.slice(5);
this[`on${name}changed`]();
}
}
Any relevant dataset operation will be reflected on the node when, as example, this.datased.foo = "bar"
happens.
Similarly to the previous reflected DOM attributes pattern, this one retains more complex data as JSON.
While this might look like a bad idea, it's a perfectly valid use case for Server Side Rendered pages that provides Custom Elements with associated data.
class extends HTMLElement {
static get observedAttributes() {
return ['value'];
}
attributeChangedCallback(name, old, value) {
// react to data change
// from either the DOM attributes world, or the JS one
// optionally avoid reacting if (old === value)
if (value != null)
value = JSON.parse(value);
// do something with value
}
get value() {
const value = this.getAttribute('value');
return value == null ? value : JSON.parse(value);
}
set value(value) {
this.setAttribute('value', JSON.stringify(value));
// eventually dispatch an event to propagate the change
}
}
This pattern simulates an input.value
like behavior, but it comes with the ability to pass any kind of data to the accessor.
The observed attribute is mostly used to setup once from the live DOM, in case the element is alredy there (via SSR).
class extends HTMLElement {
static get observedAttributes() {
return ['value'];
}
#value = null;
attributeChangedCallback(name, old, value) {
// pass through the setter to have one place to handle all changes
// optionally skip the setter if old === value || #value === value
this[name] = value;
}
get value() {
return this.#value;
}
set value(value) {
// react to properties changes, if needed (i.e. value !== this.value)
this.#value = value;
// optionally dispatch an event to propagate the change
}
}
If your browser doesn't support private class fields, or you don't transpile your code, the pattern would be very similar to the following one.
const privates = new WeakMap;
const _ = self => {
let p = privates.get(self);
return p || (privates.set(self, p = Object.create(null)), p);
};
class extends HTMLElement {
static get observedAttributes() {
return ['value'];
}
attributeChangedCallback(name, old, value) {
_(this)[name] = value;
}
get value() {
return _(this).value;
}
set value(value) {
_(this).value = value;
}
}
Particularly useful when Custom Elements are used in declarative ways, this pattern centralizes the setup of any property so that <a-person name="John" age="20">
will result into {name: "John", age: 20}
value for this.props
.
class extends HTMLElement {
static get observedAttributes() {
return ['name', 'age'];
}
static get transformAttributes() {
return {
name: String,
age: value => parseInt(age, 10)
};
}
#props = {};
attributeChangedCallback(name, old, value) {
const {transformAttributes} = this.constructor;
this.#props[name] = transformAttributes[name](value);
}
get props() {
return this.#props;
}
}
Alternatively, a props
getter might retrieve all attributes at once, but since there won't be any special meaning, validation, or value, and all attributes will be simply strings, the dataset
and its related data-
attributes would be a more approriate way to obtain a props
like object, so that <a-person data-name="John" data-age="20">
will simply expose {name, age} = this.dataset
at any time.
There's really nothing new to learn here, about events, but the following patterns are still not fully known/used in the wild.
Previously described in deep, this pattern is a classic, simplified, memory and CPU efficient way, to handle any event via components themselves.
class extends HTMLElement {
connectedCallback() {
this.addEventListener('click', this);
this.addEventListener('change', this);
this.addEventListener('connected', this);
this.addEventListener('disconnected', this);
}
// one method to rule them all
handleEvent(event) {
this[`on${event.type}`](event);
}
// any event added via addEventListener
onclick(event) { /* do something */ }
onchange(event) { /* do something */ }
onconnected(event) { /* do something */ }
ondisconnected(event) { /* do something */ }
}
We could use a constructor
to add events once too, it doesn't really matter though, 'cause even if we add the same listener N times the result is like adding it once and no more.
My rule of thumbs with listeners is the following one:
- if it's a user related event (as in
click
), theconnectedCallback
is a better place, anddisconnectedCallback
might also cleanup events - if it's a synthetic event for component purpose only, setup once in the
constructor
'cause these events might be handy offline too
In HyperHTMLElement, as well as in wickedElements, all methods (inherited or not) that starts with on...
will be added as listeners automatically, and it's always possible to remove these listeners using the instance itself.
The callbacks mechanism is great for component themselves, but useless for components consumers, unless they have a MutationObserver
that crawls all nodes each time.
To simplify the notification of life cycle events, just dispatch through the node, and possibly without bubbling, so that there won't be a bubbling "hell" when many components and nested components provides same life cycle events.
class extends HTMLElement {
attributeChangedCallback(name, old, value) {
// do something ... then, at the end
const event = new Event('attributechanged');
// optionally prevent these properties from changing
e.attributeName = name;
e.oldValue = old;
e.newValue = value;
this.dispatchEvent(event);
}
connectedCallback() {
// do something ... then, at the end
this.dispatchEvent(new Event('connected'));
}
disconnectedCallback() {
// do something ... then, at the end
this.dispatchEvent(new Event('disconnected'));
}
}
I love the python-like dispatcher pattern for events!