Events
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.
<!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 method | Meaning |
|---|---|
event.type | The event name, such as "click" |
event.target | The exact element where the event started |
event.currentTarget | The element whose listener is running now |
event.key | For keyboard events: the key pressed, such as "Enter" or "a" |
event.clientX, event.clientY | For 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.changefires when the user finishes changing it.submit: a form is sent.keydown: a key is pressed.focusinandfocusout: an element gains or loses focus.pointerdownandpointermove: 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:
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.
<!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: trueremoves the listener automatically after it runs one time.passive: truepromises that you will not callpreventDefault(). Forscroll,wheel,touchmoveandpointermovelisteners, this lets the browser keep scrolling smooth instead of waiting for your code.signalconnects the listener to anAbortController. Callingcontroller.abort()removes every listener that uses that signal, in one line.capture: trueruns the listener on the way down the tree, before bubbling. You will rarely need it.
<!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:
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 functionTwo 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())runssaveimmediately and passes its result. WriteaddEventListener("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.targetwithoutclosest()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
signaloption.
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.
onceruns a listener a single time;passivekeeps scrolling smooth;signalwithAbortControllerremoves listeners in bulk.removeEventListenerneeds the exact same function reference.- Prefer
addEventListeneroveronclickattributes 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
<!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())?
Pass the function itself, save, so the browser can call it when the click happens.
2. Why does event delegation work for list items added after the page loads?
The single listener lives on the parent, and events from any child, old or new, bubble up to it.
3. What does the passive: true option promise the browser?
A passive listener cannot cancel scrolling, so the browser can scroll smoothly without waiting.
4. How can you remove several listeners at once without keeping references to each function?
Every listener registered with that signal is removed when the controller aborts.
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
