DEV Community

loading...
Cover image for Learn basic Web Components

Learn basic Web Components

98lenvi profile image Lenvin Gonsalves ใƒป6 min read

Even though web components have been losing steam in recent days, they have a lot of advantages. One of them is writing framework-agnostic components, which is a boon provided how frequently frameworks lose their popularity in js land.

Many organizations have projects with front-ends using different frameworks, and by writing basic components like buttons, modals as web components, we can greatly increase code re-usability. Web components are not here to replace frameworks like React, Vue, and Angular, but are to be used along with frameworks

Using web components also allows encapsulating the styling to the component (Using the shadow DOM), which helps a lot in larger projects where we need to be careful about styles overriding (by duplicate class names). This functionality is provided by libraries like styled-components, but it's nice to see this natively supported.

In this tutorial, we will be creating two components, a user card and a modal. Using the Rick & Morty API, the web page will load the data and then insert the web component into the DOM. The same will be repeated as the user scrolls down.

Creating the User Cards

The Card will display two details about the character, its image & name, along with a button using which we will open the modal.

To create the web component, we will need to first create a template in markup.

<template>
    <style>
        /** Styles to be added **/
    </style>
    <!-- Mark up describing the component will go here -->
</template>
Enter fullscreen mode Exit fullscreen mode

After the template is defined, we will now need to create a class that extends from either HTMLElement or HTMLUListElement, HTMLParagraphElement, etc. If we use the former, the component will be an autonomous custom element, inheriting the minimum properties required. If we use the latter classes, the component will be a customized in-built element, inheriting additional properties.

A web component that inherits from HTMLUListElement would have left & top margin like most lists have.

<!-- Autonomous custom element -->
<user-card>
</user-card>

<!-- customized in-built element -->
<div is='user-card'>
</div>
Enter fullscreen mode Exit fullscreen mode

It is important to note that the way of using the custom elements will depend on what class is the custom element inheriting from (refer to the code-block above). In this article, we would define the element to inherit from HTMLElement.

class UserCard extends HTMLElement {

  constructor() {
    super();
  }

}
Enter fullscreen mode Exit fullscreen mode

The above is the minimal amount of code required to declare a custom-element class, in order to make it available to the DOM, we need to define it in the CustomElementRegistry as follows.

window.customElements.define("user-card", UserCard);
Enter fullscreen mode Exit fullscreen mode

That's it, we can now start using <user-card>, but currently there is nothing defined in the class, we will start by first defining the template (which we discussed earlier). Then define the constructor to do the follows -

  • When the custom element is added to the DOM, create a shadow DOM, which would be a child of the custom component.
  • Attach the node created from the template into the shadow DOM.

Shadow DOM

/** Defining the template **/
const template = document.createElement("template");
template.innerHTML = `
  <link rel="stylesheet" href="userCard/styles.css">
  <div class="user-card">
    <img />
    <div class="container">
      <h3></h3>
      <div class="info">
      </div>
      <button id="open-modal">Show Info</button>
    </div>
  </div>
`;
Enter fullscreen mode Exit fullscreen mode

The markup defined above will help us create a card that looks like this -

Single Card

/** Defining the constructor **/
  constructor() {
    super();
    this.attachShadow({ mode: "open" });
    this.shadowRoot.appendChild(template.content.cloneNode(true));
  }
Enter fullscreen mode Exit fullscreen mode

In the constructor, we are using attachShadow to attach a shadow DOM to the current node, then to the shadow DOM, which is accessed using shadowRoot we will append a child, which is a clone of the template we defined earlier.

So far, the web component should be looking as follows

const template = document.createElement("template");
template.innerHTML = `
  <link rel="stylesheet" href="userCard/styles.css">
  <div class="user-card">
    <img />
    <div class="container">
      <h3></h3>
      <div class="info">
      </div>
      <button id="open-modal">Show Info</button>
    </div>
  </div>
`;

class UserCard extends HTMLElement {

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

}

window.customElements.define("user-card", UserCard);

Enter fullscreen mode Exit fullscreen mode

The next step would be to define the life cycle, which should sound familiar if you have some React knowledge. For brevity, we will be focusing only on two methods

  • connectedCallback()
  • attributeChangedCallback()

ConnectedCallback()

This method is called when the custom element gets mounted on the DOM, this is when we should be defining event listeners, networks calls to fetch data, intervals & timeouts.

To clean up the intervals, timeouts when the custom element is unmounted, we would have to use the disconnectedCallback().

attributeChangedCallback()

This method is called when any attribute to the custom element changes (or an attribute is assigned). The method is only called when the attributes defined in the getter observedAttributes() change their value.

For the user-card component, these methods will be implemented as follows -

  static get observedAttributes() {
/** Even though we have other attributes, only defining key here
 as to reduce the number of times attributeChangedCallback is called **/
    return ["key"];
  }
  connectedCallback() {
/** Attaching an event-listener to the button so that the 
openModal() methods gets invoked in click, openModal will be 
defined later **/
    this.shadowRoot
      .querySelector("#open-modal")
      .addEventListener("click", () => this.openModal());
  }

  attributeChangedCallback(name, oldValue, newValue) {
/** Updating the DOM whenever the key attribute is updated,
 helps in avoiding unwanted DOM updates **/
    if (name === "key") {
      this.shadowRoot.querySelector("h3").innerText = this.getAttribute("name");
      this.shadowRoot.querySelector("img").src = this.getAttribute("avatar");
    }
  }
Enter fullscreen mode Exit fullscreen mode

Creating the Modals

Creating the modal component is similar to creating the user-card component.

Modal

The code for the modal -

const modalTemplate = document.createElement('template');
modalTemplate.innerHTML = `
  <link rel="stylesheet" href="modal/styles.css">
  <div class="modal">
  <div class='modal-content'>
  <button id='close' class='close'>Close</button>
  <img></img>
  <h3></h3>
  <p></p>
  </div>
  </div>
`;

class Modal extends HTMLElement {

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

  constructor() {
    super();
    this.showInfo = false;
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.appendChild(modalTemplate.content.cloneNode(true));
  }

  connectedCallback() {
    this.shadowRoot.querySelector('#close').addEventListener('click', () => {this.remove()});
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if(name==='key'){
      this.shadowRoot.querySelector('h3').innerText = this.getAttribute('name');
      this.shadowRoot.querySelector('img').src = this.getAttribute('avatar');
      this.shadowRoot.querySelector('p').innerHTML = `
      Gender: ${this.getAttribute('gender')}
      <br/>
      Status: ${this.getAttribute('status')}
      <br/>
      Species: ${this.getAttribute('species')}
      `}
  }

}

window.customElements.define('user-modal', Modal);
Enter fullscreen mode Exit fullscreen mode

To invoke the modal, we will need to define openModel in the user-card component. openModal will create the user-modal node and assign all the attributes the user-card had received to the modal, then attach it to the DOM.

  openModal() {
    const userModal = document.createElement("user-modal");
    userModal.setAttribute("name", this.getAttribute("name"));
    userModal.setAttribute("avatar", this.getAttribute("avatar"));
    userModal.setAttribute("status", this.getAttribute("status"));
    userModal.setAttribute("species", this.getAttribute("species"));
    userModal.setAttribute("gender", this.getAttribute("gender"));
    userModal.setAttribute("key", this.getAttribute("key"));
    document
      .getElementsByTagName("body")[0]
      .insertAdjacentElement("afterend", userModal);
  }
Enter fullscreen mode Exit fullscreen mode

Joining all the parts together

The components have been placed in the following folder structure

Alt Text

In the index.html both the components are imported and a script to fetch the characters data from the Rick and Morty API is defined.

Once the data is fetched, for every character, a user-card node is created, attributes are assigned, and then inserted into the DOM as follows -

await fetch(`https://rickandmortyapi.com/api/character?page=${page}`)
        .then((_) => _.json())
        .then((_) => {
          _.results.forEach((user, index) => {
            max = _.info.pages;
            const nodeToBeInserted = document.createElement("user-card");
            nodeToBeInserted.setAttribute("name", user.name);
            nodeToBeInserted.setAttribute("avatar", user.image);
            nodeToBeInserted.setAttribute("status", user.status);
            nodeToBeInserted.setAttribute("species", user.species);
            nodeToBeInserted.setAttribute("gender", user.gender);
            nodeToBeInserted.setAttribute("key", user.id);
            document
              .getElementById("details")
              .insertAdjacentElement("beforeend", nodeToBeInserted);
          });
        });
      page++;
    };
Enter fullscreen mode Exit fullscreen mode

An event listener to fetch more data when the user reaches the end of the page.

  window.addEventListener(
      "scroll",
      () => {
        const {
          scrollTop,
          scrollHeight,
          clientHeight
        } = document.documentElement;
        if (scrollTop + clientHeight >= scrollHeight - 5 && max >= page) {
          loadData();
        }
      },{ passive: true });
Enter fullscreen mode Exit fullscreen mode

This is it! The end result is in the code sandbox below

Conculsion

I hope this article gave you a good glimpse of web components.

If you are interested in knowing more, please check out Web Components at MDN

Edit - As the pointed comment below, creating the Web Component can be made simpler -

One thing nearly everyone does is due to incorrect documentation:

constructor() {
    super();
    this.attachShadow({ mode: "open" });
    this.shadowRoot.appendChild(template.content.cloneNode(true));
  }
Enter fullscreen mode Exit fullscreen mode
Enter fullscreen mode Exit fullscreen mode

Enter fullscreen mode Exit fullscreen mode

can be written as:

constructor() {
    super() // sets and returns 'this'
      .attachShadow({ mode: "open" })  //sets and return this.shadowRoot
      .append(template.content.cloneNode(true));
  }
Enter fullscreen mode Exit fullscreen mode
Enter fullscreen mode Exit fullscreen mode

Enter fullscreen mode Exit fullscreen mode

Note the use of append and appendChild. In most examples appendChilds return value is never used. append can added multiple text-nodes or elements.

And the global template.innerHTML isn't necessary either:

constructor() {
    super()
      .attachShadow({ mode: "open" })
      .innerHTML = ` ... any HTML here... `;
  }
Enter fullscreen mode Exit fullscreen mode
Enter fullscreen mode Exit fullscreen mode

Enter fullscreen mode Exit fullscreen mode

Docs also say you "use super() first in constructor"

That is incorrect also.

You can use any JavaScript before super(); you just can not use this until it is created by the super() call

</div>
Enter fullscreen mode Exit fullscreen mode

Discussion (14)

pic
Editor guide
Collapse
dannyengelman profile image
Danny Engelman • Edited

One thing nearly everyone does is due to incorrect documentation:

constructor() {
    super();
    this.attachShadow({ mode: "open" });
    this.shadowRoot.appendChild(template.content.cloneNode(true));
  }
Enter fullscreen mode Exit fullscreen mode

can be written as:

constructor() {
    super() // sets and returns 'this'
      .attachShadow({ mode: "open" })  //sets and return this.shadowRoot
      .append(template.content.cloneNode(true));
  }
Enter fullscreen mode Exit fullscreen mode

Note the use of append and appendChild. In most examples appendChilds return value is never used. append can added multiple text-nodes or elements.

And the global template.innerHTML isn't necessary either:

constructor() {
    super()
      .attachShadow({ mode: "open" })
      .innerHTML = ` ... any HTML here... `;
  }
Enter fullscreen mode Exit fullscreen mode

Docs also say you "use super() first in constructor"

That is incorrect also.

You can use any JavaScript before super(); you just can not use this until it is created by the super() call

Collapse
98lenvi profile image
Lenvin Gonsalves Author

Thanks for letting us know about this. The code looks much cleaner, and yes! not creating a template element would be much better as the global namespace wouldn't be polluted. I'll add this comment to the article ๐Ÿ˜€

Collapse
bennypowers profile image
Benny Powers ๐Ÿ‡ฎ๐Ÿ‡ฑ๐Ÿ‡จ๐Ÿ‡ฆ

Shouldn't be innerHTMLing in constructor. Better to use the template

Collapse
dannyengelman profile image
Danny Engelman • Edited

If you don't take my word; then maybe you take this guy his comment on innerHTML

I don't know the guy personally .. but Justin Fagnani sounds familiar ๐Ÿ˜›

Collapse
dannyengelman profile image
Danny Engelman

Can you motivate that statement, Benny

innerHTML and append(Child) all do the same: they add content to a DOM

That .createElement"template") takes extra CPU cycles; content is parsed once.
With .innerHTML= content is also parsed once.

So there is only benefit if code does template.cloneNode(true) multiple times

Thread Thread
bennypowers profile image
Benny Powers ๐Ÿ‡ฎ๐Ÿ‡ฑ๐Ÿ‡จ๐Ÿ‡ฆ

if you know you're only going to use the element once, yeah there's no difference.

But if you're going to construct the element several times, then you're going to invoke the parser on each instance.

If the element's shadow DOM is extensive, that could add up fast.

Moreover, keeping the template static means it's parsed up front.

Collapse
webpreneur profile image
Zsolt Gulyas

Truly said. Altough I wouldn't say just because of this, that the docs are "incorrect". Maybe the first one is more readable for newcomers. Anyway, super useful comment though. Thanks.

Collapse
dannyengelman profile image
Danny Engelman • Edited

See what code newcomers have to dig through (unless they don't RTFM) in the attachShadow MDN documentation: developer.mozilla.org/en-US/docs/W...

If you understand "// Always call super first in constructor" is incorrect
you can write that 33 lines constructor (most likely) without any comments:

constructor() {

  let span = document.createElement('span');

  function countWords() {
    let node = this.parentNode;
    let text = node.innerText || node.textContent;
    let count = text.trim().split(/\s+/g).length;
    span.innerText = 'Words: ' + count;
  }

  super()
   .attachShadow({mode: 'open'})
   .append( span );

  countWords();
  setInterval(()=> countWords() , 200);
}
Enter fullscreen mode Exit fullscreen mode

If you want to score W3C points; feel free to update that MDN article.

I did some MDN updates; then concluded there is way more effect adding comments to Web Component blogposts.
And I recently published my first Dev.to post explaining Web Components are about semantic HTML (IMHO)

And ... there is more wrong in that MDN Count Words example..

  • this.parentNode DOM access should be done in the connectedCallback, as the DOM might not even exist when the constructor runs

  • <p is="word-count"></p> Will never work in Safari, because Apple only implemented Autonomous Elements and (for the past 5 years) has strong arguments to not implement Customized Built-In Elements

Collapse
darkwiiplayer profile image
DarkWiiPlayer

These APIs are all very low-level and using them directly isn't all that practical. I usually prefer creating some simple wrappers around them, small and simple enough that I can just copy-paste them into any random project without having to worry about updating them.

For the HTMLElement class I've created this "improved" version that takes care of as much of the typical preamble without sacrificing too much flexibility or building a whole new framework.

Collapse
dannyengelman profile image
Danny Engelman • Edited

Low-Level ??

They are part of the JavaScript engine, no lower than an Array or any other API

A low-level programming language is a programming language that provides little or no abstraction from a computer's instruction set architectureโ€”commands or functions in the language map that are structurally similar to processor's instructions. Generally, this refers to either machine code or assembly language.

You can "improve" your code:

content.forEach( element => this.appendChild(element) );
Enter fullscreen mode Exit fullscreen mode

to

this.append(...content);
Enter fullscreen mode Exit fullscreen mode

MDN append documentation

Collapse
webpreneur profile image
Zsolt Gulyas

You should add webcomponents tag to your article too.

Collapse
98lenvi profile image
Lenvin Gonsalves Author

Thanks! will be adding it right away.

Collapse
caleb15 profile image
Caleb Collins-Parks

web components are losing steam? :O

Collapse
dannyengelman profile image
Danny Engelman • Edited

Yes, they do.

They don't run on coal like Frameworks, they are part of JavaScripts' core technologies, thus run on modern power. No steam required any longer.