Custom Element Lifecycle — Complete Guide
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
- 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.
- Forgetting to clean up in disconnectedCallback — Event listeners, observers, and timers continue running after element removal, causing memory leaks.
- Rendering in attributeChangedCallback without checking isConnected — attributeChangedCallback can fire before connectedCallback. Check
this.isConnectedbefore rendering. - Not tracking resources for cleanup — Use arrays to track observers, listeners, and timers so you can clean them up systematically in disconnectedCallback.
- Assuming connectedCallback fires only once — Moving an element in the DOM triggers connectedCallback again. Guard against double initialization with a flag.
Practice Questions
- What are the five lifecycle callbacks? constructor, connectedCallback, disconnectedCallback, attributeChangedCallback, adoptedCallback.
- 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.
- How do you watch for attribute changes? Implement
static get observedAttributes()returning an array of attribute names, and implementattributeChangedCallback(name, oldValue, newValue). - Challenge: Create a
<countdown-timer>element that acceptssecondsandauto-startattributes. It counts down from the specified seconds. Pauses when removed from DOM, resumes when added back. Fires atimer-completeevent when reaching zero.
FAQ
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