The web components experience

After a lot of back and forth, web components are slowly making their way into browsers. But what do they really mean given the current state of development?

To answer this questions I moved an entire application from React to web components, a good exercise to spot the differences between both choices and how this new native feature work and what they really solve in an application.

Declaration

The entry selling points of components is their declarative and reusable nature, this benefit is around architecture of a project that needs to scale and not tied to technologies.

import React from 'react';

class MovieBox extends React.Component {
  constructor() {
    super();
  }
}

export default MovieBox;

React introduced a new paradigm to define small units of functionality which can be imported and used in different parts of a project. Web components declaration imposes something really similar.

class MovieBox extends HTMLElement {
  constructor() {
    super();
  }
}

window.customElements.define('movie-box', MovieBox);

As JSX needs React components to be capitalized, custom element tag names need to contain a hyphen.

The big difference here is that the custom element becomes globally available.

As long as you are including its declaration in your bundle you can instantiate using the <movie-box> tag or creating an element via document.createElement to later inject it in the document.

Explicitly using export in a module that contains a custom element definition isn't necessary but it might be useful for testing purposes or extending the class to create a new type of component from it.

Lifecycle callbacks

In all frameworks we have some combination of callbacks that fire at a certain moment of a component's life.

class MovieBox extends HTMLElement {
  constructor() {
    super();
  }

  connectedCallback() {}

  disconnectedCallback() {}

  adoptedCallback() {}

  attributeChangedCallback() {}
}

Quick description of all these four methods if you are coming from React or similar libraries, connectedCallback equals to componentDidMount and disconnectedCallback equals to componentWillUnmount.

adoptedCallback is fired when the component is moved to a new document, something that I can't imagine happening too often.

The last one, attributeChangedCallback, fires whenever an observed attribute has changed, something that I'm going to explain further in this article.

From render to shadow DOM

React pushed a lot of tools into the spotlight like JSX and transpiling. Though this arised some controversy, truth is they really improved the developer experience when expressing a component's content.

Without both, using React.createElement interface would be mandatory, and to be honest, messy in complex situations.

class MovieBox extends React.Component {
  render() {
    return (
      <div className="movie__box">
        <h2 className="title">{this.props.title}</h2>
      </div>
    );
  }
}

In custom elements, we declare the template of our component with shadow DOM. Switching back to HTML means we lose that reactivity that stateful React components gave us.

To better understand what shadow DOM is imagine DOM elements as you know them as light DOM, elements that can be modified outside of its own DOM tree.

On the other hand, shadow DOM can only be accessed and styled inside of its host element, giving us native encapsulation.

class MovieBox extends HTMLElement {
  constructor() {
    super();

    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <div class="movie__box">
        <h2 class="title">${this.getAttribute('title')}</h2>
      </div>
    `;
  }
}

By calling this.attachShadow() we create a shadow root in the element, we can insert markup and styles making sure they won't leak outside the component.

Since there is no such a thing as props in custom elements, we use attributes to inject data.

The template tag

The issue with this approach is we trigger HTML parsing for each element that gets instantiated. To avoid this we can create a template tag, clone its content and append it to the shadow root.

const template = document.createElement('template');

template.innerHTML = `
  <div class="movie__box">
    <h2 class="title"></h2>
  </div>
`;

class MovieBox extends HTMLElement {
  constructor() {
    super();

    this.attachShadow({ mode: 'open' });
    this.shadowRoot.appendChild(template.content.cloneNode(true));
  }
}

We are now paying the parsing cost only once, no matter how many instances of the element we put in the document.

This improvement brings a new issue though, now we aren't able to take advantage of template strings to pour data into components.

constructor() {
  super();
  this.attachShadow({ mode: 'open' });
  this.shadowRoot.appendChild(template.content.cloneNode(true));
}

connectedCallback() {
  const titleElement = this.shadowRoot.querySelector('.title');
  titleElement.textContent = this.getAttribute('title');
}

DOM manipulation is recommended once the element has connected.

It's inevitable to go back to DOM scraping, which is not particularly a bad thing, but something that will require a lot of advocating to standarize inside a team.

The host

As I was translating components written in React to custom elements I noticed I was creating unnecesary nodes. When we extend the HTMLElement we already have a tag, so try to avoid wrappers.

const template = document.createElement('template');

template.innerHTML = `<h2 class="title"></h2>`;

The tree inside the component will be:

<movie-box title="Zootopia">
  <!-- shadow dom -->
  <h2 class="title">Zootopia</h2>
</movie-box>

Styles that were affecting the .movie__box selector can be moved to :host to be applied to the root tag of our component.

const template = document.createElement('template');

template.innerHTML = `
  <style>
    :host {
      display: block;
      padding: 1em;
    }

    .title {
      color: #101010;
    }
  </style>
  <h2 class="title"></h2>
`;

DOM manipulation and refs

Moving to web components means going back to DOM manipulation, something that hardly occurs when working with frameworks and one of the reasons why web applications built with them scale up quickly and easier.

If you know well the basic DOM APIs and how they work it's not that big deal to be honest, but I did really missed some features, like refs.

const template = document.createElement('template');

template.innerHTML = `
  <div class="movie__box">
    <h2 class="title" ref="title"></h2>
  </div>
`;

class MovieBox extends HTMLElement {
  constructor() { ... }

  connectedCallback() {
    this.refs.title.textContent = this.getAttribute('title');
  }
}

If this was possible, it will simplify a lot data injection and mutations on components with a high complexity.

A homebaked solution for refs

Bringing this feature to web components is possible by selecting all elements with a ref attribute and save them into an object.

function collectRefs() {
  const refsArray = [...this.shadowRoot.querySelectorAll('[ref]')];

  if (refsArray.length > 0) {
    this.refs = {};

    refsArray.map((el) => {
      this.refs[el.getAttribute('ref')] = el;
      el.removeAttribute('ref');
    });
  }
}

After looping over the references we can even remove the attribute. Of course, we still need to manually call the method in the component's lifecycle.

connectedCallback() {
  // collect refs
  collectRefs.call(this);

  // use them!
  this.refs.title.textContent = this.getAttribute('title');
}

This solution works and speeded up a lot the writing transition to me, but it's not still what refs are for frameworks since it doesn't support mutations inside the shadow DOM.

For example, if we remove the title ref element in the previous code and then re-insert it, an error would be thrown when referencing to this.refs.title since the original node doesn't exist anymore.

From children to slots

On React components we use { this.props.children } to render child nodes.

Custom elements and shadow DOM contain a similar, and probably more powerful interface to place content inside web components.

const template = document.createElement('template');

template.innerHTML = `
  <style>
    :host {
      background-color: #fefefe;
      display: block;
    }
  </style>
`;

class AppCard extends HTMLElement {
  constructor() {
    super();

    this.attachShadow({ mode: 'open' });
    this.shadowRoot.appendChild(template.content.cloneNode(true));
  }
}

window.customElements.define('app-card', AppCard);

Nodes inside of this custom element won't actually get rendered at all. To allow this, just as you would use the children prop, you can place a <slot> in the shadow root.

template.innerHTML = `
  <style>
    :host {
      background-color: #fefefe;
      display: block;
    }
  </style>

  <slot></slot>
`;

But <slot> tags are not just content placeholders, they can be named to get a better control of the place where the children nodes will be.

If you want to know more about this I recommend checking Eric Bidelman's explanation about named slots in shadow DOM.

From props to attributes, from reactive data to getters and setters

Another mind shifting concept of component based apps is data flow, pouring down properties to give shape and behavior to the interface.

This is possible in web components through attributes.

In previous examples I showed how we got the title attribute to populate text content in a component. But what happens if that attributes changes after the element was mounted?

To cover this situation custom elements support observed attributes and a callback to run specific operations.

class MovieBox extends HTMLElement {
  constructor() { ... }

  static get observedAttributes() {
    return [ 'title' ];
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'title') {
      this.refs.title.textContent = newValue;
    }
  }
}

attributeChangedCallback runs only when an attribute returned by the observedAttributes getter changes.

The name of the attribute, its old value and the new one are provided.

Reactivity and state updates

We can also use getters and setters to synchronize attributes with properties, compute the data or do more complex mutations.

get title() {
  return this.getAttribute('title');
}

set title(value) {
  this.setAttribute('title', value);
}

In general, attributes are preferred only for initial state.

Later we can handle updates through properties and setters to keep template consistency, do logic with new data or apply DOM modifications.

Attribute based styling

For styles based on boolean attributes, we can use the :host([attribute]) notation to place specific styles.

<style>
  :host {
    background-color: #fefefe;
    display: block;
  }

  :host([hollow]) {
    background-color: transparent;
  }
</style>

Data reflection and observation

If attributes are worth being listened to or being reflected to properties is something that will depend on the nature of it.

For attributes that help build urls for XHR calls I wouldn't, because if somehow we accidentally modify them we will trigger unnecessary network calls.

For properties that change constantly it wouldn't be optimal to be synced with an attribute, like the current time of a video element.

Is data reactivy possible in web components? Yes, though it will involve heavy coding around properties, attributes and a lot of careful DOM manipulation when necessary.

Final thoughts

Several discussion occured around what web components mean today and whether they came here to replace current libraries.

To achieve pretty basic mutations on small components, children references and data manipulation there's a lot of heavy lifting that really compromises the developer experience.

And it's a big deal, since developer experience is one of the reasons why React or Vue are widely used in production.

Also, web components being included in a magical global store difficults declarative and deterministic views or any other concept which hangs on a visual map of dependencies like code splitting.

Unfortunately the developer experience of building an application with web components today is quite painful.
Sam Saccone

DOM manipulation and non-declarative operations have their cost.

Though components are meant to act as minimal functional pieces in our applications, at some higher level our codebase will need a component to gather all of these together and make them interact.

This could scalate to tons of event listeners and property mutations that will be hard to track down as an application grows.

The future

Web components do solve part of the current component situation in web development. Having a native way to achieve encapsulation and templating operating at a low level in the browser will be hard to beat on performance.

As libraries get better at working with custom elements, the interoperatibility will be a big win for the platform, a company could build one component that teams using Angular or Vue could make use of.

JavaScript frameworks as we know them are never going to deliver interop.
Alex Russell

In the same way I've built a patch to use refs in custom elements, other developers will find common and solid solutions for necessities not natively covered, and as that happens frameworks will have to do less.

I'm really excited about what things web components will allow in the platform.

These thoughs came from building an entire React app in web components, which is open sourced and you can check out here: //github.com/jeremenichelli/movies-web-components

Other resources

Thanks to Tim van der Lippeā€ for useful feedback on reactive data section.