stave.dev

Component Lifecycle

When each callback fires, what's available at each stage, and the one rule you must never break.

Lifecycle Overview

ShadowComponent follows the standard Web Components lifecycle, with a few things handled for you in the base class constructor:

constructor()
connectedCallback()
attributeChangedCallback()
disconnectedCallback()

constructor()

The constructor fires when the element is created (either by the parser or by document.createElement()). ShadowComponent's constructor does the heavy lifting:

  1. Calls super() (required for all HTMLElement subclasses)
  2. Creates getter/setter pairs for each entry in static observables
  3. Attaches the shadow root with attachShadow({mode: "open"})
  4. Finds template#tag-name in the document and clones its content into the shadow root
Constructor Limitations

The element is not in the DOM during the constructor. You cannot read attributes that depend on document context, measure dimensions, or access parentElement. Save that work for connectedCallback().

If your subclass needs constructor logic, always call super() first:

class MyComponent extends ShadowComponent {
    static observables = ["count"];

    constructor() {
        super(); // MUST be first — sets up shadow root, template, observables
        this._internalState = new Map();
    }
}

connectedCallback()

Fires when the element is inserted into the DOM. This is where you do most of your setup — querying shadow DOM elements, adding event listeners, fetching data, measuring dimensions.

Critical Rule

super.connectedCallback() must be the first line in your connectedCallback(). The base class performs essential setup. Omitting this call will break your component.

connectedCallback() {
    super.connectedCallback(); // ALWAYS first

    // Now safe to do everything:
    this.button = this.shadowRoot.querySelector(".my-button");
    this.button.addEventListener("click", () => this.handleClick());

    // Access attributes
    const mode = this.getAttribute("mode") || "default";

    // Measure things
    const width = this.getBoundingClientRect().width;
}

What's Available

Capability constructor() connectedCallback()
this.shadowRoot Yes (after super()) Yes
Template content cloned Yes (after super()) Yes
Observable getters/setters Yes (after super()) Yes
this.parentElement No Yes
this.getAttribute() Unreliable Yes
DOM measurement No Yes
Event listeners on self No Yes
Slotted content accessible No Yes

disconnectedCallback()

Fires when the element is removed from the DOM. Use it to clean up event listeners, cancel timers, or release resources. This method can fire multiple times if an element is added and removed repeatedly.

disconnectedCallback() {
    // Clean up to prevent memory leaks
    if (this._observer) {
        this._observer.disconnect();
        this._observer = null;
    }
    if (this._timer) {
        clearInterval(this._timer);
        this._timer = null;
    }
    // Remove document-level listeners
    document.removeEventListener("keydown", this._boundKeyHandler);
}

attributeChangedCallback()

Fires whenever an observed attribute changes. ShadowComponent wires observedAttributes to return the static observables array, so any property you declare as observable triggers this callback when its corresponding attribute is set.

attributeChangedCallback(name, oldValue, newValue) {
    // The base class handles slot synchronization automatically.
    // Override only if you need additional side effects:

    if (name === "count" && this.shadowRoot) {
        this.shadowRoot.querySelector(".display").textContent = newValue;
    }
}

The base class attributeChangedCallback does the following:

  1. Skips if oldValue === newValue (no-op guard)
  2. Parses the new value (handles JSON arrays and objects)
  3. Finds existing slot elements for the attribute name
  4. If counts match and values are primitives, patches in place (non-destructive)
  5. Otherwise, removes and recreates the slot elements
No afterRender() Method

ShadowComponent does not have an afterRender() lifecycle hook. If you need to run code after the template is attached, do it at the end of connectedCallback(). If you need to react to attribute changes, use attributeChangedCallback().

Lifecycle Flow Diagram

Element Created
constructor()
shadow root + template + observables
Inserted into DOM
connectedCallback()
setup, listeners, queries
Attribute changes
attributeChangedCallback()
slot sync + side effects
 
Removed from DOM
disconnectedCallback()
cleanup

Complete Example

<template id="live-clock">
    <style>
        :host { display: inline-block; font-family: var(--font-mono); }
        .time { font-size: 1.2em; color: var(--accent-primary); }
    </style>
    <span class="time"></span>
</template>

<script>
class LiveClock extends ShadowComponent {
    static observables = [];

    connectedCallback() {
        super.connectedCallback();
        this._display = this.shadowRoot.querySelector(".time");
        this._tick();
        this._timer = setInterval(() => this._tick(), 1000);
    }

    disconnectedCallback() {
        clearInterval(this._timer);
    }

    _tick() {
        this._display.textContent = new Date().toLocaleTimeString();
    }
}
customElements.define("live-clock", LiveClock);
</script>