Skip to content

Custom Element Lifecycle — Complete Guide

DodaTech Updated 2026-06-28 8 min read

In this tutorial, you will learn about Custom Element Lifecycle. We cover key concepts, practical examples, and best practices to help you master this topic.

Custom Element lifecycle callbacks — constructor, connectedCallback, disconnectedCallback, attributeChangedCallback, adoptedCallback — control component behavior at each stage.

What You'll Learn

  • The five lifecycle callbacks and when each fires
  • The proper order of lifecycle events
  • How to set up resources in connectedCallback and clean up in disconnectedCallback
  • How to react to attribute changes with attributeChangedCallback

Why It Matters

Lifecycle management prevents memory leaks, ensures proper initialization, and keeps the component in sync with its environment. Components that do not clean up after themselves cause bugs and performance issues.

Real-World Use

  • A chart component fetches data in connectedCallback and removes the chart in disconnectedCallback
  • A timer component starts counting when connected and stops when disconnected
  • A form component re-validates when required attributes change
flowchart LR
  A[Element Created] --> B[constructor]
  B --> C[attributeChangedCallback]
  C --> D[connectedCallback]
  D --> E[Element is alive]
  E --> F[Attribute changes]
  F --> G[attributeChangedCallback]
  E --> H[Moved to new document]
  H --> I[adoptedCallback]
  E --> J[Element removed]
  J --> K[disconnectedCallback]

Lifecycle Order

The callbacks fire in a specific order when an element is created and added to the DOM.

class LifecycleOrder extends HTMLElement {
    constructor() {
        super();
        console.log('1. Constructor');
        this.attachShadow({ mode: 'open' });
    }

    static get observedAttributes() {
        return ['data-value'];
    }

    attributeChangedCallback(name, oldValue, newValue) {
        console.log(`2. attributeChangedCallback: ${name} ${oldValue} -> ${newValue}`);
    }

    connectedCallback() {
        console.log('3. connectedCallback');
        this.render();
    }

    render() {
        this.shadowRoot.innerHTML = `<p>Lifecycle order demo</p>`;
    }

    disconnectedCallback() {
        console.log('4. disconnectedCallback');
    }

    adoptedCallback() {
        console.log('adoptedCallback (fired when moved between documents)`);
    }
}
customElements.define('lifecycle-order', LifecycleOrder);
<lifecycle-order data-value="test"></lifecycle-order>

Expected output: When the element is first parsed, the console shows: 1. Constructor, 2. attributeChangedCallback (null -> "test"), 3. connectedCallback. If the element has no initial attributes, attributeChangedCallback fires before connectedCallback only for attributes listed in observedAttributes.

constructor

The constructor runs when the element is created. It sets up initial state.

class SetupElement extends HTMLElement {
    constructor() {
        super();

        // 1. Initialize state
        this._data = null;
        this._isReady = false;

        // 2. Attach shadow DOM
        this.attachShadow({ mode: 'open' });

        // 3. Bind methods if needed
        this._handleClick = this._handleClick.bind(this);

        console.log('Setup complete');
    }

    connectedCallback() {
        this._isReady = true;
        // Now safe to interact with DOM
        this.loadData();
    }

    async loadData() {
        // This method runs after connection
    }

    _handleClick() {
        console.log('Click handled');
    }
}
customElements.define('setup-element', SetupElement);

Expected output: The constructor runs once per element instance. It initializes state, creates the shadow root, and binds methods. No DOM access happens here.

connectedCallback

This is the main lifecycle callback. It fires when the element is inserted into the DOM.

class ConnectedDemo extends HTMLElement {
    constructor() {
        super();
        this.attachShadow({ mode: 'open' });
    }

    connectedCallback() {
        console.log('Element connected');

        // Render initial content
        this.render();

        // Set up event listeners
        window.addEventListener('resize', this._onResize);

        // Start timers or intervals
        this._tickInterval = setInterval(() => {
            this.updateTime();
        }, 1000);

        // Fetch initial data
        this.fetchData();

        // Note: connectedCallback can fire multiple times
        // if the element is moved in the DOM
    }

    disconnectedCallback() {
        console.log('Element disconnected');

        // Clean up everything set up in connectedCallback
        window.removeEventListener('resize', this._onResize);
        clearInterval(this._tickInterval);
    }

    _onResize() {
        console.log('Window resized');
    }

    render() {
        this.shadowRoot.innerHTML = `
            <div id="time"></div>
        `;
    }

    updateTime() {
        const timeEl = this.shadowRoot.getElementById('time');
        if (timeEl) {
            timeEl.textContent = new Date().toLocaleTimeString();
        }
    }

    async fetchData() {
        console.log('Fetching data...');
        // const data = await fetch(this.getAttribute('data-source'));
    }
}
customElements.define('connected-demo', ConnectedDemo);

Expected output: When added to the DOM, the element renders a time display that updates every second. It also listens for window resize. When removed from the DOM, the interval is cleared and the event listener is removed.

disconnectedCallback

Clean up everything set up in connectedCallback to prevent memory leaks.

class ResourceCleanup extends HTMLElement {
    constructor() {
        super();
        this.attachShadow({ mode: 'open' });
        this._observers = [];
        this._listeners = [];
        this._timers = [];
    }

    connectedCallback() {
        this.render();

        // Track all resources for cleanup
        const observer = new MutationObserver(() => {});
        observer.observe(this, { childList: true });
        this._observers.push(observer);

        const handler = () => {};
        document.addEventListener('scroll', handler);
        this._listeners.push({ target: document, type: 'scroll', handler });

        const timer = setTimeout(() => {}, 1000);
        this._timers.push(timer);
    }

    disconnectedCallback() {
        console.log('Cleaning up resources');

        // 1. Disconnect observers
        this._observers.forEach(o => o.disconnect());
        this._observers = [];

        // 2. Remove event listeners
        this._listeners.forEach(({ target, type, handler }) => {
            target.removeEventListener(type, handler);
        });
        this._listeners = [];

        // 3. Clear timers
        this._timers.forEach(t => clearTimeout(t));
        this._timers = [];

        console.log('All resources cleaned up');
    }

    render() {
        this.shadowRoot.innerHTML = `<p>Resource cleanup demo</p>`;
    }
}
customElements.define('resource-cleanup', ResourceCleanup);

Expected output: When the element is connected, it sets up a mutation Observer, a scroll listener, and a timeout. All are tracked in arrays. When disconnected, all resources are cleaned up in a systematic way.

attributeChangedCallback

React to changes in specific attributes.

class AvatarComponent extends HTMLElement {
    constructor() {
        super();
        this.attachShadow({ mode: 'open' });
    }

    // Declare which attributes to watch
    static get observedAttributes() {
        return ['src', 'alt', 'size', 'shape'];
    }

    attributeChangedCallback(name, oldValue, newValue) {
        console.log(`Attribute ${name} changed: "${oldValue}" -> "${newValue}"`);

        // Re-render only if the element is connected
        if (this.isConnected) {
            this.render();
        }

        // React to specific attributes
        switch (name) {
            case 'src':
                this._loadImage(newValue);
                break;
            case 'size':
                this._updateSize(newValue);
                break;
            case 'shape':
                this._updateShape(newValue);
                break;
        }
    }

    connectedCallback() {
        this.render();
    }

    render() {
        const src = this.getAttribute('src') || '';
        const alt = this.getAttribute('alt') || 'Avatar';
        const size = this.getAttribute('size') || '64';
        const shape = this.getAttribute('shape') || 'circle';

        this.shadowRoot.innerHTML = `
            <style>
                img {
                    width: ${size}px;
                    height: ${size}px;
                    object-fit: cover;
                    border-radius: ${shape === 'circle' ? '50%' : '4px'};
                    border: 2px solid #e0e0e0;
                }
            </style>
            <img src="${src}" alt="${alt}" loading="lazy">
        `;
    }

    _loadImage(src) {
        console.log('Loading image:', src);
    }

    _updateSize(size) {
        const img = this.shadowRoot.querySelector('img');
        if (img) {
            img.style.width = size + 'px';
            img.style.height = size + 'px';
        }
    }

    _updateShape(shape) {
        const img = this.shadowRoot.querySelector('img');
        if (img) {
            img.style.borderRadius = shape === 'circle' ? '50%' : '4px';
        }
    }
}
customElements.define('user-avatar', AvatarComponent);
<user-avatar src="photo.jpg" alt="User" size="80" shape="circle"></user-avatar>
<script>
    // Changing attributes triggers attributeChangedCallback
    document.querySelector('user-avatar').setAttribute('size', '120');
</script>

Expected output: The avatar renders with the specified attributes. Changing an attribute dynamically triggers the callback, which updates the relevant part of the component.

adoptedCallback

This rarely-used callback fires when the element is moved to a new document.

class Adoptable extends HTMLElement {
    constructor() {
        super();
        this.attachShadow({ mode: 'open' });
    }

    connectedCallback() {
        console.log('Connected to document:', document.title);
        this.render();
    }

    adoptedCallback() {
        console.log('Adopted! Moved to a different document');
        console.log('Previous document:', this._lastDocument);
        console.log('New document:', document.title);

        // Re-initialize if needed
        this.render();

        // Update stored reference
        this._lastDocument = document;
    }

    disconnectedCallback() {
        this._lastDocument = document;
    }

    render() {
        this.shadowRoot.innerHTML = `
            <p>Current document: ${document.title}</p>
        `;
    }
}
customElements.define('adoptable-elem', Adoptable);

// adoptedCallback fires when:
// const iframeDoc = document.querySelector('iframe').contentDocument;
// iframeDoc.body.adoptNode(element);
// iframeDoc.body.appendChild(element);

Expected output: When moved to an iframe or another document, adoptedCallback fires. This is rare but useful for components that need to reinitialize when their document context changes.

Common Mistakes

  1. Setting up listeners in constructor instead of connectedCallback — The constructor may run before the element is in a document. Use connectedCallback for any setup that depends on the DOM.
  2. Forgetting to clean up in disconnectedCallback — Event listeners, observers, and timers continue running after element removal, causing memory leaks.
  3. Rendering in attributeChangedCallback without checking isConnected — attributeChangedCallback can fire before connectedCallback. Check this.isConnected before rendering.
  4. Not tracking resources for cleanup — Use arrays to track observers, listeners, and timers so you can clean them up systematically in disconnectedCallback.
  5. Assuming connectedCallback fires only once — Moving an element in the DOM triggers connectedCallback again. Guard against double initialization with a flag.

Practice Questions

  1. What are the five lifecycle callbacks? constructor, connectedCallback, disconnectedCallback, attributeChangedCallback, adoptedCallback.
  2. Why must you clean up in disconnectedCallback? To prevent memory leaks. Event listeners on other elements, observers, and timers keep the component alive even after removal.
  3. How do you watch for attribute changes? Implement static get observedAttributes() returning an array of attribute names, and implement attributeChangedCallback(name, oldValue, newValue).
  4. Challenge: Create a <countdown-timer> element that accepts seconds and auto-start attributes. It counts down from the specified seconds. Pauses when removed from DOM, resumes when added back. Fires a timer-complete event when reaching zero.

FAQ

Does connectedCallback fire when the element is moved?

Yes. If you move an element within the DOM, disconnectedCallback fires first, then connectedCallback fires again.

Can attributeChangedCallback fire before connectedCallback?

Yes. If the element has attributes in the HTML, attributeChangedCallback fires during parsing, before the element is connected.

What happens if I define observedAttributes after the element is already connected?

The attributes specified in observedAttributes are monitored from that point forward. Previous changes do not trigger the callback.

How do I prevent double initialization when connectedCallback fires multiple times?

Use a flag like this._initialized. Check and set it at the start of connectedCallback.

Is adoptedCallback commonly used?

No. It is rarely needed in typical web development. It mainly applies to cases like moving elements between iframes or between documents.

Mini Project

Build a <data-table> custom element that fetches data from a URL attribute and displays it as an HTML table. Use connectedCallback for initial fetch. Use attributeChangedCallback to refetch when the URL changes. Clean up the fetch request (AbortController) in disconnectedCallback. Show a loading state while fetching and an error state if the request fails.

What's Next

Continue with Lesson 4: Attributes and Observed to learn how to handle component properties and observed attributes together.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro