Exploring web components (and revisiting some JS fundamentals)
Tags: webcomponents, javascript, react, webdev
Published: 2024-05-06 23:26:54

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.
<br>

---

##Background
[Frameworks are not the only tool](#frameworks-are-not-the-only-tool)
[An interim approach](#an-interim-approach)
[A better approach using classes](#a-better-approach-using-classes)

##Intro to web components
[Getting into web components](#getting-into-web-components)
[Customising existing HTML elements](#customising-existing-html-elements)
[The Shadow DOM](#the-shadow-dom)
[Using slots to customise your component](#using-slots-to-customise-your-component)

##Lifecycle methods
[Web component lifecycle methods](#web-component-lifecycle-methods)
[Working with event listeners](#working-with-event-listeners)
[Working with state updates](#working-with-state-updates)

##Wrapping up
[A brief note on security](#a-brief-note-on-security)
[Summary](#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](https://p5js.org/) *and* the [p5.sound](https://p5js.org/reference/#/libraries/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](https://www.npmjs.com/package/react-p5-wrapper#p5-plugins-and-constructors), 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.

![Puzzle pieces](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/l9a6jw6aevc2hzh4sdm6.jpg)
<br>

---

#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):

```js
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:

```js
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:

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/t35g4wxwfpw28hznpuk1.png)

(I added some basic CSS styling here):
```css
.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:

```js
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:

```js
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:

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/xkwjlsbtnx1olqdkablo.png)

```css
.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:

1. Create a JS file, e.g. Card.js
2. Include this as a `<script>` tag in the `<head>` of your index.html. Use the `defer` keyword (so it loads after the HTML content has been parsed):

    ```html
    <script defer src="Card.js""></script>
    ```

3. Inside this file, we create a custom class from the base class 'HTMLElement', using the `class` and `extends` keywords:

    ```js
    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](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement). This is a base class from which our standardly recognised HTML elements/tags (`<div>`, `<p>`, `<span>`, `<button>`, `<input>`, etc.) are all subclasses.
4. 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:

    ```js
    customElements.define("custom-card", Card)
    ```

5. Now in your index.html, you can render this element:

    ```html
    <body>
      <custom-card>Card contents</custom-card>
    </body>
    ```

The card would look like this (using our CSS styling of the 'card' class from earlier):

![Web components card](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/pmo1ypjf444ox2sndext.png)

##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](https://developer.mozilla.org/en-US/docs/Web/API/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:

```js
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:

```html
<div is="custom-card" name="Jane Doe"></div>
```

![Extending Div element](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4qvez5s4oz5pkl62818z.png)

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](https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Objects/Object-oriented_programming#encapsulation). 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:

```js
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)`:

```js
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:

```js
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:

![Encapsulated styling](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/52jbl2hjxae4qn0kacj3.png)
(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:

```js
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:

```html
<div
  is="custom-card"
  name="Jane Doe"
  title="A deer"
  description="A female deer"
  profilePic="assets\images\siska-vrijburg-KD6na8-qGPI-unsplash.jpg"
></div>
```

![Jane Doe Deer card](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/na3xt7euqefcyv47hxud.png)

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:

```js
 render() {
    this.shadowRoot.innerHTML = `
      <div class="custom-card">
          <h3>${this.getAttribute("name")}</h3>
          <slot></slot>
        </div>
      </div>
      `;
  }
```

The HTML markup:
```html
<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:

```js
 render() {
    this.shadowRoot.innerHTML = `
      <div class="custom-card">
          <h3>${this.getAttribute("name")}</h3>
          <slot name="age"></slot>
          <slot name="address"></slot>
        </div>
      </div>
      `;
  }
```

```html
<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's `componentDidMount()`, or `useEffect()` with an [empty dependency array](https://react.dev/reference/react/useEffect#examples-dependencies))
- `disconnectedCallback()` - called every time the element is removed from the DOM (much like React's `componentWillUnmount()`,  or `useEffect()` with a cleanup function)
- `attributeChangedCallback(attributeName, oldVal, newVal)` - called whenever an attribute is added, removed, updated or replaced (much like React's `componentDidUpdate()`, or `useEffect()` 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':

```js
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:

```css
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:

```js
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:

```js
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:

```js
 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:

```js
disconnectedCallback() {
    this.shadowRoot.querySelector("#toggle-info").removeEventListener();
  }
```

Your button should now show and hide details:

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/wqpw6p0smbjao3b6id9f.gif)

---

##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:

```js
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':

```html
<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()`:

```js
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:

```js
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:

```js
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:

```js
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:

```js
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.

![Counter](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/vvt3o9imp3jc69ki4fyu.gif)

---

#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](https://www.npmjs.com/package/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](https://hybrids.js.org/#/) and [Lit](https://lit.dev/), which build upon the web components API, but reduce the amount of boilerplate required.