Event Handling
Event Handling
Events are how a page reacts to the world: clicks, key presses, form submissions, scrolling, incoming messages. JavaScript's event system is a subscription model — you register a function, the browser calls it when the event fires. This tutorial covers registering listeners, reading the event object, delegation, and the mistakes that account for most broken buttons on the internet.
Step 1: Adding Event Listeners
const button = document.querySelector("#myButton");
button.addEventListener("click", (event) => {
console.log("Button clicked!", event);
});
// Options give you fine control
button.addEventListener("click", handler, {
once: true, // auto-remove after the first call
capture: false, // run during the bubbling phase (default)
passive: true // promise not to call preventDefault() — faster scrolling
});
Why addEventListener beats onclick: the older element.onclick = fn property holds exactly one handler — assigning a second silently overwrites the first. addEventListener stacks any number of independent handlers and supports options like once and passive. The passive: true flag matters for scroll and touchmove: it tells the browser it may scroll immediately without waiting to see whether your handler cancels the event.
Step 2: The Event Object
Every handler receives an event object describing what happened:
element.addEventListener("click", (event) => {
event.target; // the element actually clicked (could be a child)
event.currentTarget; // the element this listener is attached to
event.type; // "click"
event.preventDefault(); // stop the default browser action
event.stopPropagation(); // stop the event from bubbling further up
});
target vs currentTarget is the distinction beginners miss: click the icon inside a button, and target is the icon while currentTarget is the button carrying the listener. preventDefault vs stopPropagation are also unrelated tools: the first cancels the browser's built-in behavior (following a link, submitting a form), the second stops other listeners higher in the tree from hearing the event. Needing both is rare; picking the wrong one is common.
Step 3: Common Events in Practice
// Keyboard — react to specific keys
document.addEventListener("keydown", (e) => {
if (e.key === "Enter") console.log("Enter pressed");
if (e.key === "Escape") closeModal();
});
// Forms — intercept submission for validation or fetch()
form.addEventListener("submit", (e) => {
e.preventDefault(); // stop the full-page reload
const data = new FormData(form);
console.log(Object.fromEntries(data));
});
// Live input — fires on every keystroke
input.addEventListener("input", (e) => {
console.log(e.target.value);
});
Why preventDefault on submit: a form's default action navigates to its action URL, reloading the page and destroying your application state. Preventing it lets you validate and send the data with fetch instead — the foundation of every single-page app form.
Step 4: Event Delegation
Events bubble: after firing on the target, they travel up through every ancestor. Delegation exploits this — one listener on a parent handles clicks for any number of children, including ones added later:
const list = document.querySelector("#list");
list.addEventListener("click", (e) => {
const item = e.target.closest("li");
if (item && list.contains(item)) {
console.log("Item clicked:", item.textContent);
}
});
Why delegate: a 500-row table needs one listener, not 500; memory stays flat and rows created dynamically work automatically, because the listener sits on the parent that never changes. closest("li") handles clicks that land on elements nested inside the row.
Step 5: Removing Listeners and Custom Events
function onScroll() { console.log(window.scrollY); }
window.addEventListener("scroll", onScroll);
window.removeEventListener("scroll", onScroll); // must be the SAME reference
// Custom events let components talk without coupling
const evt = new CustomEvent("userLoggedIn", {
detail: { userId: 123, username: "john" },
bubbles: true
});
element.dispatchEvent(evt);
element.addEventListener("userLoggedIn", (e) => {
console.log("User:", e.detail.username);
});
Why removal needs the same reference: listeners are matched by function identity. An inline arrow function creates a brand-new object each time, so it can never be removed. Store the handler in a variable, or use { once: true }, or an AbortController signal to detach many listeners at once.
Common Beginner Errors and Fixes
- Calling the handler while registering:
addEventListener("click", doThing())runsdoThingimmediately. Fix: pass the referencedoThing, or wrap:() => doThing(id). - Trying to remove an anonymous function:
removeEventListener("click", () => {...})never matches. Fix: keep a named reference to the exact function you added. - Forgetting preventDefault on form submit: the page reloads and your fetch never completes. Fix: call
e.preventDefault()first thing in the submit handler. - Reading this inside an arrow handler: arrows inherit
thisfrom outside, so it is not the element. Fix: usee.currentTarget, which always works. - Listeners piling up: re-running setup code adds duplicate handlers, so one click logs three times. Fix: register once, or remove before re-adding, or use
once.
Practice Exercise
Build a small interactive list with delegation:
ul with five li items ("Task 1" through "Task 5") from JavaScript.ul that toggles a "done" class on whichever li was clicked.keydown listener so pressing d toggles the last item.CustomEvent named "taskToggled" (with the task text in detail) every time a task is toggled, and log it from a separate listener.If step 3 works on the dynamically added task, you have understood delegation — the single most useful event pattern in real applications.