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