JavaScript Events and Event Delegation: addEventListener, Bubbling, and Delegation
Almost every interactive web page works the same way: the user does something (click, type, scroll, hover), the browser fires an event, and JavaScript responds. Knowing how the event system actually works — bubbling, capturing, delegation, propagation control — separates fragile UI code from robust UI code.
This lesson covers the modern event API: addEventListener, the propagation model, event delegation (the pattern that single-handedly replaced 90% of per-element handlers), and the methods you'll actually call inside an event handler.
The event listener API #
addEventListener is the one and only API you should use in modern code.
button.addEventListener('click', (event) => {
console.log('Clicked!', event);
});
Three arguments:
- Event type — a string.
'click','input','submit','keydown','mouseenter','scroll', … - Handler — a function. Called with an event object.
- Options (optional) — an object with
{ once, passive, capture, signal }flags.
Removing a listener #
You need a reference to the same function you added:
function handleClick() { /* ... */ }
button.addEventListener('click', handleClick);
button.removeEventListener('click', handleClick);
If you passed an anonymous arrow, you can't remove it. This is why named handlers matter.
The modern alternative: AbortSignal #
A cleaner pattern for groups of listeners with a shared lifetime:
const controller = new AbortController();
button.addEventListener('click', handleClick, { signal: controller.signal });
window.addEventListener('scroll', handleScroll, { signal: controller.signal });
input.addEventListener('input', handleInput, { signal: controller.signal });
// Later — remove all three at once
controller.abort();
Indispensable for component cleanup, route changes, anywhere you'd otherwise have to track and remove handlers individually.
One-shot listeners #
dialog.addEventListener('close', cleanup, { once: true });
Automatically removed after the first fire. Saves a manual removeEventListener call.
What's in the event object #
Every handler receives an event object. The most useful properties:
| Property | What it is |
|---|---|
event.type |
The event name ('click', 'keydown', …) |
event.target |
The element that originated the event |
event.currentTarget |
The element the listener is on |
event.preventDefault() |
Cancels the default browser action (form submit, link navigation) |
event.stopPropagation() |
Stops the event from bubbling further |
event.key / event.code |
For keyboard events |
event.clientX / event.clientY |
For mouse/pointer events |
target vs currentTarget is the most common confusion. If you click a <span> inside a <button> that has the listener: target is the <span>, currentTarget is the <button>.
Propagation: bubbling and capturing #
When the user clicks an element, the event fires on that element and on every ancestor up to document. Three phases:
- Capture phase — event travels DOWN from
documentto the target. - Target phase — fires on the actual clicked element.
- Bubble phase — event travels UP from the target back to
document.
By default, listeners run during the bubble phase. Add { capture: true } to listen during capture.
document
└── body ← bubble phase listener fires here, third
└── div ← bubble phase listener fires here, second
└── button ← target, fires here first
This is the foundation of event delegation.
Stopping propagation #
button.addEventListener('click', (e) => {
e.stopPropagation(); // event stops here; no ancestors hear it
});
stopPropagation is useful but easy to overuse. Stopping events globally breaks delegation higher up and confuses analytics. Use it deliberately — and prefer not to.
Event delegation: one handler for many elements #
The pattern that makes large UIs maintainable. Instead of attaching a listener to every list item, attach one to the parent and inspect event.target.
<ul id="todos">
<li><button data-id="1">Delete</button> Buy milk</li>
<li><button data-id="2">Delete</button> Walk dog</li>
<li><button data-id="3">Delete</button> Read</li>
</ul>
// One listener for all delete buttons — including ones added later
document.getElementById('todos').addEventListener('click', (e) => {
const btn = e.target.closest('button[data-id]');
if (!btn) return;
deleteTodo(btn.dataset.id);
});
Why this is better than per-button listeners:
- One listener instead of N — less memory, fewer code paths to test
- Works for elements added later — the listener is on the parent that already exists
- Easier to manage — one place to wire up, one place to remove
The closest('button[data-id]') call walks up from e.target looking for the matching button — so clicks on icons or text inside the button still find their way to the right element.
Common event types you'll handle #
Mouse / pointer #
el.addEventListener('click', handler); // click (mouse, touch, keyboard activation)
el.addEventListener('dblclick', handler); // double-click
el.addEventListener('mouseenter', handler); // pointer enters element
el.addEventListener('mouseleave', handler); // pointer leaves
el.addEventListener('mousemove', handler); // pointer moves over element
For most touch-and-mouse-and-stylus support, prefer pointer events:
el.addEventListener('pointerdown', handler);
el.addEventListener('pointermove', handler);
el.addEventListener('pointerup', handler);
Pointer events unify mouse, touch, and pen into one API.
Keyboard #
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') submit();
if (e.key === 'Escape') cancel();
if (e.ctrlKey && e.key === 's') {
e.preventDefault();
save();
}
});
event.key gives you the logical key ('a', 'Enter', 'Escape', 'ArrowLeft'). event.code gives you the physical key ('KeyA', 'Enter', 'Escape'). Use key for shortcuts and text-like input; use code for game controls or keyboard layouts where physical position matters.
Form events #
form.addEventListener('submit', (e) => {
e.preventDefault(); // stop the browser from navigating
saveForm(new FormData(form));
});
input.addEventListener('input', (e) => {
// Fires on every change to the value (typing, paste, autocomplete)
validate(e.target.value);
});
select.addEventListener('change', (e) => {
// Fires when the user commits a selection (blur or pressing enter)
updateOptions(e.target.value);
});
We cover forms in depth in Lesson 7.3.
Window / document #
window.addEventListener('resize', handler);
window.addEventListener('scroll', handler, { passive: true });
window.addEventListener('beforeunload', handler);
document.addEventListener('visibilitychange', handler);
The { passive: true } flag on scroll listeners tells the browser you won't call preventDefault(), which lets it skip a costly synchronous check on every scroll event. Always pass passive: true for scroll/touchmove unless you specifically need to cancel.
preventDefault: when the browser would do something #
Many events have a default browser behavior:
'submit'on a form → navigate to the form's action'click'on an<a>→ navigate to the href'keydown'of Ctrl+S → open browser save dialog'contextmenu'→ show the right-click menu
preventDefault() stops it. This is how SPAs handle navigation — the framework intercepts link clicks via delegation, calls preventDefault, and routes within the app.
document.body.addEventListener('click', (e) => {
const link = e.target.closest('a[data-spa-route]');
if (!link) return;
e.preventDefault();
router.navigate(link.href);
});
Custom events #
You can fire and listen for your own events:
button.addEventListener('userLoggedIn', (e) => {
console.log('User:', e.detail);
});
button.dispatchEvent(new CustomEvent('userLoggedIn', { detail: { id: 42 } }));
Useful for decoupling — different parts of an app can communicate without direct references. Frameworks usually provide their own event/state mechanisms; custom DOM events are mainly used for components that need to talk to other code without coupling.
The handler-this trap #
If you pass a regular function as a handler, this inside it is the element. With an arrow function, this is inherited from the surrounding scope (usually the module or class).
button.addEventListener('click', function () {
console.log(this); // the button
});
button.addEventListener('click', () => {
console.log(this); // undefined (or window in sloppy mode)
});
In class components, arrow handlers are often what you want — they give you access to the class instance via this. The element is always available via event.currentTarget.
A summary of best practices #
addEventListeneralways. Never useel.onclick = ...(only one handler can exist that way).- Delegate to a parent when you have a list of similar elements.
- Use
closest+datasetto find the right delegated target. { once: true }for one-shot listeners.AbortControllerfor groups.{ passive: true }for scroll and touchmove handlers.preventDefaultandstopPropagationsparingly — they have global effects.- Prefer named handlers so you can remove them if needed.
Most UI bugs in production code are events doing one too many things or not propagating correctly. Internalize delegation and propagation control, and the next 90% of UI events become trivial.
What's next #
Lesson 7.3 covers forms — collecting user input, the constraint validation API, FormData, and the patterns that turn HTML forms into clean JSON payloads ready for an API.
Try it yourself #
The bubbling-vs-target distinction is the easiest one to feel:
<button><span>Click me</span></button>JS:
button.addEventListener('click', e => {
console.log('target:', e.target.tagName);
console.log('currentTarget:', e.currentTarget.tagName);
});Click the text. What logs?
browser_sandboxOutput:target: SPANcurrentTarget: BUTTONtarget is what the user actually clicked — the span. currentTarget is where the listener is — the button. When you delegate, you almost always care about currentTarget (the parent that owns the listener) and use target.closest() to find the specific item the user meant to interact with.That's the entire event system in one experiment. Master this distinction and delegation stops being a mystery.
Up next in JavaScript
More from this topic
Enjoyed this article?
Get new JavaScript tutorials delivered. No spam — just code-first articles when they ship.


