Events and Event Delegation
How a click travels through the DOM, why one listener can serve a thousand elements, and the keyboard events that inline handlers always forget.
-
HTML Basics
- What is HTML: The Structure Layer of Every Web Page
- HTML Document Structure: DOCTYPE, html, head and body
- Elements, Tags and Attributes: The Vocabulary of HTML
- HTML Comments: Notes That Ship With Your Code
- Block Level and Inline Elements
- Writing and Running Your First HTML Page
- How a Browser Turns Markup Into a Page
- Text and Formatting
- Links and Navigation
- Images and Media
- Lists
- Tables
-
Forms
- Form Structure: form, action and method
- Input Types: Text, Email, Number, Date and the Rest
- Labels: The Most Important Element in a Form
- Checkboxes, Radio Buttons and Grouping
- select, option, optgroup and datalist
- textarea, File Uploads and Hidden Fields
- Buttons: submit, reset and button
- Built In Form Validation
- GET or POST: What Happens When a Form Is Submitted
- Semantic HTML
- HTML5 Features
- Head and Metadata
- HTML with CSS
- HTML with JavaScript
- Accessibility
-
HTML SEO
- How Google Works: Crawling, Indexing and Ranking
- SEO Friendly HTML Structure
- Titles and Descriptions That Earn Clicks
- Headings and Content Structure for Search
- Internal Linking and Anchor Text
- robots.txt and XML Sitemaps
- Canonical URLs and Duplicate Content
- Structured Data and JSON-LD
- Image SEO
- Core Web Vitals and Mobile Friendliness
- DevTools and Debugging
- Editor Productivity
- HTML Best Practices
- HTML Projects
- Advanced Projects
- Practice and Exams
Concept
An event is something that happens - a click, a key press, a form submission, an image finishing loading. A listener is a function that runs when it does.
Attaching a listener
button.addEventListener("click", (event) => {
console.log("clicked", event.target);
});
// with options
el.addEventListener("scroll", onScroll, { passive: true });
el.addEventListener("click", once, { once: true });
el.addEventListener("click", handler, { capture: true });
// removing needs the same function reference
el.removeEventListener("click", handler);Not inline handlers
<!-- avoid -->
<button onclick="save()">Save</button>Inline handlers mix behaviour into markup, allow only one handler per event, need the function to be globally available, and are blocked by any reasonable Content Security Policy. addEventListener has none of those limits.
How an event travels
Three phases, and knowing them explains most surprising event behaviour.
1. Capture document -> html -> body -> div -> button
2. Target the button itself
3. Bubble button -> div -> body -> html -> documentListeners run in the bubble phase by default, which means a click on a button also reaches every ancestor. That is what makes delegation possible.
event.target // where the event actually started
event.currentTarget // the element this listener is attached to
event.stopPropagation(); // stop it travelling further
event.preventDefault(); // cancel the default browser behaviourThe distinction between target and currentTarget is the one to internalise. Click an icon inside a button and target is the icon; currentTarget is the button.
Event delegation
Instead of attaching a listener to every item, attach one to their container and work out what was clicked.
<ul id="courses">
<li><button type="button" data-action="remove" data-id="1">Remove</button></li>
<li><button type="button" data-action="remove" data-id="2">Remove</button></li>
<!-- hundreds more -->
</ul>// one listener, however many buttons
document.getElementById("courses").addEventListener("click", (event) => {
const button = event.target.closest("[data-action='remove']");
if (!button) return;
removeCourse(button.dataset.id);
});Three benefits, and the third is the important one:
- One listener instead of hundreds, so less memory.
- No setup work when the list is rendered.
- It works for elements added later. A listener attached to an element that did not exist yet is the most common reason dynamic content stops responding.
closest is what makes it robust: a click on an icon inside the button still resolves to the button.
The events worth knowing
| Event | Fires when |
|---|---|
click | Activated by mouse, touch, Enter or Space on a real button |
input | A field value changes, on every keystroke |
change | A field value is committed - on blur, or immediately for checkboxes and selects |
submit | A form is submitted |
focus / blur | An element gains or loses focus. Does not bubble. |
focusin / focusout | The same, but bubbles - use these for delegation |
keydown / keyup | A key is pressed or released |
scroll | Something scrolls. Fires very often. |
pointerdown / pointerup | Mouse, touch and pen unified |
Why click is enough on a real button
// a real button: click fires for mouse, touch, Enter and Space
button.addEventListener("click", save);
// a div pretending to be a button: needs all of this
div.addEventListener("click", save);
div.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
save();
}
});
div.tabIndex = 0;
div.setAttribute("role", "button");And even then the div still lacks disabled state handling, correct focus styling and form participation. This is the concrete argument for using the right element.
Keyboard events
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") closeDialog();
if (event.key === "/" && !isTyping(event)) focusSearch();
if (event.key === "ArrowDown") moveSelection(1);
if (event.ctrlKey && event.key === "k") openCommandPalette();
});Use event.key, which gives the character or a name such as Escape, Enter, Tab, ArrowUp. The old keyCode is deprecated and layout dependent.
Debouncing and throttling
Some events fire far more often than any handler needs.
// debounce: run once the activity stops
function debounce(fn, delay = 300) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
searchInput.addEventListener("input", debounce((event) => {
fetchResults(event.target.value);
}, 300));// scroll: mark it passive so it never blocks scrolling
window.addEventListener("scroll", () => {
requestAnimationFrame(updateHeader);
}, { passive: true });{ passive: true } promises the handler will not call preventDefault, which lets the browser scroll without waiting for it. On touch devices the difference is immediately noticeable.
Custom events
el.dispatchEvent(new CustomEvent("course:selected", {
detail: { id: 1043 },
bubbles: true,
}));
document.addEventListener("course:selected", (event) => {
console.log(event.detail.id);
});A clean way for parts of a page to communicate without holding references to each other.
Important rules
- Events bubble by default;
focus,blurand a few others do not. event.targetis where it started;event.currentTargetis where the listener is.preventDefaultcancels the browser behaviour;stopPropagationstops the travel. They are unrelated.- A listener added before an element exists never fires for it.
- Removing a listener requires the same function reference, so an inline arrow function cannot be removed.
clickon a real button covers mouse, touch and keyboard.
Common mistakes
- Attaching listeners to elements that will be replaced, then losing them.
- Using
event.targetwherecurrentTargetis meant, and matching a child element. - Calling
stopPropagationeverywhere and breaking delegation elsewhere on the page. - Click only handlers on non button elements.
- Heavy work in a scroll or input handler with no throttling.
- Using
keyCode. - Inline
onclickattributes.
Best practices
- Delegate to a container for lists and repeated controls.
- Use
closestto resolve the intended target. - Use real interactive elements so keyboard support is free.
- Debounce input handlers, throttle scroll handlers, and mark scroll listeners passive.
- Prefer
event.keyand named keys. - Use
{ once: true }for one time handlers. - Avoid
stopPropagationunless you specifically need it.
Practice
- Attach a listener to fifty buttons individually, then replace it with one delegated listener and add a new button dynamically.
- Log
targetandcurrentTargetfor a click on an icon inside a button. - Build a search box that fetches results only after typing stops for three hundred milliseconds.
- Make a
divbehave like a button, then list what still does not work.