Events.
Make the page respond.
You can select elements and change them. Now you need to know when to change them. That's what events are — the browser telling your code that something happened. A click. A keypress. A form submission. This lesson covers addEventListener, the event object, bubbling, delegation, and preventDefault. Together, they're the entire mechanism behind every interactive feature on the web.
Attach event listeners with el.addEventListener("click", handler). The handler receives an event object with event.target (what was clicked), event.currentTarget (where the listener is attached), and methods like event.preventDefault() and event.stopPropagation(). Events bubble up from the target through its ancestors — that's what makes event delegation possible. Use delegation for lists and dynamic content: one listener on the parent instead of hundreds on children.
Before this lesson: complete Lesson 4. You need to be comfortable with querySelector and classList.
🎧 addEventListener — the only way to listen
Three arguments, one pattern, works for every event type.
const button = document.querySelector("#my-button");
// The pattern: element.addEventListener(event, handler)
button.addEventListener("click", () => {
console.log("Button clicked!");
});
// The handler receives the event object
button.addEventListener("click", (event) => {
console.log("Clicked:", event.target);
});
// Named handler (needed if you want to remove it later)
function handleClick(event) {
console.log("Clicked!");
}
button.addEventListener("click", handleClick);
// Common event types
// click, dblclick — mouse
// mouseenter, mouseleave — hover
// keydown, keyup — keyboard
// input, change — form fields
// submit — forms
// focus, blur — focus state
// scroll, resize — window
// Multiple listeners on the same element — all run
button.addEventListener("click", () => console.log("First"));
button.addEventListener("click", () => console.log("Second"));
// The ONCE option — listener removes itself after firing
button.addEventListener("click", () => {
console.log("This only runs once");
}, { once: true });
Never use onclick. button.onclick = fn is a property, not a listener. It can only hold one function — assigning a second overwrites the first. addEventListener stacks listeners and lets you remove them. onclick also can't be used with removeEventListener and is harder to debug. Use addEventListener. Always.
Event name
A string like "click", "keydown", "submit". No "on" prefix — just the event itself. "click", not "onclick".
Handler function
The code to run when the event fires. Receives the event object as its first argument. Can be an arrow function or a named function.
Options (optional)
Third argument. { once: true } removes the listener after one fire. { capture: true } listens during the capture phase instead of bubbling.
Remove with same reference
removeEventListener needs the exact same function reference. Anonymous arrow functions can't be removed — store them in a variable first if you need to remove later.
📦 The event object — what happened and where
Every handler receives it. Learn the five properties you'll use daily.
const button = document.querySelector("#my-button");
button.addEventListener("click", (event) => {
// event.target — the element that was actually clicked
console.log(event.target);
// event.currentTarget — the element the listener is attached to
console.log(event.currentTarget);
// event.type — the event name
console.log(event.type); // "click"
// Prevent the browser's default action
event.preventDefault();
// Stop the event from bubbling up to parents
event.stopPropagation();
});
// Keyboard events — event.key tells you which key
document.addEventListener("keydown", (event) => {
console.log(event.key); // "a", "Enter", "Escape", "ArrowUp"
if (event.key === "Enter") {
console.log("Enter pressed");
}
});
// Form input — event.target.value has the current text
const input = document.querySelector("#name");
input.addEventListener("input", (event) => {
console.log(event.target.value); // live value as user types
});
// Mouse position
document.addEventListener("click", (event) => {
console.log(event.clientX, event.clientY); // viewport coordinates
});
// Check if modifier keys were held
button.addEventListener("click", (event) => {
if (event.shiftKey) console.log("Shift + click");
if (event.ctrlKey || event.metaKey) console.log("Ctrl/Cmd + click");
});
event.target
The element that actually triggered the event. If you click a span inside a button, target is the span. This is what you check in event delegation.
event.currentTarget
The element the listener is attached to. Stays the same regardless of what was clicked inside. Use it when you need the listener's owner.
event.key
For keyboard events — the actual key pressed. "Enter", "Escape", "a", "ArrowUp". The modern way to detect specific keys.
event.preventDefault()
Stops the browser's default action. Forms don't submit, links don't navigate, checkboxes don't toggle. Use it when you're handling the action yourself.
🫧 Event bubbling — why clicks travel upward
When you click a child, the parent hears about it too. This is a feature, not a bug.
When an event fires on an element, it doesn't just run on that element. It bubbles — the browser walks up the DOM tree, firing the event on every ancestor until it reaches the document. This is why a click on a button inside a div also triggers click handlers on the div, the body, and the document.
// HTML structure:
// div id="outer"
// div id="middle"
// button id="inner" Click me
const outer = document.querySelector("#outer");
const middle = document.querySelector("#middle");
const inner = document.querySelector("#inner");
outer.addEventListener("click", () => console.log("OUTER"));
middle.addEventListener("click", () => console.log("MIDDLE"));
inner.addEventListener("click", () => console.log("INNER"));
// Click the button. Output:
// INNER
// MIDDLE
// OUTER
// The event fired on the button first, then bubbled up.
// Every ancestor with a click listener ran.
// stopPropagation stops the bubbling
inner.addEventListener("click", (event) => {
console.log("Only INNER runs");
event.stopPropagation();
});
// Now clicking the button prints only "Only INNER runs"
// MIDDLE and OUTER never fire because the event stopped.
Bubbling is not a bug you need to fix. It's the mechanism that makes event delegation possible. Instead of fighting bubbling with stopPropagation, use it: attach one listener to a parent and let events from all children bubble up to it. That's the next section. stopPropagation should be rare — only when you truly need to isolate an event.
Capture phase runs the other direction. Before bubbling, there's a capture phase where the event goes from document down to the target. Add { capture: true } as the third argument to listen during capture. outer.addEventListener("click", fn, true) runs before the button's listener. Capturing is rare — use it for global click-away handlers, not everyday code.
🎯 Event delegation — one listener instead of hundreds
The performance pattern that every real application uses.
If you have a list of 100 items and want to handle clicks on each, attaching 100 listeners is wasteful. Event delegation: attach one listener to the parent. When a child is clicked, the event bubbles to the parent. Inside the parent's handler, check event.target to know which child fired. That's it.
// Without delegation — one listener per item
const items = document.querySelectorAll(".item");
items.forEach(item => {
item.addEventListener("click", () => {
console.log("Clicked:", item.textContent);
});
});
// Problems: 100 items = 100 listeners. New items have no listener.
// WITH delegation — one listener on the parent
const list = document.querySelector("#list");
list.addEventListener("click", (event) => {
if (event.target.classList.contains("item")) {
console.log("Clicked:", event.target.textContent);
}
});
// Now ALL current and future .item elements work.
// Dynamic elements — the killer feature
const addBtn = document.querySelector("#add");
addBtn.addEventListener("click", () => {
const li = document.createElement("li");
li.className = "item";
li.textContent = "New item";
list.appendChild(li);
// No new listener needed — delegation handles it automatically
});
// Delegation with .closest() — robust for compound elements
list.addEventListener("click", (event) => {
const item = event.target.closest(".item");
if (!item) return;
console.log("Item:", item.textContent);
});
// Delegation with delete buttons
list.addEventListener("click", (event) => {
if (event.target.classList.contains("delete")) {
event.target.closest(".item").remove();
return;
}
if (event.target.classList.contains("item")) {
event.target.classList.toggle("done");
}
});
One listener, many elements
Attach to the parent. Every child event bubbles up. Check event.target to know which child. Works for any number of children.
Dynamic content
New items added after the listener is attached work automatically. No need to re-attach anything. This is why React and every modern framework use delegation internally.
Use .closest()
If the clicked element is a child of the target (like an icon inside a button), event.target.closest(".item") walks up until it finds the matching ancestor.
When not to delegate
If the parent has only one child, or the child is unique, direct listeners are simpler. Delegation shines when there are many siblings or dynamic content.
🚫 preventDefault — take control of the browser
Stop the browser from doing what it does by default.
// Form submission — prevent page reload, handle with JS
const form = document.querySelector("form");
form.addEventListener("submit", (event) => {
event.preventDefault(); // stops the browser's default submit
const data = new FormData(form);
console.log("Name:", data.get("name"));
console.log("Email:", data.get("email"));
form.reset(); // clear the form
});
// Link click — prevent navigation
const link = document.querySelector("a[href='#']");
link.addEventListener("click", (event) => {
event.preventDefault(); // don't jump to the top of the page
console.log("Link clicked — but not followed");
});
// Checkbox — prevent toggle (rare, but possible)
const checkbox = document.querySelector("input[type='checkbox']");
checkbox.addEventListener("click", (event) => {
event.preventDefault(); // checkbox stays unchecked
console.log("Toggle blocked");
});
// SPA routing — prevent full page reload
document.querySelectorAll("a").forEach(a => {
a.addEventListener("click", (event) => {
const href = a.getAttribute("href");
if (href.startsWith("/")) {
event.preventDefault();
navigate(href); // your SPA navigation function
}
});
});
preventDefault only works on cancelable events. click, submit, keydown, and wheel are cancelable. scroll is not. You can check with event.cancelable — if false, calling preventDefault does nothing. Not every event can be prevented.
Don't use return false. In old inline handlers (onclick="return false"), returning false prevented the default action. That's a jQuery-ism that doesn't work with addEventListener. Use event.preventDefault() — it's explicit, works everywhere, and doesn't rely on magic return values.
💻 Build something — a live search filter
Every event skill in one small, working example.
This example combines input events, delegation, and DOM manipulation into a working live search. Create this as an HTML file and open it in a browser. Type in the box — the list filters in real time.
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: sans-serif; max-width: 400px; margin: 40px auto; }
input { width: 100%; padding: 10px; font-size: 16px; margin-bottom: 16px; }
li { padding: 8px; border-bottom: 1px solid #eee; }
li:hover { background: #f5f5f5; cursor: pointer; }
.empty { color: #999; text-align: center; padding: 20px; }
</style>
</head>
<body>
<h1>Search</h1>
<input id="search" placeholder="Type to filter...">
<ul id="list"></ul>
<script>
const items = ["Apple", "Banana", "Cherry", "Date", "Elderberry",
"Fig", "Grape", "Honeydew", "Kiwi", "Lemon"];
const search = document.querySelector("#search");
const list = document.querySelector("#list");
function render(filter = "") {
list.innerHTML = "";
const filtered = items.filter(item =>
item.toLowerCase().includes(filter.toLowerCase())
);
if (filtered.length === 0) {
const li = document.createElement("li");
li.className = "empty";
li.textContent = "No results";
list.appendChild(li);
return;
}
filtered.forEach(item => {
const li = document.createElement("li");
li.textContent = item;
list.appendChild(li);
});
}
search.addEventListener("input", (event) => {
render(event.target.value);
});
list.addEventListener("click", (event) => {
if (event.target.tagName === "LI" && !event.target.classList.contains("empty")) {
console.log("You clicked:", event.target.textContent);
}
});
render();
</script>
</body>
</html>
This is the exact pattern every search box uses. The input listener fires on every keystroke. It re-renders the list from data. The click listener uses delegation on the parent — one listener handles all items. When you add real data from an API in Lesson 6, the pattern is identical: fetch data, render it, attach delegation. Nothing changes.
🔍 What just happened
The mental model behind event handling.
Events travel through the DOM tree
When you click an element, the browser runs the event in three phases: capture (document down to target), target (the element itself), and bubble (target back up to document). addEventListener defaults to bubbling. The event fires on the target first, then every ancestor in order.
The event object is created by the browser
It contains a snapshot of what happened — which element was clicked, which key was pressed, the mouse position, whether modifier keys were held. Your handler receives it as an argument. Log it in the console to see everything the browser knows about the event.
Delegation works because of bubbling
One listener on the parent catches events from all children because events bubble up. Inside the handler, event.target tells you which child. This scales to any number of children, works for elements added later, and uses far less memory than individual listeners.
preventDefault is about the browser's default action
Forms want to reload the page. Links want to navigate. Checkboxes want to toggle. preventDefault tells the browser "don't do that — I'll handle it." This is essential for single-page apps, form validation, and any custom UI.
🏆 Challenge
Three tasks. Each exercises a different event skill.
Task 1 — Keyboard shortcuts
Add a keydown listener to the document. When the user presses:
1. "Escape" — log "Escape pressed"
2. "Enter" — log "Enter pressed"
3. Any letter key — log the letter
Hint: event.key gives you the key name. Test in the console.
Task 2 — Delegated list with actions
Create a list of 5 items. Add ONE click listener to the ul. When an item is clicked:
1. Toggle a "selected" class on it.
2. Log how many items are currently selected.
3. Add a "delete" button to each item that removes it — using the same delegated listener.
This is the pattern every real list UI uses.
Task 3 — Form without reload
Create a form with a name and email input. Add a submit handler that:
1. Calls event.preventDefault().
2. Reads the values with event.target.elements or FormData.
3. Logs them to the console.
4. Clears the form with form.reset().
Test: fill the form, click submit, verify the page doesn't reload and the values appear in the console.
⚠️ Common mistakes
The event bugs that catch every beginner.
button.onclick = fn can only hold one handler. A second assignment overwrites the first. Use addEventListener — it stacks and supports removal.
addEventListener("click", myFn()) runs myFn immediately and passes its return value as the handler. Fix: addEventListener("click", myFn) — no parentheses.
Without event.preventDefault(), the form submits and the page reloads. Your JavaScript never sees the data. Add it first thing in the submit handler.
If you delegate to a parent and don't check event.target, the handler runs for clicks anywhere in the parent — including on padding, borders, and other children. Always check event.target or use .closest().
stopPropagation breaks delegation and surprises other code. It should be rare — only when you truly need to isolate an event. If you're reaching for it often, the architecture is wrong.
If your script runs before the HTML is parsed, querySelector returns null and addEventListener throws. Put the script at the end of body, or use defer.
el.removeEventListener("click", () => {}) does nothing — that arrow function is a new reference. Store the handler in a variable first.
return false only works in inline onclick attributes. With addEventListener, it does nothing. Use event.preventDefault().
➡️ What's next
You can respond to users. Next: talk to servers and render real data.
❓ Frequently asked questions
What are JavaScript events?
Events are things that happen in the browser — a click, a keypress, a mouse movement, a form submission, a page load. JavaScript lets you listen for these events and run code when they occur. Every interactive feature on the web depends on events.
What is the difference between addEventListener and onclick?
onclick is a property that can only hold one handler — setting it again overwrites the previous one. addEventListener can attach multiple handlers to the same element and event, and lets you remove them with removeEventListener. Always use addEventListener.
What is event bubbling in JavaScript?
When an event fires on an element, it first runs on that element, then bubbles up through its parent, grandparent, and all ancestors up to the document. This is why a click on a button inside a div also triggers click handlers on the div. You can stop it with event.stopPropagation().
What is event delegation in JavaScript?
Instead of adding a listener to every child element, add one listener to a parent. When a child is clicked, the event bubbles up to the parent. Inside the handler, check event.target to see which child triggered it. This is faster, works for dynamically added elements, and uses less memory.
What does event.preventDefault() do?
It stops the browser's default action for an event. For a form submit, it prevents the page from reloading. For a link click, it prevents navigation. You call it inside the event handler: event.preventDefault(). Use it when you want to handle the action yourself.
What is the difference between event.target and event.currentTarget?
event.target is the element that actually triggered the event — the specific child clicked. event.currentTarget is the element the listener is attached to — the parent. In event delegation, currentTarget is the parent, target is the child.
How do I remove an event listener?
Use removeEventListener with the same function reference: el.removeEventListener('click', handler). You cannot remove an anonymous function — store it in a variable first. For one-time listeners, use the once option: el.addEventListener('click', handler, { once: true }).
What is the event object in JavaScript?
Every event handler receives an event object with details about what happened. It includes event.type, event.target, event.currentTarget, event.preventDefault(), event.stopPropagation(), and event.key for keyboard events. Log it in the console to see everything available.