DEV Community

Cover image for Exploring web components (and revisiting some JS fundamentals)
JamieBarlow
JamieBarlow

Posted on

Exploring web components (and revisiting some JS fundamentals)

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.

Puzzle pieces


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.");
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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

(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;
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

The results, with some added styling for a card author:

Image description

.card-author {
  font-style: italic;
}
Enter fullscreen mode Exit fullscreen mode

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

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

    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.

  1. 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)
    
  2. 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):

Web components card

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"
});
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Extending Div element

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>`;
Enter fullscreen mode Exit fullscreen mode

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, {
)
Enter fullscreen mode Exit fullscreen mode

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",
});
Enter fullscreen mode Exit fullscreen mode

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
(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>
      `;
  }
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Jane Doe Deer card

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>
      `;
  }
Enter fullscreen mode Exit fullscreen mode

The HTML markup:

<div is="custom-card" name="Jane Doe">Age: 21</div>
Enter fullscreen mode Exit fullscreen mode

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>
      `;
  }
Enter fullscreen mode Exit fullscreen mode
<div is="custom-card" name="Jane Doe">
  <h3 slot="age">Age: 21</h3>
  <p slot="address" class="address">The Forest</p>
</div>
Enter fullscreen mode Exit fullscreen mode

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)
  • 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':

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>
  `
}
Enter fullscreen mode Exit fullscreen mode

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;
        }
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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());
  }
Enter fullscreen mode Exit fullscreen mode

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";
    }
  }
Enter fullscreen mode Exit fullscreen mode

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();
  }
Enter fullscreen mode Exit fullscreen mode

Your button should now show and hide details:

Image description


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);
Enter fullscreen mode Exit fullscreen mode

In our HTML, we can set an initial value for an attribute, in this case 'count':

<my-counter count="1"></my-counter>
Enter fullscreen mode Exit fullscreen mode

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");
}
Enter fullscreen mode Exit fullscreen mode

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"];
}
Enter fullscreen mode Exit fullscreen mode

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++;
  }
Enter fullscreen mode Exit fullscreen mode

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());
  }
Enter fullscreen mode Exit fullscreen mode

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);
  }
Enter fullscreen mode Exit fullscreen mode

Phew! This is quite a lot of setup - but we should now have a working counter component, with full control over the lifecycle.

Counter


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)

Collapse
 
dannyengelman profile image
Danny Engelman

Some minor comments

  • Apple/Safari doesn't do Customized Built-In Elements

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 using document.createElement(""). You should use connectedCallback 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.

    createCardElement() {
      const createElement = (tag, props = {}) => Object.assign(document.createElement(tag), props);
      const createDIV = (props={}) => createElement("div",props);

      this.card = createDIV({
        className: "card"
      });
      this.card.append(
        createDIV({
          className: "card-title",
          textContent: this.title
        }),
        createDIV({
          className: "card-content",
          textContent: this.content
        })
      );
      return this.card;
    }
Enter fullscreen mode Exit fullscreen mode
  • Ditch oldskool appendChild and use append instead. It's more flexible adds multiple elements and is faster.
    this.shadowRoot.append(this.createCardElement());
Enter fullscreen mode Exit fullscreen mode
Collapse
 
jaybarls profile image
JamieBarlow

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:

  • Noted on 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 from HTMLElement) 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 from HTMLElement) but then using the is attribute:
<custom-card is="ProfileCard">
Enter fullscreen mode Exit fullscreen mode

...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 using document.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 newer append, as I can't see any benefit apart from habit / niche use cases!

Collapse
 
dannyengelman profile image
Danny Engelman