Free lesson · JavaScript Basic to Advance
Events and Event Handling
How Pages Respond
The DOM is a static structure until something happens to it. Events are how something happens — clicks, key presses, network responses, timer ticks, page loads. Every interactive web page is built from event handlers that respond to these events.
const button = document.querySelector("button");
button.addEventListener("click", (event) => {
console.log("Button clicked!");
console.log("Event:", event);
});
addEventListener attaches a function to be called when a specific event happens on an element. When the user clicks the button, the browser calls the function with an event object describing what happened.
This Topic covers events comprehensively: how to attach and remove listeners, event objects, bubbling and capturing, event delegation, and common patterns.
Attaching Event Listeners
The standard API is addEventListener(eventType, handler, options):
const button = document.querySelector("#submit");
// Basic usage
button.addEventListener("click", handleClick);
function handleClick(event) {
console.log("Clicked");
}
// Inline arrow function
button.addEventListener("click", (event) => {
console.log("Inline handler");
});
// Options as third argument
button.addEventListener("click", handler, {
once: true, // remove after first invocation
capture: true, // listen during the capture phase (covered below)
passive: true // promise not to call preventDefault (for performance)
});
The options argument is modern; older code passes true/false as the third argument, which sets capture only.
Removing Event Listeners
To remove a listener, you need the same function reference you used to add it:
function handleClick(event) {
console.log("Clicked");
}
button.addEventListener("click", handleClick);
// Later, remove it
button.removeEventListener("click", handleClick);
This is why inline arrow functions can be problematic — you can't remove them later because you don't have a reference:
button.addEventListener("click", () => console.log("hi")); // arrow function
// Can't remove this — no reference to the function
button.removeEventListener("click", () => console.log("hi")); // doesn't remove anything
If you'll need to remove a handler, store it in a variable. If you won't, inline is fine.
A modern alternative for managing many handlers: AbortController:
const controller = new AbortController();
button.addEventListener("click", handler1, { signal: controller.signal });
input.addEventListener("input", handler2, { signal: controller.signal });
window.addEventListener("resize", handler3, { signal: controller.signal });
// Later — remove all three at once
controller.abort();
AbortController lets you remove multiple listeners with a single abort() call. Useful for components that need to clean up many listeners on unmount.
The Event Object
When a handler runs, it receives an event object with details about what happened:
button.addEventListener("click", (event) => {
event.type // "click"
event.target // the element that fired the event
event.currentTarget // the element the listener is attached to
event.timeStamp // when the event occurred
event.bubbles // whether this event bubbles up
event.cancelable // whether preventDefault can stop it
// For mouse events
event.clientX // mouse position relative to viewport
event.clientY
event.button // which mouse button (0=left, 1=middle, 2=right)
// For keyboard events
event.key // "a", "Enter", "ArrowUp"
event.code // "KeyA", "Enter", "ArrowUp"
event.shiftKey // boolean — was shift held
event.ctrlKey
event.altKey
event.metaKey
});
The properties available depend on the event type. Mouse events have positional info. Keyboard events have key info. Form events have target value info. The event object's structure is part of the API for each event type.
Two methods you'll use often:
event.preventDefault() — cancel the browser's default behavior. Useful for forms (prevent submission), links (prevent navigation), drag-and-drop (prevent default browser handling).
event.stopPropagation() — stop the event from bubbling to ancestor elements (covered below).
Interactive animation: The event object - event.target is the element that was clicked, event.currentTarget the element the listener is on.
Event Bubbling and Capturing
A crucial DOM concept: events propagate through the DOM tree in two phases.
When you click a button inside several nested divs, the click event:
- Capture phase. Starts at the document root, travels down to the target. Handlers registered with
capture: truefire during this phase. - Target phase. The event reaches the actual element clicked. All non-capturing handlers on it fire.
- Bubble phase. Travels back up from the target to the root. Non-capturing handlers on ancestors fire.
// Setup
// <div id="outer">
// <div id="middle">
// <button id="inner">Click me</button>
// </div>
// </div>
document.getElementById("outer").addEventListener("click", () => {
console.log("Outer (bubbling)");
});
document.getElementById("middle").addEventListener("click", () => {
console.log("Middle (bubbling)");
});
document.getElementById("inner").addEventListener("click", () => {
console.log("Inner (target)");
});
// Click the button:
// Outer (capture phase) - if registered with capture:true
// Middle (capture phase) - if registered with capture:true
// Inner (target)
// Middle (bubbling)
// Outer (bubbling)
By default, handlers run in the bubble phase. The capture phase is mostly used in libraries or specific edge cases.
graph TD
A[document] -.->|capture phase| B[outer div]
B -.->|capture phase| C[middle div]
C -.->|capture phase| D[button - target]
D -->|bubble phase| C
C -->|bubble phase| B
B -->|bubble phase| A
style D fill:#10b981,color:#fff
style A fill:#7c3aed,color:#fff
This diagram shows how events propagate through the DOM. When an event occurs on the target element — in this example, a button — the browser first runs handlers registered for the capture phase. These fire on each ancestor from the document root down to the target. Then the target's own handlers run. Then bubble phase handlers run, on each ancestor from the target back up to the root. By default, addEventListener registers handlers for the bubble phase only.
Event Delegation
Bubbling enables a powerful pattern: event delegation. Instead of adding listeners to many child elements, add one listener to a common ancestor and use event.target to determine which child was actually involved.
// Bad: one listener per item
document.querySelectorAll(".item").forEach(item => {
item.addEventListener("click", () => console.log("Clicked", item));
});
// Good: one listener on the container, using delegation
document.querySelector(".list").addEventListener("click", (event) => {
if (event.target.classList.contains("item")) {
console.log("Clicked", event.target);
}
});
The advantages:
Fewer listeners. A list with 1000 items needs one listener, not 1000.
Works with dynamically added items. New items added later don't need their own listeners — they bubble to the same delegated handler.
Cleaner cleanup. One listener to remove, not many.
The trade-offs: every click on the container has to check whether to handle it. For very deep DOM trees with many event types, this can become complex. For most cases, delegation is the right pattern.
Interactive animation: Delegation, the bubble put to work - one listener on the container handles every row, including rows added later.
stopPropagation and stopImmediatePropagation
Sometimes you need to stop bubbling:
button.addEventListener("click", (event) => {
event.stopPropagation(); // event doesn't bubble to ancestors
// Other handlers on this same element still fire
});
button.addEventListener("click", (event) => {
event.stopImmediatePropagation(); // stops other handlers on this element too
});
stopPropagation is occasionally useful but should be used sparingly. It breaks the delegation pattern — code higher up the tree might be relying on the event bubbling to it.
A common anti-pattern: using stopPropagation to prevent a click from closing a modal. The modal's outer-click-to-close handler often relies on bubbling. Stopping propagation makes it harder for other code to coordinate.
When in doubt, don't stop propagation. Let events bubble freely and have handlers decide whether to act based on event.target.
Interactive animation: Two brakes, two different machines - stopPropagation halts the bubbling, preventDefault cancels the browser's default behavior.
Common Events to Know
A non-exhaustive list of the most-used event types:
Mouse: click, dblclick, mousedown, mouseup, mousemove, mouseenter, mouseleave
Keyboard: keydown, keyup, keypress (legacy — usually use keydown instead)
Form: submit, input, change, focus, blur
Window: load, DOMContentLoaded, resize, scroll, beforeunload
Touch: touchstart, touchmove, touchend — for touch devices
Pointer: pointerdown, pointermove, pointerup — unified mouse/touch/pen handling (modern preferred)
For most applications, click, input, submit, and DOMContentLoaded cover 80% of needs.
DOMContentLoaded vs load
A common confusion. Two different "page is ready" events:
DOMContentLoaded — fires when the HTML has been parsed and the DOM is fully constructed. CSS, images, and other resources may still be loading.
load — fires when the page and all dependent resources (CSS, images, scripts) have fully loaded.
document.addEventListener("DOMContentLoaded", () => {
console.log("DOM ready — can manipulate elements");
});
window.addEventListener("load", () => {
console.log("Everything loaded — including images");
});
For most JavaScript that manipulates the DOM, DOMContentLoaded is what you want. It fires earlier and lets your script start working before images finish loading. Use load only if you specifically need to wait for images or other resources.
Common Pitfalls
Warning: The event-handling traps.
Inline arrow functions for handlers you need to remove later. Without a stable reference, you can't remove them. Use named functions or AbortController for managed cleanup.
Forgetting that event listeners can leak memory. A listener attached to a long-lived element holds references to closure variables. If you create many elements with listeners and forget to remove them, memory accumulates.
Using
stopPropagationcasually. It breaks the delegation pattern and can interfere with other code that relies on bubbling. Stop propagation only when you have a specific reason.Trying to handle clicks on dynamically-added elements without delegation. Each newly-added element would need its own listener. Use delegation to handle current and future elements with one listener.
Why This Matters in Interviews
Interview: Event handling is a foundational frontend interview topic. "Explain event bubbling" and "what's event delegation?" are extremely common questions. The expected answers include the three phases (capture, target, bubble), how
event.targetandevent.currentTargetdiffer, and when to use delegation.Topic 12.7 — Interview Lens: Browser and DOM Questions — covers the deeper treatment.
Try It
Exercise: Build an event delegation demo:
- Create a list with 5 items
- Add a single click listener to the list container, not to each item
- In the handler, use
event.targetto identify which item was clicked- Make clicked items toggle a "selected" class
- Add a button that adds new items to the list
- Verify the new items respond to clicks without needing their own listeners
Once this works, you've internalized why delegation is so common in real frontend code.