I’ve been doing some trials with web custom elements recently and start to be more efficient with them for basic applications. Not targeting complete isolation of my components, just building a moderately interactive page while separating concerns in components.

At first I had been struggling with data flow, but start to get a better idea on that. Data flows from outer component to inner ones with attributes, properties or constructor arguments. Data and flow from inner components to outer one with events.

An attribute appears in the HTML (and thus can be defined by server-side-rendering) and can be used by CSS ([attr=value]), but it is limited to strings. A property allows structured data to be passed, but it requires having access to the element instance and is thus restricted to Javascript. Attributes can be doubled as a property as a shortcut (and it is the case for many native elements). Constructor arguments are quite similar to properties except they can be specified from initialization, allowing immutable properties (but preventing creation with innerHTML or createElement).

class MyComponent extends HTMLDivElement {
  static observedAttributes = ['test-attribute'];

  #testImmutable;
  #testAttribute = '';
  #testProperty = [];

  constructor(immutable) {
    super();
    this.#immutable = immutable;
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'test-attribute') {
      this.#label = newValue;
    }
  }

  get testProperty() {
    return this.#testProperty;
  }
  set testProperty(value) {
    this.#testProperty = value;
  }

  get testImmutable() {
    return this.#immutable;
  }
}

customElements.define('my-component', MyComponent, { extends: 'div' });

element = new MyComponent('immutable');

element.setAttribute('test-attribute', 'Hello, world!');
element.getAttribute('test-attribute') === 'Hello, world!';

element.testProperty = [1, 2, 3];
element.testProperty.length === 3;

element.testImmutable === 'immutable';

Events allow the parents to know when something occurred in a child. Thanks to event bubbling, the parent can sometimes simply add an event listener on itself. Using CustomEvent allows providing data as detail, but the consumer can also read properties on the emitter thanks to target.

class ChildComponent extends HTMLDivElement {
  #uuid;

  constructor() {
    super();
    this.#uuid = Crypto.randomUUID();
  }

  connectedCallback() {
    this.dispatchEvent(new CustomEvent('welcome-event', {detail: `Hello, ${this.#uuid}!`}));
  }

  get uuid() {
    return this.#uuid;
  }
}

customElements.define('child-component', ChildComponent, { extends: 'div' });

class ParentComponent extends HTMLDivElement {
  connectedCallback() {
    this.innerHtml = `
<div is="child-component"></div>
<div is="child-component"></div>
<div is="child-component"></div>
`;

    this.addEventListener('welcome-event', (event) => {
      // happens 3 times
      console.log(event.detail); // Hello, 2689faf4-4657-4a80-adfc-c5dc71dfe212!
      console.log(event.target.uuid); // 2689faf4-4657-4a80-adfc-c5dc71dfe212
    });
  }
}

customElements.define('parent-component', ParentComponent, { extends: 'div' });

All this is bound together with an “update” function which is responsible for updating children elements. connectedCallback is used to create the DOM structure without actual content, adds event listeners, and then call the “update” function which hydrates the DOM. When a property or attribute changes, component internal state is updated and then the “update” fonction called. Since initial attributes are read before connectedCallback fires, “update” must be ready to handle missing DOM. (as an optimization there might be several such methods if different parts can be updated independently, but having one reduces mistakes and usually don’t impact performances)

class ChildComponent extends HTMLDivElement {
  #uuid;

  constructor(uuid) {
    super();
    this.#uuid = uuid;
  }

  connectedCallback() {
    this.innerHtml = `
<span></span>
<button>Delete me</button>
`;
    this.querySelector('span').textContent = this.#uuid;
    this.querySelector('button').addEventListener('click', (event) => {
      event.stopPropagation();
      this.dispatchEvent(new CustomEvent('deleted-event', {
        detail: {
          uuid: this.#uuid,
        },
      }));
    });
  }

  get uuid() {
    return this.#uuid;
  }
}

customElements.define('child-component', ChildComponent, { extends: 'div' });

class ParentComponent extends HTMLDivElement {
  static observedAttributes = ['title', 'children'];

  #title = '';
  #children = [];

  connectedCallback() {
    this.innerHtml = `
<h1></h1>
<ul>
</ul>
`;
    this.addEventListener('deleted-event', (event) => {
      this.#children = this.#children.filter((child) => child.uuid !== event.detail.uuid);
      this.#update();
    });

    this.#update();
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'title') {
      this.#title = newValue;
      this.#update();
    } else if (name === 'children') {
      this.#children = newValue.split(',');
      this.#update();
    }
  }

  #update() {
    const title = this.querySelector('h1');
    if (title) {
      title.textContent = this.#title;
    }

    const list = this.querySelector('ul');
    if (list) {
      // suboptimal: clear list and recreate all eleemnts
      while (list.firstChild) {
        list.removeChild(list.firstChild);
      }
      this.#children.forEach((uuid) => {
        const item = document.createElement('li');
        item.appendChild(new ChildComponent(uuid));
        list.appendChild(item);
      });
    }
  }
}

customElements.define('parent-component', ParentComponent, { extends: 'div' });

And server side can render:

<div is="parent-component" title="So many uuids!" children="2689faf4-4657-4a80-adfc-c5dc71dfe212,a492c551-3112-402e-94b6-5c887d4a7b81"></div>