Step 05

Component lifecycle

A custom element has five lifecycle callbacks, and most of the bugs people hit come from treating only two of them as real: constructor and connectedCallback. The other three — disconnectedCallback, attributeChangedCallback, and the rarely-seen adoptedCallback — are where resource management and cleanup live. This step walks through the full set, the timing between them, and the listener that outlives its element because nobody removed it.

Learning Objectives

  • Master all lifecycle callbacks and their purposes
  • Understand timing and sequencing of callbacks
  • Implement proper cleanup patterns
  • Handle edge cases and memory management

Lifecycle Callbacks Overview

Custom elements have five lifecycle callbacks that allow you to hook into different stages of an element's life. Understanding when and how each callback is triggered is crucial for building robust components.

The Five Lifecycle Callbacks

  • constructor() - Element is created (instantiated)
  • connectedCallback() - Element added to the DOM
  • disconnectedCallback() - Element removed from the DOM
  • attributeChangedCallback() - Observed attribute changed
  • adoptedCallback() - Element moved to new document (rare)

Callback Timing and Flow

// Creation flow: // 1. constructor() - Element instantiated // 2. attributeChangedCallback() - For any attributes set initially // 3. connectedCallback() - When added to DOM const element = document.createElement('my-element'); // constructor() element.setAttribute('value', '42'); // attributeChangedCallback() document.body.appendChild(element); // connectedCallback() // Removal flow: document.body.removeChild(element); // disconnectedCallback() // Attribute change flow (while connected): element.setAttribute('value', '100'); // attributeChangedCallback()

The fourth callback

adoptedCallback is the one nobody mentions, because it fires rarely: only when an element is moved into a different document, such as document.adoptNode() into an iframe. It matters when it happens, because document-scoped things the element captured earlier — observers, stylesheets, references to document — are now pointing at the wrong document.

adoptedCallback(oldDocument, newDocument) { // Re-acquire anything that was scoped to the old document. }

Constructor Rules and Restrictions

The constructor has strict rules defined by the HTML specification. Following these rules prevents errors and ensures consistent behavior.

⚠️ Constructor Restrictions

  • Must call super() first
  • Cannot access attributes or children
  • Cannot add attributes or children
  • Cannot return a value other than this
  • Should only set up initial internal state

Correct Constructor Pattern

class MyElement extends HTMLElement { constructor() { super(); // Must be first! // ✅ Initialize private state this._isConnected = false; this._data = {}; this._value = 0; // ✅ Set up internal references this._handleResize = this._handleResize.bind(this); // ❌ Don't do this in constructor: // this.innerHTML = '...'; // DOM manipulation // this.getAttribute('attr'); // Reading attributes // this.setAttribute('attr', 'val'); // Setting attributes // this.appendChild(child); // Adding children } connectedCallback() { // ✅ Do DOM work here instead this.render(); this.attachEventListeners(); } }

Connected vs Disconnected Callbacks

These callbacks are called when an element is added to or removed from the document. They're your opportunity to set up and tear down resources.

connectedCallback()

Called when element is added to the document:

connectedCallback() { // ✅ Access parent/children this._parent = this.parentElement; // ✅ Set up event listeners this.addEventListener('click', this._handleClick); window.addEventListener('resize', this._handleResize); // ✅ Start intervals/observers this._interval = setInterval(() => { this.update(); }, 1000); this._observer = new ResizeObserver(() => { this.handleResize(); }); this._observer.observe(this); // ✅ Render initial content this.render(); }

disconnectedCallback()

Called when element is removed from the document:

disconnectedCallback() { // ✅ Remove event listeners this.removeEventListener('click', this._handleClick); window.removeEventListener('resize', this._handleResize); // ✅ Clear intervals if (this._interval) { clearInterval(this._interval); this._interval = null; } // ✅ Disconnect observers if (this._observer) { this._observer.disconnect(); this._observer = null; } // ✅ Release resources this._data = null; }
Important: Elements can be connected and disconnected multiple times! Always clean up in disconnectedCallback and handle reconnection gracefully.

Attribute Observation

The attributeChangedCallback is only called for attributes listed in observedAttributes. This improves performance by avoiding unnecessary callbacks.

Observing Attributes

class MyElement extends HTMLElement { // Define which attributes to observe static get observedAttributes() { return ['value', 'disabled', 'theme']; } attributeChangedCallback(name, oldValue, newValue) { // Called when value, disabled, or theme changes console.log(`${name} changed from "${oldValue}" to "${newValue}"`); // Update based on which attribute changed switch(name) { case 'value': this._value = parseFloat(newValue) || 0; this.updateDisplay(); break; case 'disabled': this._disabled = newValue !== null; this.updateState(); break; case 'theme': this.updateTheme(newValue); break; } } // Property setter that reflects to attribute set value(val) { this.setAttribute('value', val); // Triggers attributeChangedCallback } }
⚠️ Avoid Infinite Loops: Don't call setAttribute for the same attribute inside attributeChangedCallback without a guard condition.

Demo 1: Lifecycle Visualization

Watch lifecycle callbacks fire in real-time as you interact with components. This demo logs every callback execution so you can see the exact order and timing.

The shape of a logging element

A sketch of the idea — every callback announces itself. The frame below runs the same pattern across three different elements (auto-clock, resize-tracker, network-status) logging straight to the console.

class LifecycleElement extends HTMLElement { static get observedAttributes() { return ['value']; } constructor() { super(); this.log('constructor'); this._value = 0; } connectedCallback() { this.log('connectedCallback'); this.render(); } disconnectedCallback() { this.log('disconnectedCallback'); } attributeChangedCallback(name, oldValue, newValue) { this.log(`attributeChangedCallback: ${name} = ${newValue}`); } adoptedCallback() { this.log('adoptedCallback'); } log(message) { const event = new CustomEvent('lifecycle-event', { detail: { message, element: this }, bubbles: true }); this.dispatchEvent(event); } render() { this.innerHTML = `<div class="lifecycle-box">Lifecycle Element ${this._value}</div>`; } } customElements.define('lifecycle-element', LifecycleElement);

Live Demo

Buttons drive a real auto-clock element through create, add, move, attribute change, and remove. A resize tracker and a network status indicator can each be toggled on and off the same way. Every callback logs to the console below the frame.

Resource Management Patterns

Proper resource management is critical for preventing memory leaks and ensuring components behave correctly when added, removed, and re-added to the DOM.

Pattern 1: Timer Management

class AutoClock extends HTMLElement { connectedCallback() { // Start timer when connected this._interval = setInterval(() => { this.updateTime(); }, 1000); this.updateTime(); } disconnectedCallback() { // Stop timer when disconnected if (this._interval) { clearInterval(this._interval); this._interval = null; } } updateTime() { const format = this.getAttribute('format') || 'short'; const options = format === 'long' ? { hour: '2-digit', minute: '2-digit', second: '2-digit' } : { hour: '2-digit', minute: '2-digit' }; this.textContent = new Date().toLocaleTimeString('en-US', options); } } customElements.define('auto-clock', AutoClock);

Pattern 2: Observer Management

class ResizeTracker extends HTMLElement { connectedCallback() { // Create and start observer this._observer = new ResizeObserver(entries => { const { width, height } = entries[0].contentRect; this.updateSize(width, height); }); this._observer.observe(this); this.render(); } disconnectedCallback() { // Disconnect observer if (this._observer) { this._observer.disconnect(); this._observer = null; } } updateSize(width, height) { const output = this.querySelector('.size-output'); if (output) { output.textContent = `${Math.round(width)}px × ${Math.round(height)}px`; } } render() { this.innerHTML = ` <div class="resize-box" style="resize: both; overflow: auto; border: 2px solid #ccc; padding: 20px; min-width: 200px; min-height: 100px;"> <div class="size-output">Resize me!</div> </div> `; } } customElements.define('resize-tracker', ResizeTracker);

Pattern 3: Event Listener Management

class NetworkStatus extends HTMLElement { constructor() { super(); // Bind methods in constructor for easy cleanup this._handleOnline = this._handleOnline.bind(this); this._handleOffline = this._handleOffline.bind(this); } connectedCallback() { // Add global event listeners window.addEventListener('online', this._handleOnline); window.addEventListener('offline', this._handleOffline); this.updateStatus(); } disconnectedCallback() { // Remove global event listeners window.removeEventListener('online', this._handleOnline); window.removeEventListener('offline', this._handleOffline); } _handleOnline() { this.updateStatus(); } _handleOffline() { this.updateStatus(); } updateStatus() { const online = navigator.onLine; this.innerHTML = ` <div class="status ${online ? 'online' : 'offline'}"> ${online ? '✓ Online' : '✗ Offline'} </div> `; } } customElements.define('network-status', NetworkStatus);

Live Demos

A clock, a resize tracker, and a network status indicator all run in the frame above. The three samples here are teaching sketches of the same three patterns, not the demo's code: the real file formats its clock differently, puts the resize tracker in a shadow root, and — for the network status — uses the single AbortController shown below rather than the paired removeEventListener calls above. Open the frame's view-source button to compare.

Memory Leak Prevention

Memory leaks occur when components hold references that prevent garbage collection. Always clean up resources in disconnectedCallback.

Common Memory Leak Sources

  • Event listeners on window/document - Use removeEventListener
  • Observers (Mutation, Intersection, Resize) - Call disconnect()
  • Timers (setTimeout, setInterval) - Use clearTimeout or clearInterval
  • External references - Set to null or use WeakMap

Cleanup Checklist Pattern

class WellBehavedElement extends HTMLElement { connectedCallback() { // Set up resources this._interval = setInterval(() => this.update(), 1000); this._observer = new MutationObserver(() => this.handleMutation()); this._observer.observe(document.body, { childList: true }); window.addEventListener('resize', this._handleResize); document.addEventListener('click', this._handleClick); } disconnectedCallback() { // Clean up timers if (this._interval) { clearInterval(this._interval); this._interval = null; } // Disconnect observers if (this._observer) { this._observer.disconnect(); this._observer = null; } // Remove event listeners window.removeEventListener('resize', this._handleResize); document.removeEventListener('click', this._handleClick); // Clear data references this._data = null; this._cache = null; } }

One signal, every listener

The classic component leak is a listener registered on window or document in connectedCallback and never removed: the element goes away, the listener does not, and it holds a reference to the element forever. Removing them one by one means keeping a matching bound reference for each. An AbortController collapses all of that into one signal.

class ViewportWatcher extends HTMLElement { #controller; // Arrow-function class fields are bound to the instance for free — no // bind() in the constructor, and the same reference on every connect. #onResize = () => this.#update(); #onVisibility = () => this.#update(); connectedCallback() { // A fresh controller each time: an element can reconnect. this.#controller = new AbortController(); const { signal } = this.#controller; window.addEventListener('resize', this.#onResize, { signal }); document.addEventListener('visibilitychange', this.#onVisibility, { signal }); this.#update(); } disconnectedCallback() { this.#controller.abort(); // removes every listener above at once } #update() { this.textContent = `${window.innerWidth}px · ${document.visibilityState}`; } } customElements.define('viewport-watcher', ViewportWatcher);

connectedCallback can fire more than once

Moving an element in the DOM disconnects and reconnects it. Anything connectedCallback sets up must be safe to set up again — which is another argument for a fresh AbortController each time rather than one created in the constructor.

Advanced Lifecycle Patterns

Pattern 1: Lazy Initialization

Defer expensive setup until the element is actually connected to the DOM:

class LazyComponent extends HTMLElement { connectedCallback() { // Only create template once if (!this._template) { this._template = this.createExpensiveTemplate(); } // Use the cached template this.appendChild(this._template.cloneNode(true)); } createExpensiveTemplate() { const template = document.createElement('template'); // ... expensive DOM creation return template.content; } }

Pattern 2: Handling Reconnection

Components can be removed and re-added. Handle this gracefully:

class ReconnectableComponent extends HTMLElement { connectedCallback() { // Do expensive setup only once if (!this._initialized) { this.expensiveSetup(); this._initialized = true; } // Do this every time we connect this.start(); } disconnectedCallback() { // Stop but don't destroy state this.stop(); // Don't reset _initialized } expensiveSetup() { // Heavy lifting (load data, create structures, etc.) } start() { // Start timers, observers, etc. } stop() { // Stop timers, observers, etc. } }

Pattern 3: Debouncing Attribute Changes

Avoid expensive re-renders when attributes change rapidly:

class DebouncedComponent extends HTMLElement { static get observedAttributes() { return ['value', 'size', 'theme']; } attributeChangedCallback(name, oldValue, newValue) { // Clear previous timeout if (this._updateTimeout) { clearTimeout(this._updateTimeout); } // Batch updates this._pendingChanges = this._pendingChanges || {}; this._pendingChanges[name] = newValue; // Debounce the actual update this._updateTimeout = setTimeout(() => { this.update(this._pendingChanges); this._pendingChanges = {}; }, 100); } update(changes) { // Process all changes at once console.log('Updating with:', changes); this.render(); } }

Demo 2: Attribute Change Handling

Explore how components respond to attribute changes throughout their lifecycle. Try changing different attributes to see how the component updates.

Live Demo

Best Practices

Key Principles

  • Keep constructor minimal - Only initialize internal state
  • Check connection state - Use this.isConnected before DOM operations
  • Handle multiple connections - Elements can be moved and reconnected
  • Clean up everything - Always remove listeners, stop timers, disconnect observers
  • Batch updates - Debounce multiple attribute changes to improve performance
  • Use guards - Check for null/undefined before cleanup operations

Common Pitfalls

❌ Common Mistakes

class BadElement extends HTMLElement { constructor() { super(); // ❌ DOM manipulation in constructor this.innerHTML = '<div>Bad!</div>'; // ❌ Reading attributes this.value = this.getAttribute('value'); } connectedCallback() { // ❌ Forgetting to bind window.addEventListener('resize', this.handleResize); } disconnectedCallback() { // ❌ No cleanup - memory leak! } attributeChangedCallback(name, old, val) { // ❌ Infinite loop this.setAttribute(name, val.toUpperCase()); } }

✅ Correct Approach

class GoodElement extends HTMLElement { constructor() { super(); // ✅ Only initialize state this._value = 0; // ✅ Bind methods this.handleResize = this.handleResize.bind(this); } connectedCallback() { // ✅ DOM work in connectedCallback this.render(); // ✅ Store listener reference window.addEventListener('resize', this.handleResize); } disconnectedCallback() { // ✅ Clean up properly window.removeEventListener('resize', this.handleResize); } attributeChangedCallback(name, old, val) { // ✅ Update internal state, don't set attribute if (old !== val) { this._value = val; this.render(); } } }

Next Steps

In Step 6: Slots and templates, we'll explore:

  • <template> as inert markup, and why you clone it rather than move it
  • Named slots, the default slot, and fallback content
  • Reacting to assignment changes with slotchange
  • Composition versus configuration — when an input is a slot and when it is an attribute