Building a Practical Web Component
Let's dive into building a practical web component and learn the basics of how web components work.
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 React components without third-party scripts or dependencies.
In this article, I want to further step up the game by removing React from the table. Yes, you heard me right, no React! So take my hand, relax, and venture out into a framework-less world without any dependencies, build pipelines or NPM scripts. Are you ready?
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 a 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 data binding, state updates, or other complex concepts.
The downside of this is that web development is getting 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 must 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 has grown enormously. Today, we can get so much further with plain JavaScript than five years ago, and things keep improving. In this tutorial, we will 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, such as Shadow DOM, Custom Elements, or HTML elements. They are independent of each other but work together smoothly, giving developers a pleasant 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 excellent 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 three 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 will 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 our index.html file. This is going to be pretty straightforward. However, 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></script>
</body>
</html>
So far, so good. Nothing will appear if we open it in a browser besides the title. Note the link to styles.css
in the head and badge-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 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 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, 0.1);
border-radius: 0.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 will use the Shadow DOM, which works as an encapsulation for the component markup. Therefore, the CSS in styles.css
won't get through to our component's markup, leaving it without any styling.
"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 as easily as the Light DOM 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 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 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);
It looks a little like React, doesn't it? Web Components are classes that extend HTMLElement
, and 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>";
}
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 reaccess the Shadow DOM, so we save a reference to this._shadow
(you can name the variable differently if you prefer). The mode is set to open, allowing JavaScript to access the elements inside. In most cases, you want it to be open.
You'll see a DOM property that you should be familiar with on the following line: 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 actual 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 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. 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 more manageable, as you don't have to switch between different files to make changes or understand their logic. Everything related to this component is in the same place.
- Clean HTML pages: this might not apply if you have just 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.
Lifecycle 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 four new variables (or rather properties):
_items
will store our email addresses. It's initialized as an empty array._input
is a reference to theinput
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 theul
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. 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).
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 made available to us by the framework.
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. But, 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, 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}">×</button>
</li>
`;
}).join("");
}
React is data-driven, meaning that UI is rendered based on state and props. In JSX, that's made simple by allowing us to use conditions, loops, and so on to control what elements should be visible in what state.
Using Web Components, we, unfortunately, don't have such luxury as with JSX, although we try to achieve the same thing. So 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");
// ...
};
This method will hide our error element and remove the associated class from the input on each keypress. And that's working just fine!
Okay, now take a deep breath, relax, and see 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? Here, we defined a delete button:
<button type="button" data-value="${item}">×</button>
Thanks to that, data-value
will always be the email address, 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??"
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 li
s with the email text and a button per item. We 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.
- We first prevent the default browser behavior on line 2, which would be the copied text being inside the input.
- We get the clipboard contents on line 4 as a string.
- Using a regular expression and
match()
, we extract all valid emails from the string into the array emails. - 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. - We then merge our
this._items
with the new items on line 10. - The last step is to re-render the list again to make our newly pasted emails 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:
The goal here was to show you how easy it can be to build a practical Web Component from scratch. But, of course, we haven't covered everything there is; many more topics lie ahead, eager to be discovered:
- Properties and attributes to pass data into a Web Component.
- Real data binding, using a library or the JavaScript Proxy object.
- Using Constructable Stylesheets to easily apply global CSS without having to fight the border between Light and Shadow DOM.
- Interaction between components, like in React. Sharing state throughout the app.
I hope you enjoyed this tutorial. Feel free to tell me your suggestions or feedback. Happy coding!