Engineering

A practical introduction to web components

How to use web components real-life applications

In this article, I provide a basic understanding of what web components are and how to use them. Using real-life examples, I’ll show how web components can help make applications more predictable and easier to maintain. In addition, I share tips on how to repurpose some code writing by isolating HTML/CSS/JS code into…wait for it…isolated components for reuse. I also cover how to compose web components in the application layouts multiple times and how to carry the same behavior throughout the application.

Ok, let’s skip all the theory and just jump into a couple of code examples.

Example 1 - Simple native HTML form of two fields


<form>
    <div class="form-field">
        <label for="name">Enter your name: </label>
        <input type="text" name="name" id="name" required> <span class="error">This field is required!</span> 
    </div>
    <div class="form-field">
        <label for="email">Enter your email: </label>
        <input type="email" name="email" id="email" required> <span class="error">This field is required!</span> 
    </div>
    <div class="form-field">
        <input type="submit" value="Subscribe!"> 
    </div>
</form>

While the example above isn’t very exciting, this perfectly illustrates my point about predictable, easy to maintain code. Trust me. 😉 We already can predict that if more fields are added, the code becomes difficult to maintain with a big pile of field, labels, etc. No fun!

Imagine this…someone asks to augment this form with extra stuff near each field. They ask for information text. Oh, boy.

Instead, a good place to start is by using web components. They allow us to extend only one component which extends throughout all the coding. Sweet!

It’s worth noting that web components are supported by default in Firefox (version 63), Chrome, and Opera. Safari supports a number of web component features but offers less than the above browsers. Edge’s support is in progress.

Example 2: The same form of two fields, but implemented using web components.


<form>
    <db-form-field type="text" label="Enter your name" required />
    <db-form-field type="email" label="Enter your email" required />
    <db-form-submit value="Subscribe!" />
</form>

Looks nice, right? 🙂 This code is readable and reusable. It’s great because the code is now easier to maintain which also makes future changes faster to update and with fewer bugs.

A step-by-step guide to implementing form field components

In this section, I show partial code examples, describe in detail how to create reusable form fields, and note how to use it in the layout. (To see the finished product, go at the end of the article. 😉)

Keep in mind that web components consist of HTML templates and custom elements which use shadow DOM. These features help code become more maintainable and predictable. The logic is implemented in one place. It’s then reused and applied to the same component throughout the program.

Step 1: Create an initial class of the web component where all the logic is added.


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

Introduce a component template. A template is part of the component which appears in shadow DOM. It can be defined in HTML which uses the component or in the same .js file. In most cases, I prefer using .js files because it has all related component information in one place.


const template = document.createElement('template');
template.innerHTML = "It works!";

class FormField extends HTMLElement {
	...

Step 2: Use the template below and use shadow DOM.

NOTE: This is designed to encapsulate elements logic, but can be accessed via `shadowRoot` selector.

Define the shadow root property which is like the shell for all the web component markup.


...

class FormField extends HTMLElement {
    constructor() {
        super();
        this._shadowRoot = this.attachShadow({
            'mode': 'open'
        });
        this._shadowRoot.appendChild(template.content.cloneNode(true));
    }
}

Step 3: Define the custom element.

This is a necessary step for so that the browser knows how to react to the custom tag name used in HTML layout. After this, the component is ready to be used in HTML and have a custom tag name. See how the name custom element makes sense?!


...

class FormField extends HTMLElement {
    ...
}

window.customElements.define('db-form-field', FormField);

If you opt to use <db-form-field>in HTML layout, you need to include the component file via script tag like this:


<!DOCTYPE html>
<html lang="en">
    <head>
        <title>Form</title>
    </head>
    <body>
        <db-form-field></db-form-field>
        <script src="dbFormField.js"></script>
    </body>
</html>

Then, open index.html in the browser. (For this example, I used Chrome.) You should see something like this:

Web browser window displaying rendered result and opened developer tools displaying the code.

Since the template uses shadow root, you can see that the component was successfully created and used in the layout. You can also update the template to hold the label, input, and note error message.


...

template.innerHTML = `
<div class="form-field">
    <label>Enter name:</label>
    <input type="text" /> 
 	<span class="error">This field is required!</span>
</div>`;

...

Web browser window displaying rendered result and opened developer tools displaying the code.

You’re not done yet! The label text, input field type, and error message have static values that need to be configured. Check the attributes provided on the custom element by declaring an observed attribute method in the class. It should return an array of desired attributes names for which ones you want to track changes:


static get observedAttributes() {
    return ["label", "type", "error-message"];
}

If any of these attribute values are changed, it triggers an attributeChangedCallback method. Assign component elements to these variables in the constructor and set attribute values in the callback method.


constructor() {
    ...

    this.$label = this.shadowRoot.querySelector("label");this.$input = this.shadowRoot.querySelector("input");this.$error = this.shadowRoot.querySelector(".error");
}
static get observedAttributes() {
    return ["label", "type", "error-message"];
}
attributeChangedCallback(name, oldValue, newValue) {
    switch (name) {
        case "label":
            this.$label.innerText = newValue;
            break;
        case "type":
            this.$input.type = newValue;
            break;
        case "error-message":
            this.$error.innerText = newValue;
            break;
        default:
            break;
    }
}

Pass attributes to the db-form-field in index.html.


<db-form-field error-message="This field is required ❗" label="Custom Field" type="text"></db-form-field>

The layout should look something like this:

Web browser window displaying rendered result and opened developer tools displaying the code.

Step 4: Add some styles to the component and hide error messages by default.

There are a couple of ways to add styles to the custom element. A straightforward option is using a style tag shown here:


template.innerHTML=`<style>:host {
    margin-bottom: 10px;
    display: block;
}

.form-field {
    display: table;
}

label,
input {
    display: table-cell;
}

label {
    padding-right: 10px;
}

.error {
    display: block;
}

.hidden {
    display: none;
}

</style>

...

After seeing this, follow along to add one more field to the layout. You should see the below as a result.

Web browser window displaying rendered result and opened developer tools displaying the code.

Be sure to include a .hidden class for an error message on field initialization along with one more attribute—invalid—to watch list. As a result, the components are able to switch from a valid to an invalid state and vice versa.


...  

<span class="error hidden"></span></div>`;

class FormField extends HTMLElement {    
...    

static get observedAttributes() {        
    return ["label", "type", "error-message", "invalid"];    
}

attributeChangedCallback(name, oldValue, newValue) {        
    switch (name) {
       ...           
       case "invalid":                
          newValue !== null ? this.$error.classList.remove("hidden") : this.$error.classList.add("hidden");                
          break;           
...

In the illustration below, I refactored an invalid case and extracted into a separate method. (You’ll notice the sample code changed. I extracted the logic to another method to make it more readable.) This shows the implementation of invalid state switching.


...

case "invalid": this._handleInvalidState(newValue);
break;..._handleInvalidState(value) {
    if (value !== null) {
        this.$error.classList.remove("hidden");
        this.$input.classList.add("invalid-field");
    } else {
        this.$error.classList.add("hidden");
        this.$input.classList.remove("invalid-field");
    }
}

Be sure to set the field’s invalid state programmatically and note the property as invalid. However, in this case, the attributeChangedCallback would not be called because the component property changed instead of attribute. You need to use the component class to change the setting attribute and to reflect properties to attributes by creating a getter and setter.


get invalid() {
    return this.hasAttribute("invalid");
}
set invalid(value) {
    if (!!value) {
        this.setAttribute("invalid", "");
    } else {
        this.removeAttribute("invalid");
    }
}

Once the property is set programmatically, the attributes are also updated and callback is fired.
In the image below, I set the property on the element via dev tools console and component reacts to it.

Web browser window displaying rendered result and opened developer tools displaying the code.

Web browser window displaying rendered result and opened developer tools displaying the code.

Step 5: Include a required field validation into the component.

This feature requires programmers to both introduce a required attribute and a blur event listener for the input field. This way, you know when to check and validate the field value. To add an event listener, use the connectedCallback method of the component life-cycle. This action connects the component to shadow DOM. To be sure, check the input field isConnected property.


connectedCallback() {
    if (this.$input.isConnected) {
        this.$input.addEventListener("blur", (event) => {
            if (!event.target.value && this.hasAttribute("required")) {
                this.invalid = true;
                this.$error.innerText = "This field is required."
            } else {
                this.invalid = false;
                this.value = event.target.value;
            }
        });
    }
}

The value property also was made in the same manner as the invalid property and should now be reflected in the attribute. When adding the value to the input field, it also carries over to the db-form-field value. This action allows programmers to get the value of input field which is in shadow DOM without digging into shadow DOM and to extract the values from all the form fields on the submission without querying shadow Dom and digging into the component’s structure. The code only gets the value from the db-form-field.

For more flexibility, you can slot content to the component by using a slot element. It creates an empty space, where nested content between component tags appears. See an example of adding info text for the field here.


template.innerHTML=`<style>

...

::sloted(span) {
    color: grey;
    font-style: italic;
    padding-left: 10px;
}

</style>

...

Web browser window displaying rendered result and opened developer tools displaying the code.

In conclusion

See the whole code.

I recognize that there are still some parts that could be improved in this example. As a reminder, the purpose of this article is to try multiple techniques when creating web components: adding templates, using components in layout, using shadow Dom to isolate components from outside environments, detecting attribute changes, reflecting properties to attributes, and bubble values from child elements to root component. Using these approaches can improve the component creation featured in this blog or your web components.

Additional recommendations and resources:

Check out this article to dig deeper into web components.

Consider researching polyfills for additional browser support information.

NOTE: This article does not include this detail as the intent was to introduce to native web components and how to create them. Of course, it is reasonable now to use one of the frameworks/libraries like Polymer or SkateJs and have IE11+ support. It’s possible in the near future they won’t be needed because all modern browsers are working on fully support web components.