Handling Events with JavaScript

Handling Events with JavaScript

I think event handling is where JavaScript really comes alive in the browser. Everything from a simple button click to a complex drag-and-drop interface comes down to listening for events and responding to them. Early in my career I only knew onclick="doSomething()" in HTML — it worked, but it was messy and limited. Once I properly learned addEventListener, event objects, bubbling, capturing, and delegation, my understanding of building interactive UIs completely changed. Let me take you through it all.

What Is an Event?

An event is a signal that something has happened — a user clicked a button, typed in a field, resized the window, or a network request completed. The browser (or Node.js’s EventEmitter for server-side events) generates these signals, and I write code that “listens” for them and reacts.

Registering Event Listeners

The Modern Way: addEventListener

const button = document.querySelector("#myButton");

button.addEventListener("click", function (event) {
  console.log("Button clicked!", event);
});

I always use addEventListener over inline HTML attributes (onclick="...") or the element.onclick = fn property, for a few concrete reasons:

  • I can attach multiple listeners to the same event on the same element.
  • I can control capturing vs. bubbling phases.
  • I can easily remove a specific listener later with removeEventListener.
  • It keeps my JavaScript separate from my HTML markup.
function handleClick() {
  console.log("First handler");
}

function handleClickAgain() {
  console.log("Second handler");
}

button.addEventListener("click", handleClick);
button.addEventListener("click", handleClickAgain);
// Both handlers fire when the button is clicked

Removing Listeners

button.removeEventListener("click", handleClick);

This only works if I pass the same function reference used when adding the listener — an anonymous function or an arrow function defined inline can never be removed this way, since I have no reference to it afterward.

The Event Object

Every event handler receives an Event object with detailed information about what happened.

button.addEventListener("click", (event) => {
  console.log(event.type);       // "click"
  console.log(event.target);     // the actual element that triggered the event
  console.log(event.currentTarget); // the element the listener is attached to
  console.log(event.timeStamp);  // when the event occurred
});

event.target vs event.currentTarget is a distinction I use constantly, especially with event delegation (explained below) — target is where the event originated, currentTarget is where the listener is attached, and these can differ significantly when events bubble.

Common Event Types

CategoryExamples
Mouseclick, dblclick, mousedown, mouseup, mousemove, mouseenter, mouseleave
Keyboardkeydown, keyup, keypress (deprecated)
Formsubmit, change, input, focus, blur
Windowload, resize, scroll, beforeunload
Touchtouchstart, touchmove, touchend
Dragdragstart, dragover, drop
document.querySelector("#searchInput").addEventListener("input", (e) => {
  console.log("Current value:", e.target.value);
});

window.addEventListener("resize", () => {
  console.log(`Window resized: ${window.innerWidth}x${window.innerHeight}`);
});

preventDefault() and stopPropagation()

preventDefault()

I use this to stop the browser’s default behavior for an event — for example, stopping a form from actually submitting so I can validate it with JavaScript first.

const form = document.querySelector("#signupForm");

form.addEventListener("submit", (event) => {
  event.preventDefault();
  const email = form.email.value;
  if (!email.includes("@")) {
    alert("Please enter a valid email");
    return;
  }
  console.log("Submitting:", email);
});

stopPropagation()

I use this to prevent an event from bubbling up to parent elements — useful when a click inside a modal shouldn’t also trigger a “close modal” handler attached to the overlay behind it.

modal.addEventListener("click", (event) => {
  event.stopPropagation();
});

overlay.addEventListener("click", () => {
  closeModal();
});

Event Bubbling and Capturing

This is one of the most important internal mechanics to understand, and it confused me for a long time before I saw it visualized properly.

When an event fires on an element, it doesn’t just run at that element — it travels through the DOM tree in three phases:

  1. Capturing phase: The event travels from the window down to the target element.
  2. Target phase: The event reaches the actual element that triggered it.
  3. Bubbling phase: The event travels back up from the target to the window.

By default, addEventListener listens during the bubbling phase. I can opt into the capturing phase with a third argument:

parent.addEventListener(
  "click",
  () => console.log("Parent - capturing"),
  { capture: true }
);

child.addEventListener("click", () => console.log("Child - target"));

parent.addEventListener("click", () => console.log("Parent - bubbling"));

// Clicking the child logs:
// Parent - capturing
// Child - target
// Parent - bubbling
<div id="parent">
  <button id="child">Click me</button>
</div>

Understanding bubbling explains why a click on a <button> inside a <div> with its own click listener triggers both handlers — the event bubbles from the button up through the div.

Event Delegation

This is one of the most powerful patterns event bubbling enables, and I use it constantly for lists, tables, and any dynamically-generated content.

Instead of attaching a listener to every single list item (which also fails for items added after the listeners were set up), I attach one listener to the parent and use event.target to figure out which child was actually clicked.

const list = document.querySelector("#todoList");

list.addEventListener("click", (event) => {
  if (event.target.matches(".delete-btn")) {
    const item = event.target.closest("li");
    item.remove();
  }
});
<ul id="todoList">
  <li>Buy groceries <button class="delete-btn">Delete</button></li>
  <li>Walk the dog <button class="delete-btn">Delete</button></li>
</ul>

Even if I dynamically add a hundred more <li> elements later, this single listener handles clicks on all of them — I never need to attach new listeners to new items. This dramatically improves both performance and maintainability compared to attaching individual listeners to every element.

The once, passive, and signal Options

button.addEventListener(
  "click",
  () => console.log("Fires only once"),
  { once: true }
);

document.addEventListener(
  "touchstart",
  () => {},
  { passive: true } // tells the browser I won't call preventDefault(), improving scroll performance
);

const controller = new AbortController();
button.addEventListener("click", () => console.log("Clicked"), {
  signal: controller.signal,
});
controller.abort(); // removes the listener — useful for cleaning up many listeners at once

The passive: true option is something I always add to scroll/touch listeners when I know I won’t call preventDefault() — it lets the browser start scrolling immediately instead of waiting to see if my handler will block it, which noticeably improves scroll smoothness.

Custom Events

I’m not limited to built-in events — I can define and dispatch my own.

const cartUpdated = new CustomEvent("cartUpdated", {
  detail: { itemCount: 3, total: 59.97 },
});

document.addEventListener("cartUpdated", (event) => {
  console.log("Cart updated:", event.detail);
});

document.dispatchEvent(cartUpdated);
// Cart updated: { itemCount: 3, total: 59.97 }

I use custom events to decouple different parts of an application — a cart module can announce “the cart changed” without needing to know anything about the code that updates the header badge.

Internal Working: How the Browser Dispatches Events

When a user interacts with the page, the browser’s rendering engine generates a native event and determines the DOM path from window down to the actual target element. It then runs the capturing phase (top-down), the target phase, and the bubbling phase (bottom-up), invoking any listeners registered for each phase along the way, in the order they were attached.

Internally, each element keeps an internal list of registered listeners, categorized by event type and phase. stopPropagation() prevents the event from continuing to travel further along this path, but it does not stop other listeners attached to the same element for the same event — for that, I’d need stopImmediatePropagation().

button.addEventListener("click", (e) => {
  console.log("First listener");
  e.stopImmediatePropagation();
});

button.addEventListener("click", () => {
  console.log("Second listener — this will NOT run");
});

Practical, Real-World Patterns

A Reusable Modal Close Pattern

function setupModal(modalEl) {
  modalEl.addEventListener("click", (e) => {
    if (e.target === modalEl) {
      modalEl.classList.remove("open");
    }
  });

  document.addEventListener("keydown", (e) => {
    if (e.key === "Escape") modalEl.classList.remove("open");
  });
}

Debounced Scroll Handler

function debounce(fn, delay) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  };
}

window.addEventListener(
  "scroll",
  debounce(() => console.log("Scroll position:", window.scrollY), 100)
);

Best Practices

  • I prefer addEventListener over inline handlers or .onclick assignment for flexibility and separation of concerns.
  • I use event delegation for lists or dynamically generated content instead of attaching listeners to every individual item.
  • I remove listeners I no longer need, especially in single-page applications, to avoid memory leaks.
  • I use { passive: true } on scroll/touch listeners that don’t call preventDefault().

Common Mistakes to Avoid

  1. Attaching listeners to elements that don’t exist yet (created dynamically later) — use delegation instead.
  2. Forgetting to remove listeners on elements that get destroyed, causing memory leaks in long-running apps.
  3. Confusing target and currentTarget, leading to bugs in delegated event handlers.
  4. Calling preventDefault() unnecessarily, which can break expected browser behavior like text selection or native form validation.

Debugging Tips

  • I use the “Event Listeners” panel in Chrome DevTools’ Elements tab to inspect exactly which listeners are attached to a given element.
  • I log event.target.tagName and event.target.className inside delegated handlers to confirm I’m matching the right elements.
  • For events that seem to “not fire,” I check whether stopPropagation() somewhere upstream is blocking them.

FAQs

Q: What’s the difference between click and dblclick? A: click fires on a single click; dblclick fires only after two rapid clicks, and it fires in addition to two separate click events.

Q: Does event.preventDefault() stop event bubbling too? A: No — it only cancels the default browser behavior (like following a link). Use stopPropagation() separately to stop bubbling.

Q: Can I attach the same handler function for multiple event types? A: Yes: ["mouseenter", "focus"].forEach(evt => el.addEventListener(evt, handler));

Q: Why use event delegation instead of just attaching listeners to each item? A: It’s more memory-efficient (one listener instead of many) and automatically works for elements added to the DOM later.

Summary and Key Takeaways

  • addEventListener is the modern, flexible way to register event handlers, supporting multiple listeners and phase control.
  • Events travel through capturing, target, and bubbling phases — bubbling is the default and enables event delegation.
  • preventDefault() stops default browser behavior; stopPropagation() stops further event travel.
  • Event delegation is a powerful, performance-friendly pattern for handling dynamic lists and collections of elements.
  • Custom events let different parts of an application communicate without tight coupling.

Getting comfortable with the mechanics of event propagation, not just the syntax of addEventListener, is what let me build genuinely interactive, efficient interfaces instead of fighting unexpected bugs around click handlers firing (or not firing) when I expected.

References

Total
0
Shares

Leave a Reply

Previous Post
Selecting and Manipulating DOM Elements in JavaScript

Selecting and Manipulating DOM Elements in JavaScript

Next Post
Understanding Asynchronous Programming in JavaScript

Understanding Asynchronous Programming in JavaScript

Related Posts