Events

JavaScript · Lesson 34 of 61 7 min read Updated

A web page waits most of the time. Then something happens: a click, a key press, a form submit, the pointer moving. Each of these is an event, and JavaScript lets you run code when one happens. Events are what turn a static page into an app.

In this lesson you will listen for events with addEventListener, read the event object, stop the browser's default action, handle many elements with one listener using event delegation, and add and remove listeners cleanly with options such as once, passive and signal.

Listening for an event

element.addEventListener(type, listener) says: "when an event of this type happens on this element, call this function". The function is called a listener or handler. The browser calls it and passes in an event object describing what happened.

HTMLTry it yourself
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Chai counter</title>
</head>
<body>
  <h1>Cutting chai orders</h1>
  <button type="button" id="minus">-</button>
  <output id="count">0</output>
  <button type="button" id="plus">+</button>
  <p id="info">Click a button or press + or - on your keyboard.</p>

  <script>
    const countBox = document.querySelector("#count");
    const info = document.querySelector("#info");
    let count = 0;

    function update(change, source) {
      count = Math.max(0, count + change);
      countBox.textContent = count;
      info.textContent = `Last change from: ${source}`;
    }

    document.querySelector("#plus").addEventListener("click", (event) => {
      update(1, `${event.type} on #${event.target.id}`);
    });

    document.querySelector("#minus").addEventListener("click", (event) => {
      update(-1, `${event.type} on #${event.target.id}`);
    });

    document.addEventListener("keydown", (event) => {
      if (event.key === "+") update(1, `key "${event.key}"`);
      if (event.key === "-") update(-1, `key "${event.key}"`);
    });
  </script>
</body>
</html>

Notice that you pass the function itself. You do not call it. The browser calls it later, every time the event happens. The click event also fires when a focused button is activated with the Enter key or Space bar, so it works for keyboard users too.

The event object

The event object carries details about what happened. These properties are the ones you will use most:

Property or methodMeaning
event.typeThe event name, such as "click"
event.targetThe exact element where the event started
event.currentTargetThe element whose listener is running now
event.keyFor keyboard events: the key pressed, such as "Enter" or "a"
event.clientX, event.clientYFor pointer events: the position in the window
event.preventDefault()Stop the browser's default action

Some event types you will meet often:

  • click: a button, link or other element is activated.
  • input: the value of a text box changes, on every key press. change fires when the user finishes changing it.
  • submit: a form is sent.
  • keydown: a key is pressed.
  • focusin and focusout: an element gains or loses focus.
  • pointerdown and pointermove: a mouse, pen or finger presses or moves.
  • DOMContentLoaded: the HTML has been fully read.

Stopping the default action

Many events come with a built-in browser action. Clicking a link opens a new page, and submitting a form sends the data and reloads the page. Call event.preventDefault() when you want to handle the event yourself instead:

JavaScript
const form = document.querySelector("#search");
form.addEventListener("submit", (event) => {
  event.preventDefault(); // stay on this page
  // ...read the form and show the results with JavaScript
});

You will use this in nearly every form you handle with JavaScript. See Working with Forms.

Bubbling and event delegation

When you click a button inside a list item inside a list, the click does not stay on the button. It bubbles up: first the button's listeners run, then the list item's, then the list's, and so on up to document. event.target stays the button the whole way, while event.currentTarget changes to whichever element's listener is running.

Bubbling allows a powerful pattern called event delegation: instead of adding a listener to every button, add one listener to their shared parent and check event.target to see which button was clicked. It uses less memory, and it works for elements added later, which separate listeners cannot do.

HTMLTry it yourself
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Event delegation</title>
  <style>
    li { margin-block: 0.3rem; }
    .bought span { text-decoration: line-through; color: gray; }
  </style>
</head>
<body>
  <h1>Shopping list</h1>
  <form id="add-form">
    <input name="grocery" placeholder="e.g. Basmati rice" required>
    <button>Add</button>
  </form>
  <ul id="list">
    <li><span>Atta, 5 kg</span> <button type="button" data-action="bought">Bought</button> <button type="button" data-action="delete">Delete</button></li>
  </ul>

  <script>
    const list = document.querySelector("#list");
    const form = document.querySelector("#add-form");

    // One listener on the <ul> handles every button, even ones added later.
    list.addEventListener("click", (event) => {
      const button = event.target.closest("button[data-action]");
      if (!button) return; // the click was not on a button

      const item = button.closest("li");
      if (button.dataset.action === "bought") {
        item.classList.toggle("bought");
      } else if (button.dataset.action === "delete") {
        item.remove();
      }
    });

    form.addEventListener("submit", (event) => {
      event.preventDefault(); // stop the page from reloading
      const text = form.elements.grocery.value.trim();
      if (!text) return;

      const li = document.createElement("li");
      const label = document.createElement("span");
      label.textContent = text;

      const bought = document.createElement("button");
      bought.type = "button";
      bought.dataset.action = "bought";
      bought.textContent = "Bought";

      const remove = document.createElement("button");
      remove.type = "button";
      remove.dataset.action = "delete";
      remove.textContent = "Delete";

      li.append(label, " ", bought, " ", remove);
      list.append(li);
      form.reset();
    });
  </script>
</body>
</html>

Add a few items, then mark them bought or delete them. The new buttons work even though nobody attached a listener to them. The line event.target.closest("button[data-action]") matters: if a button contained an icon or a <span>, event.target would be that inner element, and closest() walks up to the button.

You may read about event.stopPropagation(), which stops bubbling. Avoid it unless you have a clear reason: it silently breaks delegated listeners higher up the page.

Listener options: once, passive and signal

addEventListener takes an optional third argument, an options object:

  • once: true removes the listener automatically after it runs one time.
  • passive: true promises that you will not call preventDefault(). For scroll, wheel, touchmove and pointermove listeners, this lets the browser keep scrolling smooth instead of waiting for your code.
  • signal connects the listener to an AbortController. Calling controller.abort() removes every listener that uses that signal, in one line.
  • capture: true runs the listener on the way down the tree, before bubbling. You will rarely need it.
HTMLTry it yourself
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Listener options</title>
</head>
<body>
  <button type="button" id="coupon">Get my one-time coupon</button>
  <p id="coupon-msg"></p>

  <button type="button" id="start">Start tracking</button>
  <button type="button" id="stop">Stop tracking</button>
  <p id="position">Pointer: not tracked</p>

  <script>
    // once: the listener removes itself after the first click
    document.querySelector("#coupon").addEventListener("click", () => {
      document.querySelector("#coupon-msg").textContent = "Your code is CHAI50. Clicking again does nothing.";
    }, { once: true });

    // signal: one abort() removes every listener added with this signal
    let controller = null;
    const position = document.querySelector("#position");

    document.querySelector("#start").addEventListener("click", () => {
      if (controller) return; // already tracking
      controller = new AbortController();

      document.addEventListener("pointermove", (event) => {
        position.textContent = `Pointer: ${event.clientX}, ${event.clientY}`;
      }, { signal: controller.signal, passive: true });

      document.addEventListener("keydown", (event) => {
        if (event.key === "Escape") stopTracking();
      }, { signal: controller.signal });
    });

    function stopTracking() {
      controller?.abort();
      controller = null;
      position.textContent = "Pointer: tracking stopped";
    }

    document.querySelector("#stop").addEventListener("click", stopTracking);
  </script>
</body>
</html>

Removing listeners

The classic way to remove a listener is removeEventListener with the very same function you added:

JavaScript
function onScroll() {
  console.log(window.scrollY);
}
window.addEventListener("scroll", onScroll, { passive: true });
window.removeEventListener("scroll", onScroll); // works: same function

button.addEventListener("click", () => save());
button.removeEventListener("click", () => save()); // does nothing: a different function

Two arrow functions that look identical are still two different functions, so the second removal fails silently. That is why the signal option is often easier: you do not need to keep a reference to each function, and one abort() cleans up a whole group of listeners, as the tracking example shows. The same AbortController can also cancel a network request, as you will see in Fetch API.

Common mistakes

  • Calling the function instead of passing it. addEventListener("click", save()) runs save immediately and passes its result. Write addEventListener("click", save).
  • Adding "on" to the event name. The type is "click", not "onclick".
  • Forgetting preventDefault() on a form. The page reloads and your JavaScript result disappears.
  • Using event.target without closest() in delegation. The target may be a child element inside the button.
  • Trying to remove an anonymous function. Keep a named reference, or use the signal option.

Key points

  • addEventListener(type, listener, options) runs your function each time the event happens.
  • The event object tells you the type, the target, the key or the position, and lets you call preventDefault().
  • Events bubble up the tree, so one listener on a parent can handle many children, including future ones.
  • once runs a listener a single time; passive keeps scrolling smooth; signal with AbortController removes listeners in bulk.
  • removeEventListener needs the exact same function reference.
  • Prefer addEventListener over onclick attributes and properties.

Exercise

Build a "rate this dish" widget: five buttons labelled 1 to 5 inside a <div id="stars">, and a paragraph for the result. Use one delegated click listener on the <div> to show "You rated 4 out of 5" (with the right number). After the first rating, the widget should stop responding: use the once option or an AbortController.

Show solution
HTML
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Rate this dish</title>
</head>
<body>
  <div id="stars">
    <button type="button" data-rating="1">1</button>
    <button type="button" data-rating="2">2</button>
    <button type="button" data-rating="3">3</button>
    <button type="button" data-rating="4">4</button>
    <button type="button" data-rating="5">5</button>
  </div>
  <p id="result">Tap a number to rate the paneer tikka.</p>

  <script>
    const controller = new AbortController();

    document.querySelector("#stars").addEventListener("click", (event) => {
      const button = event.target.closest("button[data-rating]");
      if (!button) return;
      document.querySelector("#result").textContent = `You rated ${button.dataset.rating} out of 5`;
      controller.abort(); // stop listening after the first real rating
    }, { signal: controller.signal });
  </script>
</body>
</html>

An AbortController works better than once here: with once, a click on the gap between buttons would use up the listener without giving a rating.

Quick Quiz

1. What is wrong with button.addEventListener("click", save())?

2. Why does event delegation work for list items added after the page loads?

3. What does the passive: true option promise the browser?

4. How can you remove several listeners at once without keeping references to each function?

Learn JavaScript with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call