Custom Elements Under the Hood

RocíoRocíoFebruary 19, 2025
Custom Elements Under the Hood

Hi there 🫶 I want to share a recap about the inner workings of custom elements that I have put together now that I have been working with them a little bit more.

Understanding the parsing process

I wondered what happened exactly when the browser sees a custom element. Let's go through the process:

When the DOM parser encounters a custom element tag like <my-component></my-component>, it goes through several key stages:

1. Tokenization

The HTML parser first tokenizes the input. During this phase:

  • It detects a tag named my-component
  • Begins creating an element corresponding to that tag name
  • Marks this element as a custom element candidate due to the presence of a dash (-) in its name

2. Element Creation

The parser then creates an instance of the element through these steps:

  • If the element has been defined (via customElements.define), it's instantiated as an instance of its custom class
  • The constructor is invoked
  • Tag attributes are processed and attached to the element
  • Any child nodes are parsed and added
  • The fully constructed element is inserted into the DOM tree at the parser's current position

3. Lifecycle Callback

After construction and DOM insertion:

  • The connectedCallback is triggered
  • This marks the point where the element is fully integrated into the document

Working with Custom Elements

Now, there are some considerations about writing the custom element class that are not so obvious from the beginning. Here's a basic example of defining a custom element:

class MyCustomElement extends HTMLElement {
  constructor() {
    super();
    this.innerHTML = "<div>MyCustomElement</div>";
  }
}

customElements.define("my-custom-element", MyCustomElement);

Important Considerations

When working with custom elements, keep in mind:

  • DOM Access: Within the custom element class, this represents the node being inserted, giving you access to the DOM.
  • Constructor Limitations: The constructor runs before the element is attached to the document
  • Layout measurements and parent-child relationships may be unreliable at this stage
  • Properties like offsetWidth or sibling queries may return inaccurate values
  • Best Practices:
    • Initialize visual state in the constructor
    • Leave DOM manipulations for the connectedCallback
    • Consider adding a loading state for heavy processing or async tasks in connectedCallback to avoid visual flicker

Here is a cool example that you can play around with to get a better understanding of the component lifecycle // Define a custom element that demonstrates the full lifecycle:

class LifecycleElement extends HTMLElement {
  constructor() {
    super();
    console.log("1. Constructor: Element is being created");

    // Create a shadow root for encapsulation
    const shadow = this.attachShadow({ mode: "open" });

    // Initialize but don't measure or query siblings yet
    this.initialContent = document.createElement("div");
    this.initialContent.textContent = "Initializing...";
    shadow.appendChild(this.initialContent);

    // Demonstrate why measuring here is premature
    console.log("Width in constructor:", this.offsetWidth); // Will likely be 0
  }

  connectedCallback() {
    console.log("2. Connected: Element is now in the DOM");

    // Safe to measure and query the DOM now
    console.log("Width in connectedCallback:", this.offsetWidth);

    // Demonstrate async behavior
    this.updateContent();
  }

  async updateContent() {
    // Simulate an async operation
    await new Promise((resolve) => setTimeout(resolve, 1000));

    const content = document.createElement("div");
    content.innerHTML = `
      <style>
        .container {
          padding: 20px;
          border: 2px solid #333;
          margin: 10px;
        }
        .info {
          color: #666;
          font-style: italic;
        }
      </style>
      <div class="container">
        <h2>Lifecycle Element</h2>
        <p>Element width: ${this.offsetWidth}px</p>
        <p class="info">This content was rendered asynchronously</p>
      </div>
    `;

    this.shadowRoot.replaceChildren(content);
  }

  disconnectedCallback() {
    console.log("3. Disconnected: Element removed from DOM");
  }

  attributeChangedCallback(name, oldValue, newValue) {
    console.log(
      `4. Attribute changed: ${name} from ${oldValue} to ${newValue}`
    );
  }

  static get observedAttributes() {
    return ["data-state"];
  }
}

// Register the custom element
customElements.define("lifecycle-element", LifecycleElement);

// Usage example

// Create and append element
const element = document.createElement("lifecycle-element");
document.body.appendChild(element);

// Later: modify attribute
element.setAttribute("data-state", "updated");

// Later: remove element
element.remove();

And that's all for now, next chapter: Virtual DOM vs Shadow DOM.