JavaScript Practice #2 Event Handling and Delegation
In #1 DOM Manipulation Basics you saw the tools for working with elements; this post is about handling user interaction with them.
Registering events — addEventListener
#
const btn = document.querySelector('#submit');
btn.addEventListener('click', (e) => {
console.log('clicked');
});addEventListener('type', handler) — the modern JavaScript standard.
In old code you’ll see direct assignment like btn.onclick = () => {...} — but you can register only one handler per element that way. addEventListener allows multiple handlers for the same event.
Common event types #
click mouse click
input input field value change (live)
change value change (on blur)
submit form submit
keydown/keyup keypress
focus/blur gaining/losing focus
mouseenter mouse enter
mouseleave mouse leave
scroll scroll
load load complete
DOMContentLoaded HTML parsing completeThe event object — e
#
The argument the handler receives is the event object. Here are the common properties.
btn.addEventListener('click', (e) => {
e.target; // the element where the event started
e.currentTarget; // the element on which the handler was registered
e.type; // 'click'
e.preventDefault(); // block the default behavior
e.stopPropagation(); // stop bubbling
});e.target vs e.currentTarget
#
The most confusing part.
<button class="card">
<span>click me</span>
</button>btn.addEventListener('click', (e) => {
console.log(e.target); // <span> (the actually clicked element)
console.log(e.currentTarget); // <button> (where the handler is registered)
});target— the element where the event originated (innermost click target)currentTarget— the element where the handler is registered
In the event-delegation pattern, the difference matters — more on that coming up.
Event flow — capturing and bubbling #
When an event fires, it propagates in two stages.
1. Capturing — document → event location, top→down
2. Target — arrives at the location
3. Bubbling — location → document, bottom→upBy default, handlers run in the bubbling phase.
<div id="outer">
<div id="inner">
<button id="btn">click</button>
</div>
</div>document.querySelector('#outer').addEventListener('click', () => console.log('outer'));
document.querySelector('#inner').addEventListener('click', () => console.log('inner'));
document.querySelector('#btn').addEventListener('click', () => console.log('button'));
// On click of the button:
// button
// inner
// outer
The event bubbles up button → inner → outer, running each handler in turn. That’s bubbling.
Listening on capture #
document.querySelector('#outer').addEventListener(
'click',
() => console.log('outer (capture)'),
{ capture: true }
);Pass { capture: true } as the third argument to run on capture. Rarely used — but useful when “the outer must handle first.”
stopPropagation and preventDefault
#
stopPropagation — halt propagation
#
button.addEventListener('click', (e) => {
e.stopPropagation(); // doesn't go further up
});If the handler above calls stopPropagation, the outer/inner handlers don’t run.
preventDefault — block the default
#
form.addEventListener('submit', (e) => {
e.preventDefault(); // form doesn't reload the page
// ... handle manually
});
link.addEventListener('click', (e) => {
e.preventDefault(); // link doesn't navigate
});Blocks the browser’s default behavior. Common cases include form submission, link navigation, and right-click menus.
The two methods serve different purposes. stopPropagation controls JavaScript handler propagation; preventDefault blocks browser default behavior. Keep them distinct so you don’t mix them up.
Event delegation — efficient pattern #
Suppose a list has 100 items and each needs a click handler:
Bad pattern — handler per item #
document.querySelectorAll('.item').forEach((item) => {
item.addEventListener('click', (e) => {
console.log('clicked:', item.dataset.id);
});
});100 handlers in memory. New items added dynamically have no handler.
Good pattern — one handler on the parent #
const list = document.querySelector('.list');
list.addEventListener('click', (e) => {
const item = e.target.closest('.item');
if (!item || !list.contains(item)) return;
console.log('clicked:', item.dataset.id);
});Just one handler. On click, the event bubbles up to the parent, and closest checks which item was clicked.
Benefits of delegation #
- Memory — one handler
- Auto-handles dynamic items — items added later work with the same handler
- Concise — no register/unregister as the list changes
In lists, tables, card grids — almost the standard pattern.
Removing handlers — removeEventListener
#
function onClick() {
console.log('click');
}
btn.addEventListener('click', onClick);
// later
btn.removeEventListener('click', onClick);The handler reference must match to remove. An inline arrow can’t be removed.
btn.addEventListener('click', () => console.log('click'));
btn.removeEventListener('click', () => console.log('click')); // ✗ different function
AbortController — modern handler cleanup
#
The AbortController from Intermediate #6 fetch API also helps with event cleanup.
const controller = new AbortController();
btn.addEventListener('click', onClick, { signal: controller.signal });
input.addEventListener('input', onInput, { signal: controller.signal });
window.addEventListener('resize', onResize, { signal: controller.signal });
// remove all at once
controller.abort();Clean up multiple handlers in one shot. Fits component cleanup nicely.
Run once — { once: true }
#
btn.addEventListener('click', () => {
console.log('first and last');
}, { once: true });It removes itself automatically. Very clean for one-shot handlers.
Passive events — scroll performance #
For events like scroll, touchmove, registering as passive is recommended.
window.addEventListener('scroll', onScroll, { passive: true });A passive handler promises not to call preventDefault. Given that promise, the browser can scroll faster.
Common form/input patterns #
input vs change
#
input.addEventListener('input', (e) => {
// every keypress
console.log(e.target.value);
});
input.addEventListener('change', (e) => {
// when input ends and focus is lost (or on select change)
console.log(e.target.value);
});For live processing like search autocomplete — input. For heavier work like validation/saving — change or debounced input.
Keyboard events #
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') submit();
if (e.key === 'Escape') close();
if (e.ctrlKey && e.key === 's') {
e.preventDefault();
save();
}
});e.key is the modern standard. The old e.keyCode is deprecated.
Custom events #
You can create and dispatch your own events.
const event = new CustomEvent('user-login', {
detail: { id: 'u1', name: 'Curtis' },
});
document.dispatchEvent(event);
// elsewhere
document.addEventListener('user-login', (e) => {
console.log(e.detail); // { id: 'u1', name: 'Curtis' }
});Useful for cross-module communication in large apps, but overuse makes the event flow hard to trace — use it sparingly.
Wrap-up #
What we covered:
addEventListeneris the modern standarde.target(origin) vse.currentTarget(registered location)- Event flow: capturing → target → bubbling (default is bubbling)
stopPropagationfor propagation,preventDefaultfor defaults- Event delegation — register once on the parent, locate with
closest removeEventListenerneeds the same function referenceAbortControllercleans many handlers at once{ once: true },{ passive: true }optionsinputis live,changeis on completione.keyis the modern keyboard standard
In the next post (#3 fetch and async UI) we cover patterns for receiving data with fetch and reflecting it in the DOM — loading/error states, debounce, and AbortController together.