In this article I'm going to be exploring different ways of implementing reusable components in JS. I'll be highlighting some interesting comparison points between specific frameworks (like React) and a frequently overlooked alternative - web components, which uses the browser's native API.
The examples below explore some of the overall reasoning behind component-based frontend development in general - and should help you consider your own preferred approach for different situations and use-cases. Later in the article, I will provide a how-to for building web components - if you only want this, feel free to skip ahead! - but my hope is that you'll find the added deep-dive a useful way of building up towards web components, helping to contextualise our understanding of React and component frameworks in general, as it has been for me.
Background
Frameworks are not the only tool
An interim approach
A better approach using classes
Intro to web components
Getting into web components
Customising existing HTML elements
The Shadow DOM
Using slots to customise your component
Lifecycle methods
Web component lifecycle methods
Working with event listeners
Working with state updates
Wrapping up
A brief note on security
Summary
Frameworks are not the only tool
Before diving in, a little context.
Since adding React to my tech stack, I've since been keen wherever possible to refactor my existing 'vanilla' JS projects, in order to make use of the framework. This way I could leverage some of React's core features - such as allowing for state management, an interface which updates and re-renders dynamically with user interaction, or otherwise building a frontend UI with a more modular, component-based structure.
I have seen great first-hand benefits from doing so. But in certain specific cases, the temptation to 'reactify' everything can introduce more problems than it solves, or add unnecessary complexity. There's an appropriate tool for every type of task, which I hope to illustrate shortly.
For example, in my case I discovered that the virtual DOM doesn't always play nicely with other libraries (or, to shift any perceived culpability, they don't always play nicely with React). This came to a head for me when attempting to introduce React to an app built with both p5.js and the p5.sound extension library. I was able to make React work fine with just p5.js, running in 'instance mode' - great! - but running p5.sound alongside this in a React application proved unworkable (if anyone has successfully managed this - and I'm sure there is a way - I'd be keen to hear it! I am aware of options discussed with certain wrapper libraries, but no luck as of yet).
As developers, we know the urge to crack that solution, for the dopamine hit or for the benefit of the project itself - but sometimes that stubbornness (or grit or tenacity) works against us, especially when we become tied to a particular technology. It can be useful to take a step back and consider: are we painting ourselves into a corner? Are the perceived benefits worth the potentially lengthy troubleshooting exercise, the added complexity and technical overhead, or trial-and-error workarounds with other libraries?
I don't want to curtail that (dark?) problem-solving urge at all, but instead I want to redirect it - in this case, leaning away from the abstraction of a framework towards a focus on some of the fundamentals of web technology (and as we'll see, OOP), which is what I decided to do when faced with this particular problem. This is where web components come in.
The result is something that I found gave me more options and flexibility, and a better understanding of how to approach building a component-based UI.
An interim approach
Let's go back to the intended purpose for using React - how will it specifically benefit my application?
In my case above, I realised that the one feature I really wanted was a component-based UI architecture. React (other UI frameworks are available) handles this very well, but sometimes you don't actually need the complexity of a full-fledged framework to make this happen, or could benefit from not being tied to it as a dependency.
What if we tried to replicate some of that functionality with 'vanilla' JS? Here's one very simple approach - creating a reusable function, which effectively generates a component within your JS code (we'll get to a more elegant implementation later):
function createCard(container, title, content) {
const card = document.createElement("div");
card.classList.add("card");
const cardTitle = document.createElement("div");
cardTitle.classList.add("card-title");
cardTitle.textContent = title;
const cardContent = document.createElement("div");
cardContent.classList.add("card-content");
cardContent.textContent = content;
card.appendChild(cardTitle);
card.appendChild(cardContent);
// Append the card to the specified container in the DOM
container.appendChild(card);
}
const cardContainer = document.querySelector(".card-container");
createCard(cardContainer, "Card 1", "This is the content of card 1.");
createCard(cardContainer, "Card 2", "This is the content of card 2.");
This goes back to some of the fundamentals of JS DOM manipulation - using document.createElement()
to create items, and then element.appendChild()
to append them to a selected container.
It's quite simple, but still gives us some flexibility - you can add customisation as desired with props - and most crucially, it's reusable - you can break up your code into function 'modules' much in the way you would with JSX components.
This does of course mean that you are reliant on JS code to generate DOM elements, which isn't the most attractive or clear method for rendering a document. One way of improving the structure of your code might be to create container elements, which you then append items to:
const firstDiv = document.querySelector(".first-div");
createCard(firstDiv, "Card 1", "This is the content of card 1.");
createCard(firstDiv, "Card 2", "This is the content of card 2.");
const secondDiv = document.querySelector(".second-div");
createHeader(secondDiv, "This is a header");
createParagraph(secondDiv, "This is the content of a paragraph")
// etc.
While we now have more of a system, it's still a little verbose and unclear - you can't easily see how the page is structured in a familiar way as with HTML/JSX markup, especially if your function creates some nested elements. It's a bare-bones approach, and with this you sacrifice a lot of clarity and maintainability.
Another limitation is its lack of flexibility - you can't easily adapt the function or extend it to create parents or subcomponents, and may end up having to write duplicate code in these cases.
However, it definitely works. Here are the (very exciting) cards:
(I added some basic CSS styling here):
.card-container {
display: flex;
gap: 1rem;
margin: 1rem 0;
}
.card {
width: max-content;
border: 1px solid black;
border-radius: 1rem;
padding: 1rem;
flex: 1;
font-family: sans-serif;
box-shadow: 5px 5px rgb(0 0 0 / 0.2);
}
.card-title {
font-size: 1.5rem;
}
.card-content {
font-size: 1rem;
padding: 1rem 0;
}
A better approach using classes
How do we get closer to a framework-esque approach to building components, which is a little more flexible?
Here's an arguably more refined example borrowed from Object-oriented programming (OOP), using classes:
class Card {
constructor(container, title, content) {
this.container = container;
this.title = title;
this.content = content;
this.element = this.createCardElement();
}
createCardElement() {
const card = document.createElement("div");
card.classList.add("card");
const cardTitle = document.createElement("div");
cardTitle.classList.add("card-title");
cardTitle.textContent = this.title;
const cardContent = document.createElement("div");
cardContent.classList.add("card-content");
cardContent.textContent = this.content;
card.appendChild(cardTitle);
card.appendChild(cardContent);
return card;
}
render() {
this.container.appendChild(this.element);
}
}
// Example usage
const cardContainer = document.querySelector(".card-container");
const card1 = new Card(cardContainer, "Card 1", "This is the content of card 1.");
const card2 = new Card(cardContainer, "Card 2", "This is the content of card 2.");
card1.render();
card2.render();
If you're familiar with classes, some of the benefits of this over the previous approach should be fairly clear. Using a class allows us to not only customize instances of an object by passing in parameters (the equivalent of using React 'props'), but we are now able to extend that class, making it far for flexible and scalable.
You could extend this model using 2 common OOP approaches - inheritance (creating a similar component which shares some or all features) or composition (combining classes to create a larger/more complex class). This allows you to create a hierarchy of superclasses and subclasses, effectively generating a component hierarchy:
class AuthorCard extends Card {
constructor(container, title, content, author) {
super(container, title, content);
this.author = author;
this.addAuthor();
}
addAuthor() {
const authorElement = document.createElement("div");
authorElement.classList.add("card-author");
authorElement.textContent = `By ${this.author}`;
this.element.appendChild(authorElement);
}
render() {
// Override the render method to add special handling for Authorcard
this.container.appendChild(this.element);
}
}
// Example usage
const cardContainer = document.querySelector(".card-container");
const card1 = new Card(
cardContainer,
"Card 1",
"This is the content of card 1."
);
const authorCard1 = new AuthorCard(
cardContainer,
"Author Card 1",
"This is the content of author card 1.",
"John Doe"
);
card1.render();
authorCard1.render();
The results, with some added styling for a card author:
.card-author {
font-style: italic;
}
Getting into web components
Hopefully the exercise / detour above illustrates how we can leverage some fundamental concepts from JS (and OOP in general) to structure our site or web app in a way more akin to a framework. However, we're still rendering our components programmatically, rather than directly in our markup. This means we aren't separating concerns between our site's structure (HTML) and our component's behaviour (JS), which comes at the cost of some clarity and maintainability.
As we'll see in a moment though, this approach should also help us better understand a more established method in the form of web components, which can be injected in our markup. We'll see how these are actually composed from certain DOM elements (or classes) which form the building blocks of any web app.
Web components are built into the browser, and as they're built on web standards, they can be used in any frontend framework (or vanilla JS app, if preferred) - this is great if you're looking for a lot of flexibility in your tech stack.
Custom elements
Here's how you would create one:
- Create a JS file, e.g. Card.js
-
Include this as a
<script>
tag in the<head>
of your index.html. Use thedefer
keyword (so it loads after the HTML content has been parsed):
<script defer src="Card.js""></script>
-
Inside this file, we create a custom class from the base class 'HTMLElement', using the
class
andextends
keywords:
class Card extends HTMLElement { constructor() { super(); this.innerHTML = `<div class="card">${this.innerText}</div>`; } }
Much like our 'interim' example, we are extending a class - in this case HTMLElement. This is a base class from which our standardly recognised HTML elements/tags (<div>
, <p>
, <span>
, <button>
, <input>
, etc.) are all subclasses.
-
To create this as a custom HTML tag, we use the
customElements.define()
method, passing in as arguments the desired name of our tag, and the class name. The element name passed in must contain at least 1 hyphen. This is to make it clear that it's a custom element - standard elements never include this:
customElements.define("custom-card", Card)
-
Now in your index.html, you can render this element:
<body> <custom-card>Card contents</custom-card> </body>
The card would look like this (using our CSS styling of the 'card' class from earlier):
Customising existing HTML elements
Rather than extending from the overall parent class of HTMLElement, we can extend from specific built-in HTML elements, which are children of HTMLElement. This is useful if you want to make small modifications rather than writing a component from scratch.
Here's an example of a card component that extends
the HTMLDivElement.
To make this work, you also need to pass in an object to the customElements.define()
method, and reference the element you're extending:
class Card extends HTMLDivElement {
constructor() {
super();
this.innerHTML = `${this.getAttribute('name')}`;
this.style.color = "red";
}
}
customElements.define('custom-card', Card, {
extends: "div"
});
In the above example, I have used the getAttribute()
method (available on all HTML elements) to set the text within the div using the 'name' attribute - this is similar to setting a component's content conditionally in React, using props.
Finally, use the is
attribute on the HTML element itself:
<div is="custom-card" name="Jane Doe"></div>
Be wary - sometimes extending a specific HTML element can cause styling conflicts depending on your app/site's CSS - in the above example, any CSS styling on the 'div' element selector will apply to your custom component as well. We'll see below how to avoid this.
The Shadow DOM
We're not done yet! We have an issue with this approach so far, which is that the styling is not encapsulated. Generally the desired purpose of components is that we can style them independently, without the component's styles impacting or overriding the styling of other elements (or vice versa). Since the custom component uses the <div>
HTMLElement, if you applied an inline style rule like the below, ALL <div>
elements would be affected too, i.e. globally:
this.innerHTML = `<style>div { color: blue } </style><div>${this.getAttribute.name}</div>`;
This is why we use the shadow DOM to set custom styles - it encapsulates those styles and prevents them affecting (or being affected by) styling of other DOM elements. It also provides a scoped enclosure within the component for any JavaScript code - functions, variables and event handlers - so that these don't conflict with scripts outside the shadow DOM.
We can set this up on the class constructor, using this.attachShadow()
. You need to specify a mode - "open" means it can be modified using this.shadowRoot
, which makes it easier to change attributes (and view the shadow DOM in our browser dev tools). You almost always want this.
To render the component with encapsulated styles, you can use 1 of 2 different approaches, depending on your use-case. Personally I prefer the 2nd, but I've seen plenty of examples of the 1st, so I'll cover this too.
Method 1 - cloning a template
This involves using a template, which we define outside the class. We create a DOM element, add some styling to the innerHTML
, and then append a cloned version of this to the shadowRoot, using template.content.cloneNode(true)
:
const template = document.createElement('template');
template.innerHTML = `
<style>
div {
color: blue;
}
</style>
<div class-"custom-card"></div>
`
class Card extends HTMLDivElement {
constructor() {
super()
this.attachShadow( { mode: "open"} );
this.shadowRoot.appendChild(template.content.cloneNode(true))
this.shadowRoot.querySelector('div').innerText = this.getAttribute('name');
}
}
customElements.define("custom-card", Card, {
)
Method 2 - on component mount
This makes use of a lifecycle method (covered shortly) called connectedCallback()
, which we use within the class but outside the constructor. We use this to call this.render()
when an element is added to the page:
class Card extends HTMLDivElement {
constructor() {
super();
this.attachShadow( { mode: "open"} );
}
connectedCallback() {
this.render();
}
render() {
this.shadowRoot.innerHTML = `
<style>
div {
color: blue;
}
</style>
<div class="card">${this.getAttribute("name")}</div>
`
}
}
customElements.define("custom-card", Card, {
extends: "div",
});
Note that in both cases, even though I have added the class 'card' to the element, which is used in my CSS stylesheet in previous examples, the style encapsulation means the only styles applied will be those inside the component, i.e. setting the colour to blue:
(we'll style this again shortly)
Which rendering method you use is partly a matter of preference, but I personally prefer the 2nd method because:
- It gives you a little more control over the component lifecycle (especially useful for dynamic elements)
- It tends to be more performant (without needing to clone elements)
- It separates the rendering logic from the constructor, which looks cleaner to me. This way you can easily add conditional props 'inline' within the rendering markup - which is again a style familiar to users of React - using
this.getAttribute()
to fetch the relevant prop:
render() {
this.shadowRoot.innerHTML = `
<style>
.custom-card {
width: 450px;
display: grid;
grid-template-columns: 1fr 2fr;
grid-gap: 10px;
border: 1px solid black;
border-radius: 20px;
box-shadow: 5px 5px rgb(0 0 0 / 0.2);
}
h3, h4, p {
font-family: sans-serif;
}
img {
width: 100%;
border-radius: 20px 0 0 20px;
}
</style>
<div class="custom-card">
<img src=${this.getAttribute("profilePic")} />
<div>
<h3>${this.getAttribute("name")}</h3>
<h4>${this.getAttribute("title")}</h4>
<p>${this.getAttribute("description")}</p>
</div>
</div>
`;
}
Here is the HTML markup:
<div
is="custom-card"
name="Jane Doe"
title="A deer"
description="A female deer"
profilePic="assets\images\siska-vrijburg-KD6na8-qGPI-unsplash.jpg"
></div>
You can test that your styles are encapsulated by rendering another instance of the extended element - i.e. a <div>
- in your document. You should see that this doesn't follow the same style rules set in your component, which is exactly what we want!
Using slots to customise your component
If you add a <slot>
element to your component's rendering markup (or template), you can use this as a placeholder for any additional or optional content you may want to pass in.
This can be useful when defining a parent component with an uncertain (or flexible) number of subcomponents.
For example:
render() {
this.shadowRoot.innerHTML = `
<div class="custom-card">
<h3>${this.getAttribute("name")}</h3>
<slot></slot>
</div>
</div>
`;
}
The HTML markup:
<div is="custom-card" name="Jane Doe">Age: 21</div>
The text 'Age: 21' will appear where the <slot>
element is placed.
You can also use named slots to determine where to place your custom content. This can be achieved by adding a name
property to the <slot>
, and matching this to a slot
attribute in your HTML:
render() {
this.shadowRoot.innerHTML = `
<div class="custom-card">
<h3>${this.getAttribute("name")}</h3>
<slot name="age"></slot>
<slot name="address"></slot>
</div>
</div>
`;
}
<div is="custom-card" name="Jane Doe">
<h3 slot="age">Age: 21</h3>
<p slot="address" class="address">The Forest</p>
</div>
This gives you a lot of control and flexibility over specifically where content can sit within the structure of that component, as well as allowing you to choose different element types - offering many possibilities.
Web component lifecycle methods
Our custom element classes allow for a variety of lifecycle methods, again similar to working with state in a framework like React. These methods are all inherited from the HTMLElement class:
-
connectedCallback()
is called when the element is inserted into the DOM (much like React'scomponentDidMount()
, oruseEffect()
with an empty dependency array) -
disconnectedCallback()
- called every time the element is removed from the DOM (much like React'scomponentWillUnmount()
, oruseEffect()
with a cleanup function) -
attributeChangedCallback(attributeName, oldVal, newVal)
- called whenever an attribute is added, removed, updated or replaced (much like React'scomponentDidUpdate()
, oruseEffect()
with a dependency array, watching for state updates)
Working with event listeners
Here's an example of using connectedCallback()
, which is useful for performing initialization tasks - in this case, adding an event listener to our Card component, so that we can have some stateful interactivity. We'll create a button inside our card which toggles show/hide for certain details.
In our rendering template, let's set up a button and a <div>
containing the elements for which we want to toggle visibility, with the class 'info':
render() {
this.shadowRoot.innerHTML = `
<div class="custom-card">
<img src=${this.getAttribute("profilePic")} />
<div>
<h3>${this.getAttribute("name")}</h3>
<button id="toggle-info">Show info</button>
<div class="info">
<slot name="age"></slot>
<slot name="address"></slot>
</div>
<h4>${this.getAttribute("title")}</h4>
<p>${this.getAttribute("description")}</p>
</div>
</div>
`
}
I'll add in some basic button styling too:
button {
background-color: rgb(37 99 235);
color: white;
padding: 0.5rem 1rem;
border: 0;
border-radius: 20px;
}
button:hover {
background-color: rgb(29 78 216);
cursor: pointer;
}
Now in your constructor, add some state in the form of a boolean - we'll use this to track whether the content should be shown or hidden:
constructor() {
super();
this.attachShadow({ mode: "open" });
this.showInfo = true;
}
Using the connectedCallback()
method, select the button element and add a click event listener which will pass in a function:
connectedCallback() {
this.render();
this.toggleInfo();
this.shadowRoot
.querySelector("#toggle-info")
.addEventListener("click", () => this.toggleInfo());
}
Elsewhere within the component, set up the toggleInfo()
function, which uses the state of our 'showInfo' value to toggle the text display of the button, and the visibility of the 'info' elements:
toggleInfo() {
this.showInfo = !this.showInfo;
const info = this.shadowRoot.querySelector(".info");
const toggleBtn = this.shadowRoot.querySelector("#toggle-info");
if (this.showInfo) {
info.style.display = "block";
toggleBtn.innerText = "Hide info";
} else {
info.style.display = "none";
toggleBtn.innerText = "Show info";
}
}
Finally, you want to handle the removal of your event listener at the end of the lifecycle, using the disconnectedCallback()
method:
disconnectedCallback() {
this.shadowRoot.querySelector("#toggle-info").removeEventListener();
}
Your button should now show and hide details:
Working with state updates
Sometimes we want to work with state that changes dynamically in our web page or app, which is where the attributeChangedCallback()
method becomes useful.
A good example of this is in a classic counter component, which increments a number display when we click on a button.
Here is our basic component rendering logic:
class MyCounter extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
}
render() {
this.shadowRoot.innerHTML = `
<h1>Counter</h1>
${this.count}
<button id="btn">+1</button>
`;
}
}
customElements.define("my-counter", MyCounter);
In our HTML, we can set an initial value for an attribute, in this case 'count':
<my-counter count="1"></my-counter>
To use and display our 'count' in our component, we can use a 'getter' to get the value of our property (or state), using this.getAttribute()
:
get count() {
return this.getAttribute("count");
}
Now we need to handle updates to our attribute value. This is linked to a property in our component, which we set using a static get
method ('static' because it's on all instances) called observedAttributes()
. The method keeps track of updates to our state. It will return any attributes you want to observe:
static get observedAttributes() {
return ["count"];
}
Next, to handle updates to this value, we use the attributeChangedCallback()
method, passing in 3 arguments - the name of the attribute, the old value, and the new or updated value:
attributeChangedCallback(prop, oldVal, newVal) {
if (prop === "count" && oldVal !== newVal) {
this.render();
let btn = this.shadowRoot.querySelector("#btn");
btn.addEventListener("click", () => this.increment());
}
}
increment() {
this.count++;
}
Note that you'll also need to initialize an event listener on the component using the connectedCallback()
method:
connectedCallback() {
this.render();
let btn = this.shadowRoot.querySelector("#btn");
btn.addEventListener("click", () => this.increment());
}
Finally, to handle updates to our state, we need to use a 'setter' with the this.setAttribute()
method:
set count(val) {
this.setAttribute("count", val);
}
Phew! This is quite a lot of setup - but we should now have a working counter component, with full control over the lifecycle.
A brief note on security
The methods used here (and in other tutorials I've seen) lean heavily on manipulation of the innerHTML
property of elements, which can expose a site to security issues such as cross-site scripting (XSS) attacks.
If your components don't rely on dynamic content based on user input (e.g. forms) or data pulled from APIs, databases or other 3rd-party sources, then that risk is significantly reduced, but for a production app you would still want to consider sanitizing your HTML before injecting it into your component, either manually or using a library like sanitize-html.
Summary
As we've seen, there are numerous benefits to using web components.
They provide a flexible foundation for building your own reusable UI elements. They're lightweight, without being dependent on any external frameworks, but their encapsulated nature (HTML, CSS and JS in a single file) and usage of built-in browser APIs makes them very easily 'portable' between frameworks if required, without the need for refactoring.
Understanding web components can in turn give you a deeper understanding of built-in features of the browser, HTML elements and shadow DOM, as well as useful OOP principles.
Ultimately, it can be good to break down some of the layers of abstraction built into JS frameworks, so that we can better understand why the features they are adding are useful, and how best to make use of them.
As an added benefit, because you are extending existing HTML elements, you will be retaining their accessibility traits, rather than necessarily needing to rewrite them.
The tradeoff of course is that web components can be fairly 'low level,' with fewer abstractions than frameworks, and can require more setup and understanding to use correctly - especially when managing the component lifecycle. This can certainly make them feel intimidating and convoluted (at first), and the workflow may not be as efficient, though of course this depends on their complexity. To address this, it may be worth exploring libraries such as hybrids and Lit, which build upon the web components API, but reduce the amount of boilerplate required.
Top comments (3)
Some minor comments
Your
extends HTMLDIVElement
does not work in Safari. Apple will not implement this part of the spec.You should use
extends HTMLElement
(Autonomous Elements) instead.You can't do DOM operations in the
constructor
, as there is no DOM yet. Like when usingdocument.createElement("")
. You should useconnectedCallback
instead.Create your own (global) helper functions to create elements, like
createDIV
. This way you can easily change the way you create elements in the future. There is also less need now for tools like Lit, Stencil etc.appendChild
and useappend
instead. It's more flexible adds multiple elements and is faster.Hi Danny, thanks for taking the time to read this and sharing the info - these are some very useful points that I wasn't aware of. I'll consider adding some updates / extra info:
extends HTMLDivElement
not (currently?) being part of the Apple/Safari spec - that's disappointing, I had assumed these were universal standards and I hadn't seen this mentioned anywhere previously with tutorials on custom elements. For compatibility then across 100% of modern browsers, it sounds like Autonomous Elements (extending fromHTMLElement
) are the way to go, though I have seen on Stack Overflow that it's possible to take a hybrid approach, using the name of the defined custom element (extended fromHTMLElement
) but then using theis
attribute:...which generates a
<custom-card>
tag with another<div class="custom-card">
tag inside of it. I tested and it appears to work, though I'm not sure if this is the best workaround or solution.Good point re: keeping DOM operations in
connectedCallback
, though I didn't encounter any issues using e.g.document.createElement()
in the constructor either, is there a scenario where this may be a problem? Would this be an issue with 'method 1' in the section on the Shadow DOM? In my preferred method (method 2) I wouldn't be usingdocument.createElement()
like this, but wondering if I've understood correctly.The custom
createDIV
approach seems like a good way create reusable code, will definitely consider this pattern.Happy to switch out
appendChild
for the newerappend
, as I can't see any benefit apart from habit / niche use cases!Longer reads:
stackoverflow.com/questions/720901...
dev.to/dannyengelman/web-component...