Building a practical Web Component

Andreas Remdt
ITNEXT
Published in
16 min readMay 16, 2019

--

Are Web Components the future’s building blocks of the web? Photo by Iker Urteaga on Unsplash

Recently, I published a tutorial on how to create email chips in pure React. My goal was to show you how easy it can be to build such components in React without using any third-party scripts or dependencies.

In this article, I want to step up the game even further by removing React from the table. Yes, you heard me right, no React!
Take my hand, relax, and venture out into a framework-less world without any dependencies, build pipelines or NPM scripts. Are you ready?

The finished component. Go ahead and try it out.

Why would you do this in the first place?

Many developers tend to get nervous when you mention vanilla JavaScript. In 2019, building a big web application without frameworks or libraries seems almost impossible, or at least tedious. And honestly, React, Vue.js and all these tools exist for good reason: they make developing data-driven UIs so much easier. Once you understand how a framework or library works, you can build amazing apps without worrying about things like data binding, state or other complex concepts.

The downside of this is that web development is getting more and more complicated (one might say bloated). Before you can even think about writing a line of code in React, you need to have a build tool like Webpack or Parcel ready. And don’t forget Babel to transpile your code for older browsers. Thanks to the community, things like create-react-app exist and make the project scaffolding a breeze. Still, you have to install hundreds of dependencies first, and they don’t always get along nicely.

However, I have noticed a change in recent years. Many new native ECMAScript features and APIs have seen the light of the day and the web platform grew enormously. Today, we can get so much further with plain JavaScript than 5 years ago, and things keep improving. In this tutorial, we are going to use one of these new features: Web Components.

What are Web Components?

The idea behind Web Components is the same that made React or Vue.js so famous: reusable components for your entire app.

“Web Components is a suite of different technologies allowing you to create reusable custom elements — with their functionality encapsulated away from the rest of your code — and utilize them in your web apps.”
- MDN

Web Components is a collection of APIs and technologies, such as Shadow DOM, Custom Elements or HTML elements. They are independent of each other but work together smoothly, giving developers a nice experience.

This tutorial is not meant as an introduction to Web Components — its purpose is to give an example of how you could implement a practical component from scratch. If you’re new to the entire thing, there’s an awesome series about Web Components on CSS-Tricks. I’d recommend reading it first if you’re still confused about this whole thing.

Scaffolding the project

Let’s get our hands dirty and create the project’s foundation. Brace yourselves: we just need 3 files!

  • index.html
  • badge-input.js
  • styles.css

Go ahead and create these files in an empty folder, you can call it chips or whatever you like. That’s it. No create-react-app, no npm install, nothing. When’s the last time you had it that easy?

Note: we are going to use JavaScript features that are not available in older browsers without transpilation. If you want to follow along, make sure to use an up-to-date browser like Chrome or Firefox.

The HTML markup

The next step is to create the HTML markup inside of our index.html file. This is going to be pretty straight forward. We need the basic skeleton first:

<!DOCTYPE html><html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Email Chips</title> <link rel="stylesheet" href="styles.css">
</head>
<body>
<script src="badge-input.js" defer>
</body>
</html>

So far, so good. If we open it in a browser, nothing will appear, besides the title. Note the link to styles.css in the head andbadge-input.js in the body. Both files are currently empty. The script tag has the attribute defer, which tells the browser that this file can be loaded asynchronously, hence it improves loading performance (although it’s hardly noticeable in this small project). Still a best practice.

Put the following code snippet inside the HTML’s body, right before the script tag:

<div class="wrapper">
<badge-input></badge-input>
</div>

That’s actually it. Go ahead and reload the browser — it’s still going to be empty. That’s because we are “using” our Web Component badge-input, but it still is not defined anywhere, so the browser just sees the tag and ignores it. The div is for styling purposes only.

Basic styling

The CSS will be the same as it was in the previous tutorial, with a slight difference. This time, we don’t put all of our CSS rules into styles.css, only these three:

@import url("https://fonts.googleapis.com/css?family=Open+Sans");:root {
color: #565656;
font-family: "Open Sans", sans-serif;
font-size: 14px;
line-height: 1.7;
}
body {
background-color: #eaeaea;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
}
.wrapper {
background-color: white;
width: 400px;
padding: 2rem;
box-shadow: 0 1.5rem 1rem -1rem rgba(0, 0, 0, .1);
border-radius: .3rem;
}

In my previous React tutorial, all DOM elements (including the input and chips) were on the same level, the Light DOM. In this tutorial, however, we are going to use the Shadow DOM, which works as an encapsulation for the component markup. Therefore, the CSS in styles.css won’t be able to get through to our component’s markup, leaving it without any styling.

Light and shadow. Photo by Martino Pietropoli on Unsplash

“Woah, wait a second. What was that just about?”, you might ask yourself after reading this paragraph. Seems confusing, right?

Let me break it down a bit more: traditionally, we have something called the DOM, in which all your HTML elements live. There’s only one DOM, and if you wrote any CSS or JavaScript, you could access your elements (like paragraphs or headings) using document.getElementById() and so on.

A few years back, the DOM’s dodgy brother Shadow DOM was introduced to the web platform. It basically works the same way as our usual DOM (now referred to as Light DOM, talking about balance), but it can’t be accessed using JavaScript or CSS.

Say you have an element <p id="text"></p> inside of the Shadow DOM. If you try accessing it using document.getElementById("text"), it would not find anything, even our CSS wouldn’t be able to style it, as it’s basically hidden from us. Like the monster below the bed, we know it’s there, but we can’t see it.

“So far so good, but what the heck is this doing for me?” I see you asking. Well, encapsulation is the answer. Have you ever worked with the HTML 5 <video> or <audio> elements? If you throw the <video> tag in your markup and reload, you’ll see a video player with styled controls, but you didn’t add any of these, right? They also live in the Shadow DOM. You can’t style them, you can’t manipulate them, they are basically out of your reach, provided by the browser. The big advantage is that they don’t conflict with any styles or logic that you have set up in your app. And that’s what we want for our component, too.

The Web Component

Now, let’s get to the interesting part of the project, the component itself. Inside badge-input.js, we’ll start simple with an IIFE:

(function() {
"use strict";
// Code will be here...})();

The reason I am wrapping the entire thing in an IIFE is encapsulation. Besides the Web Component itself, I might want to define other functions or variables that should not pollute my global namespace. By wrapping everything inside an IIFE, I get my own namespace and don’t have to worry about side effects in other parts of the application.

Next, let’s create the foundation of the component:

class BadgeInput extends HTMLElement {
constructor() {
super();
}
}
customElements.define("badge-input", BadgeInput);

Looks a little like React, doesn’t it? Web Components are basically classes that extend HTMLElement, they can have properties and methods. Like in React, if you define a constructor (for example to initialize variables) you need to call the super() function to inherit logic and behavior from HTMLElement. This is what makes it behave like a Web Component in the end.

If you save and refresh your browser, you still won’t see anything. Not really surprising, because our component is empty. Let’s add some markup:

constructor() {
super();
this._shadow = this.attachShadow({ mode: "open" });
this._shadow.innerHTML = "<h1>Hello from the Shadows!</h1>";
}

In order to insert things into the Shadow DOM, we need to call this.attachShadow({ mode: "open" }). attachShadow is a method that we get out of the box, because our class BadgeInput extends HTMLElement.

Later, we will need to access the Shadow DOM again, so we save a reference to this._shadow (you can name the variable differently if you prefer). The mode is set to open, which allows JavaScript to access the elements inside. In most cases you want it to be open.

On the next line, you’ll see a DOM property that you should be familiar with: innerHTML. Like in the Light DOM, we can use appendChild, insertBefore, textContent and all the other DOM methods and properties to manipulate elements.

Having a look into the browser, we can finally see something happening, and if you inspect the source code you’ll notice that our h1 tag is inside the Shadow DOM:

Of course, we don’t want to print out a simple heading, so remove line 4 again. We’ll add our real markup as the next step.

Defining the markup using HTML templates

In my previous React tutorial, we defined our markup structure inside the render function of our component using JSX.
Since this is not React anymore, we need a different way to build up the HTML.

Right before we define the BadgeInput class (but still inside the IIFE), paste the following code:

var template = document.createElement("template");
template.innerHTML = `
<style>
/* paste the styles here */
</style>
<ul></ul>
<input type="email" placeholder="Type or paste email addresses and press 'Enter'...">
<p hidden></p>
`;

So, what we do here is to create a new template element. Then, we set the innerHTML to have the structure that we later need. It’s almost identical to the React example.

We define a style block on line 3 that is currently empty. In here, we need to paste the CSS rules that are missing in our styles.css. Please go ahead and paste them yourself from the finished product, as I left them out to not bloat up this article unnecessarily.

Okay, now that we have a template and with markup, we need to use it inside our component:

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

Voila, you should see the text field appearing in your browser.
We have used the content property of template (which is a document fragment) and the cloneNode method to append a copy of our template’s HTML structure into the component itself. cloneNode received true as a parameter to make a deep clone, meaning that all child nodes get cloned as well.

“But what is this template thingy?”, you might ask.

It’s used to store our markup which can be reused throughout our codebase. Contrary to the usual HTML elements, it’s not being rendered in the browser by default, which increases performance. Templates are meant to be used for exactly the purpose of cloning their contents and reusing them when and where needed.

Instead of creating the template programmatically in JavaScript, you could also add it to our index.html:

<template id="badges">
<style>
/* paste the styles here */
</style>
<ul></ul>
<input type="email" placeholder="Type or paste email addresses and press 'Enter'...">
<p hidden></p>
</template>

Now we can get a reference in our JavaScript using document.getElementById:

constructor() {
super();
var template = document.getElementById("badges");

this._shadow = this.attachShadow({ mode: "open" });
this._shadow.appendChild(template.content.cloneNode(true);
}

This might look cleaner to you, as the HTML isn’t defined inside of our JavaScript anymore. However, I prefer the first way for a few reasons:

  • Deleteability: by keeping our whole component (meaning HTML, CSS, and JavaScript) in one file, it can easily be moved or deleted without leaving any dead code behind.
  • Organization: like in React or Vue.js, having all code together makes development easier, as you don’t have to switch between different files to do changes or understand their logic. Everything related to this component is in the same place.
  • Clean HTML pages: this might not apply if you just have one component, but rather lots of them. If you put all the templates into, let’s say index.html, this file would get cluttered with templates. It might be hard to distinguish between code that’s actually rendered by the browser and code that’s used by components.

Templates are a part of the Web Component specification for a reason, as they make it easy to reuse code blocks in components.

Life cycle and event listeners

So, what’s the next step? Much like in React, we need to register our events, for example when a user presses the tab key to add a new email address. We also need an array that holds these email addresses. Let’s add a few properties to the instance of our component:

constructor() {
super();
this._shadow = this.attachShadow({ mode: "open" });
this._shadow.appendChild(template.content.cloneNode(true));
this._items = [];

this._input = this._shadow.querySelector("input");
this._error = this._shadow.querySelector("p");
this._list = this._shadow.querySelector("ul");
}

We are still inside the constructor. Below line 5, you can now see that we have 4 new variables (or rather properties):

  • _items will store our email addresses. It‘s initialized as an empty array.
  • _input is a reference to the input element, which we will need to add error classes for styling later on.
  • _error again is a reference to a DOM element. We will need this reference to toggle the error message itself.
  • Finally, _list is a reference to the ul element. We will update it each time an email address has been added or removed.

Did you notice the _ I put before the variable names? That’s not mandatory, but considered a pattern for “private” class properties. Of course, JavaScript doesn’t yet have private/public properties like in PHP or Java. Still, I wrote these variables with an underscore to mark them as private (although it doesn’t affect their behavior or namespace whatsoever). It’s rather an organizational thing.

Great, we have references to our HTML elements, but the event listeners are still missing. Let’s add them:

constructor() {...}connectedCallback() {
this._input.addEventListener("keydown", this.handleKeyDown);
this._input.addEventListener("paste", this.handlePaste);
this._list.addEventListener("click", this.handleDelete);
}
disconnectedCallback() {
this._input.removeEventListener("keydown", this.handleKeyDown);
this._input.removeEventListener("paste", this.handlePaste);
this._list.removeEventListener("click", this.handleDelete);
}

I have added two new methods to our component’s class. They are referred to as lifecycle methods and might sound familiar to you. The same concept can be seen in React: componentDidMount and componentWillUnmount are two example lifecycle methods that are made available to us by the framework.

Exactly like in React, connectedCallback gets called once the Web Component has been connected (or rather: mounted) to our website.

In index.html we “connect” the component by using <badge-input></badge-input>. So once the browser encounters this code, it will instantiate our class and call connectedCallback.

The same goes for disconnectedCallback, but this one gets called once a Web Component is removed from the DOM, for example through document.removeChild. Pretty neat, isn’t it?

Inside connectedCallback we register three event listeners. Unlike in React, we need to clean up our event listeners in disconnectedCallback to avoid memory leaks. Why?

Imagine having many components added and then removed from your page; if you didn’t remove the event listeners every time, they might live on and slow down the entire app by consuming memory. In React, this is taken care of for you, however, here we have to clean up after ourselves to keep the app performant and clean.

Handling input

Our event listeners are registered, but we don’t have any functions to actually do something, yet. Let’s add the first one, handleKeyDown:

constructor() {...}connectedCallback() {...}disconnectedCallback() {...}handleKeyDown = (evt) => {
if (TRIGGER_KEYS.includes(evt.key)) {
evt.preventDefault();
var value = evt.target.value.trim(); if (value && this.validate(value)) {
evt.target.value = "";
this._items.push(value);
this.update();
}
}
};

The first thing you’ll notice is the TRIGGER_KEYS, which we haven’t defined yet.

const TRIGGER_KEYS = ["Enter", "Tab", ","];

I put this code at the top of the IIFE, but you can choose any other location. However, I’d recommend putting it inside the IIFE, as it is part of our component and shouldn’t pollute the global namespace.

So, if the pressed key is either Enter, Tab or a comma, we get the input value on line 5 and perform validation on line 7. this.validate() doesn’t exist, yet, so let’s add it:

validate(email) {
var error = null;
if (this.isInList(email)) {
error = `${email} has already been added.`;
}
if (!this.isEmail(email)) {
error = `${email} is not a valid email address.`;
}
if (error) {
this._error.textContent = error;
this._error.removeAttribute("hidden");
this._input.classList.add("has-error");
return false;
}
return true;
}
isInList(email) {
return this._items.includes(email);
}
isEmail(email) {
return /[\w\d\.-]+@[\w\d\.-]+\.[\w\d\.-]+/.test(email);
}

If there’s an error, we will use our this._error reference to set the error message and visibility. Also, the input field (this._input) will get a class that adds a red border.

Back in handleKeyDown, once validation passes (it’s the same as in my previous React tutorial), we reset the input’s value to an empty string and add the email address to this._items. The last step is to re-render our component to display the updated list of emails on top of the input.

That’s what this.update() is for:

update() {
this._list.innerHTML = this._items
.map(function(item) {
return `
<li>
${item}
<button type="button" data-value="${item}">&times;</button>
</li>
`;
}).join("");
}

React is data-driven, meaning that UI is rendered based on state and props. In JSX, that’s made really simple by allowing us to use conditions, loops and so on to control what elements should be visible on what state.

Using Web Components, we, unfortunately, don’t have such luxury as with JSX, although we try to achieve the same thing. Instead, we use the more traditional approach of DOM manipulation, as you can see in the above method.

Each time this.update gets called, we override the entire HTML inside of our ul (which, if you remember, we defined in the template). Using Template literals, we can easily “inject” data into the HTML, finally appending it using innerHTML.

.join("") at the end is important, because .map() returns an array, and we can’t use innerHTML with arrays, only strings. .join("") takes all the items inside the array, joins them together (separated by nothing, hence "") and returns a string.

You can reload your browser and check it out, it should work fine. There is, however, a tiny bug: the error state doesn’t get reset, meaning that if you enter an invalid email, the error will show and stay forever.

Let’s fix this inside our handleKeyDown method:

handleKeyDown = (evt) => {
this._error.setAttribute("hidden", true);
this._input.classList.remove("has-error");
// ...
}

On each keypress will this method hide our error element and remove the associated class from the input. And that’s working just fine!

Things are looking good so far.

Okay, now take a deep breath, relax and look at how far you have come. Only two more features are missing: deleting and pasting emails.

Deleting emails

The logic for deleting email chips will be pretty similar to the React example:

handleDelete = (evt) => {
if (evt.target.tagName === "BUTTON") {
this._items = this._items.filter(
item => item !== evt.target.dataset.value
);
this.update();
}
};

It’s as simple as that. Since we have all of our emails stored in this._items, we can easily filter through it and remove deleted emails.
evt.target.dataset.value will contain the email that we want to get rid of.

Remember the update method I showed you 2 minutes earlier? In here, we defined a delete button:

<button type="button" data-value="${item}">&times;</button>

Thanks to that, data-value will always be the email address, therefore making it easy for us to use this value in handleDelete. evt.target is a reference to the button itself. But wait! “What about this weird if-condition on line 2?”, I hear you ask. “We also didn’t set any event listeners for the button, so how in the world does this work??”

If you wondered about this as well, give yourself a tab on the shoulder.

We use something called event delegation. Indeed, there’s no event listener on the button directly (and there never will), but instead on the ul. Remember the lifecycle callbacks, the connectedCallback to be more specific?

this._list.addEventListener("click", this.handleDelete);

Here’s the magic. Our ul receives the event listener, so we can click on everything inside it to trigger this event. Inside are lis with the email text and a button per item. Would could click on the button and it triggers the event handler. But we could also click on the text and it will trigger the very same handler, handleDelete.

Of course, we don’t want that. The deletion should be triggered when a user clicks the button only. That’s what this strange if-condition is for. It checks whether the clicked element actually is the button (event.target.tagName), and only then does it run the logic.

Pasting emails

We are finally on the home stretch, with just one feature missing: pasting email addresses. Here’s the code:

handlePaste = evt => {
evt.preventDefault();

var paste = evt.clipboardData.getData("text");
var emails = paste.match(/[\w\d\.-]+@[\w\d\.-]+\.[\w\d\.-]+/g);
if (emails) {
var toBeAdded = emails.filter(email => !this.isInList(email));
this._items = [...this._items, ...toBeAdded];
this.update();
}
};

This code is pretty much the same as in the React counterpart.

  1. We first prevent the default browser behavior on line 2, which would be the copied text being inside the input.
  2. We get the clipboard contents on line 4 as a string.
  3. Using a regular expression and match(), we extract all valid emails from the string into the array emails.
  4. If there are any emails inside the array, we run a filter function to only get the emails that are not yet in our this._items array.
  5. We then merge our this._items with the new items on line 10.
  6. The last step is to re-render the list again so that our newly pasted emails are visible.

Conclusion

Here we are, having just converted a React component into a Web Component without any build process or dependencies. Plain, old JavaScript, enriched by the latest APIs and standards. Here’s the final result:

Our final result looks and behaves exactly like the one we built in React.

The goal here was to show you how easy it can be to build a practical Web Component from scratch. Of course, we haven’t covered everything there is, many more topics lie ahead, eager to be discovered:

I hope you enjoyed this tutorial, feel free to tell me your suggestions or feedback. Happy coding!

--

--

Web Developer passionate about crafting beautiful, modern and accessible websites using as little libraries/frameworks as possible.