Navigation Menu

Skip to content

Instantly share code, notes, and snippets.

@WebReflection
Last active February 14, 2024 00:13
Show Gist options
  • Star 57 You must be signed in to star a gist
  • Fork 3 You must be signed in to fork a gist
  • Save WebReflection/ec9f6687842aa385477c4afca625bbf4 to your computer and use it in GitHub Desktop.
Save WebReflection/ec9f6687842aa385477c4afca625bbf4 to your computer and use it in GitHub Desktop.
Handy Custom Elements' Patterns

Handy Custom Elements' Patterns

Ricardo Gomez Angel 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.


Attributes

Whenever we need to simulate some native attribute behavior, we should find an answer in one of these patters.

Boolean Attributes

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 [...]; }.

Reflected DOM Attributes

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.

Reflected Dataset Attributes

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.

Reflected DOM Data Attributes

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
  }
}

Own Attributes as Accessors

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;
  }
}

A props like accessor

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.


Events

There's really nothing new to learn here, about events, but the following patterns are still not fully known/used in the wild.

Handling Events

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), the connectedCallback is a better place, and disconnectedCallback 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.

Life Cycle Events

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'));
  }
}
@NCarson
Copy link

NCarson commented Jan 12, 2023

I love the python-like dispatcher pattern for events!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment