JavaScript and TypeScript course ยท Module 8: Events and the DOM
Building Interactive Components
In this lesson4
In Jurassic Park's control center, every panel on the screen is a separate component - a dinosaur card, an alert window, a report form. Each responds to operator actions: clicks, keys, mouse hover. Building such interactive components is one of the most common use cases for event listeners in real applications.
In this lesson, you'll learn to create self-contained, reusable UI components that manage their own events - from modal windows to panels with dynamic content.
The problem shows up when a component disappears. A panel closed by the operator should take its sensors with it, otherwise after an hour of work the control center is listening to hundreds of windows that nobody can see anymore.
Component: Modal Window
A modal is a window displayed on top of the page, blocking interaction with the rest of the interface. In our park, we'll use it to display dinosaur details after clicking its card.
Creating the Structure and Registering Events
The createDinosaurModal() function takes the dinosaur data and builds a ready element out of it. It starts with the HTML structure.
function createDinosaurModal(dinosaurData) {
// Creating the modal structure
const modal = document.createElement('div');
modal.className = 'dinosaur-modal';
modal.innerHTML = `
<div class="modal-content">
<div class="modal-header">
<h2>${dinosaurData.name}</h2>
<button class="close-btn">×</button>
</div>
<div class="modal-body">
<img src="${dinosaurData.image}" alt="${dinosaurData.name}">
<p><strong>Era:</strong> ${dinosaurData.era}</p>
<p><strong>Diet:</strong> ${dinosaurData.diet}</p>
<p><strong>Length:</strong> ${dinosaurData.length}m</p>
<p><strong>Weight:</strong> ${dinosaurData.weight}kg</p>
<p>${dinosaurData.description}</p>
</div>
<div class="modal-footer">
<button class="book-tour-btn">Book a Tour</button>
<button class="add-favorite-btn">Add to Favorites</button>
</div>
</div>
`;The element is created in memory and isn't on the page yet. We fill the template with data from the lab database, but if it came from users, it would be safer to insert it with textContent, because innerHTML interprets every tag. Now the first listeners.
// Get interactive elements from the modal
const closeBtn = modal.querySelector('.close-btn');
const bookTourBtn = modal.querySelector('.book-tour-btn');
const addFavoriteBtn = modal.querySelector('.add-favorite-btn');
// Close modal with X button
closeBtn.addEventListener('click', () => {
closeModal(modal);
});
// Close by clicking the background (overlay)
modal.addEventListener('click', (e) => {
if (e.target === modal) {
closeModal(modal);
}
});Calling modal.querySelector() searches only inside this modal, so it works even before the modal is inserted into the page. A click on the content bubbles up to the listener on modal, but then e.target points to an inner element. The e.target === modal condition is true only for a click on the background itself. What's left are the actions and the keyboard.
// Book a tour
bookTourBtn.addEventListener('click', () => {
openBookingForm(dinosaurData.id);
});
// Add to favorites with button state change
addFavoriteBtn.addEventListener('click', () => {
addToFavorites(dinosaurData.id);
addFavoriteBtn.textContent = 'Added to favorites!';
addFavoriteBtn.disabled = true;
});
// Close with Escape key
const handleEscape = (e) => {
if (e.key === 'Escape') {
closeModal(modal);
document.removeEventListener('keydown', handleEscape);
}
};
document.addEventListener('keydown', handleEscape);
return modal;
}
function closeModal(modal) {
modal.classList.add('closing'); // Closing animation
setTimeout(() => modal.remove(), 300);
}The function returns a ready modal, and the caller inserts it into the page, for example with document.body.appendChild(). The closeModal() function adds an animation class and removes the element after 300 milliseconds.
Notice several key practices:
- Closing on Escape - we add a listener on
documentand remove it after closing with the Escape key (with other ways of closing it has to be removed separately) - Clicking the background - we check
e.target === modalto distinguish a click on the overlay from a click on the modal content - Button state change - after adding to favorites, we disable the button to prevent multiple clicks
Find the weak spot: if the operator closes the modal with the X button or by clicking the background, the handleEscape listener stays on document. It keeps the removed modal in its closure, which is exactly the leak we talked about in the cleanup lesson. You'll see the fix at the end of this lesson.
Component: Toggle Switch
A simple component for turning park systems on and off. It takes a label and an onChange function, which it calls after every change of state:
function createToggle(label, onChange) {
const container = document.createElement('div');
container.className = 'toggle-container';
container.innerHTML = `
<span class="toggle-label">${label}</span>
<button class="toggle-btn" data-active="false">OFF</button>
`;
const btn = container.querySelector('.toggle-btn');
btn.addEventListener('click', () => {
const isActive = btn.dataset.active === 'true';
const newState = !isActive;
btn.dataset.active = String(newState);
btn.textContent = newState ? 'ON' : 'OFF';
btn.classList.toggle('active', newState);
// Call callback with new state
onChange(newState);
});
return container;
}
// Usage - fence control panel
const fenceToggle = createToggle('Electric Fence', (active) => {
console.log(`Fence ${active ? 'ACTIVE' : 'DISABLED'}`);
});
document.getElementById('controls').appendChild(fenceToggle);data-* attributes store text only, which is why we compare with 'true' and save String(newState). The second argument of classList.toggle() forces the state of the class instead of flipping it. The switch doesn't know that it controls the fence, it only reports the change through the callback, so the same component can handle gates and lighting.
Component: Tabs
Tabs are an essential element of the monitoring panel, allowing switching between views of different park zones. First, we create a container with two parts:
function createTabs(tabsData) {
const container = document.createElement('div');
container.className = 'tabs-container';
// Tab headers
const header = document.createElement('div');
header.className = 'tabs-header';
// Tab content
const body = document.createElement('div');
body.className = 'tabs-body';The header will collect the buttons, and the body the panels with content. In the loop, every tab gets a button, a panel, and its own listener.
tabsData.forEach((tab, index) => {
// Tab button
const tabBtn = document.createElement('button');
tabBtn.className = 'tab-btn' + (index === 0 ? ' active' : '');
tabBtn.textContent = tab.title;
tabBtn.dataset.index = index;
// Content panel
const tabPanel = document.createElement('div');
tabPanel.className = 'tab-panel' + (index === 0 ? ' active' : '');
tabPanel.innerHTML = tab.content;
// Click on tab
tabBtn.addEventListener('click', () => {
// Deactivate all
header.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
body.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
// Activate selected
tabBtn.classList.add('active');
tabPanel.classList.add('active');
});
header.appendChild(tabBtn);
body.appendChild(tabPanel);
});
container.appendChild(header);
container.appendChild(body);
return container;
}Each listener remembers its own tabBtn and tabPanel pair in a closure, so we don't need any IDs. The first tab starts active thanks to the index === 0 condition. With a large number of tabs, you can replace these listeners with a single one on the header, as in the lesson on delegation. Now the usage:
// Usage
const monitorTabs = createTabs([
{ title: 'Zone A', content: '<p>T-Rex: status OK</p>' },
{ title: 'Zone B', content: '<p>Raptors: active</p>' },
{ title: 'Zone C', content: '<p>Brachiosaurus: calm</p>' },
]);The function only returns a ready container, so monitorTabs still has to be inserted into the page with appendChild().
Pattern: Component Cleanup
Every component should have a destroy() method that removes all event listeners and prevents memory leaks:
class ParkWidget {
constructor(element) {
this.element = element;
this.handlers = [];
this.init();
}
addListener(target, event, handler) {
target.addEventListener(event, handler);
this.handlers.push({ target, event, handler });
}
destroy() {
// Remove all registered listeners
this.handlers.forEach(({ target, event, handler }) => {
target.removeEventListener(event, handler);
});
this.handlers = [];
this.element.remove();
}
init() {
this.addListener(this.element, 'click', (e) => {
console.log('Widget clicked');
});
this.addListener(document, 'keydown', (e) => {
if (e.key === 'Escape') this.destroy();
});
}
}Storing references to all listeners and collectively removing them in destroy() is the best practice when building components - especially important in dynamic applications where components are frequently created and destroyed.
The addListener() method records every triple: the target, the type, and the function, so destroy() can also remove the listener from document. That's the fix for the modal's weak spot. The full life cycle of a component has four stages: creating the element with createElement, adding it to the DOM with appendChild, registering the listeners for closing and Escape, and finally destroy(), which removes the listeners and the element itself.
My advice: instead of an array, you can use one AbortController per component and pass its signal to every listener. Then destroy() comes down to abort() and removing the element. In the sandbox below you'll open and close a dinosaur modal. In the next lesson we'll gather the best practices of event handling into one list.
Remember: a component is like a paddock in the park that you open together with its sensors and close only once you've switched off the last of them.
Code for this lesson: index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Creating Interactive Components</title>
<style>
body { font-family: Arial, sans-serif; background: linear-gradient(135deg, #2c3e50, #34495e); color: white; padding: 20px; }
.modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); z-index: 1000; }
.modal.show { display: flex; align-items: center; justify-content: center; }
.modal-content { background: #34495e; padding: 30px; border-radius: 15px; max-width: 500px; width: 90%; }
.close-btn { float: right; font-size: 24px; cursor: pointer; }
button { padding: 12px 24px; background: #3498db; color: white; border: none; border-radius: 6px; cursor: pointer; margin: 10px; }
button:hover { background: #2980b9; }
</style>
</head>
<body>
<h1>Interactive Components</h1>
<button id="open-modal">Open Dinosaur Modal</button>
<div id="modal" class="modal">
<div class="modal-content">
<span class="close-btn">×</span>
<h2>T-Rex</h2>
<p>Tyrannosaurus Rex - king of dinosaurs!</p>
<button id="like-btn">Like</button>
</div>
</div>
<script>
const modal = document.getElementById('modal');
const openBtn = document.getElementById('open-modal');
const closeBtn = document.querySelector('.close-btn');
function openModal() {
modal.classList.add('show');
console.log('Modal opened');
}
function closeModal() {
modal.classList.remove('show');
console.log('Modal closed');
}
openBtn.addEventListener('click', openModal);
closeBtn.addEventListener('click', closeModal);
modal.addEventListener('click', (e) => {
if (e.target === modal) closeModal();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeModal();
});
document.getElementById('like-btn').addEventListener('click', () => {
alert('You liked T-Rex!');
});
console.log('Modal system ready');
</script>
</body>
</html>Docs entries used in this lesson
Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. In a modal component, how do you tell a click on the backdrop (overlay) from a click on the content?
2. Why should a component remove its event listeners in the
destroy()method?
Hands-on tasks in the game
- Vertical ordering
Order the stages of a modal component's lifecycle (top to bottom):