⚡ Lesson 15: Events & Event Listeners
Everything you've learned so far runs once when the page loads. Events are what make pages respond — to clicks, key presses, form submissions, and more. This is where JavaScript gets truly interactive.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Attach event listeners to elements with
addEventListener - Handle common events: click, input, submit, keydown, and more
- Use the
eventobject to access event details - Prevent default browser behavior with
preventDefault() - Explain event propagation — bubbling and capturing
- Use event delegation to handle events on dynamic content
- Remove event listeners when they're no longer needed
Estimated Time: 55 minutes
Project: Build an interactive task list with event delegation
📑 In This Lesson
What Are Events?
An event is something that happens in the browser — a user clicks a button, types in a text field, hovers over an image, scrolls the page, or submits a form. JavaScript lets you listen for these events and respond with code.
Think of it like a doorbell: the event is someone pressing the button, and the event listener is the code that runs when it rings — maybe you open the door, maybe you check a camera, maybe you ignore it. You decide what happens.
(click, type, scroll)"] --> B["Browser Fires
an Event"] B --> C["Your Listener
Catches It"] C --> D["Your Code
Responds"] style A fill:#fef3c7,stroke:#f59e0b,stroke-width:2px,color:#1e293b style B fill:#eff6ff,stroke:#3b82f6,stroke-width:2px,color:#1e293b style C fill:#ecfdf5,stroke:#22c55e,stroke-width:2px,color:#1e293b style D fill:#fce7f3,stroke:#ec4899,stroke-width:2px,color:#1e293b
addEventListener()
The modern way to listen for events is addEventListener. It takes two required arguments: the event type (a string) and a callback function (what to do when it happens).
const button = document.querySelector("#my-button");
// Basic pattern: element.addEventListener(eventType, callback)
button.addEventListener("click", function () {
console.log("Button was clicked!");
});
Using Arrow Functions
Arrow functions are a clean, common choice for event callbacks.
button.addEventListener("click", () => {
console.log("Clicked with an arrow function!");
});
Using Named Functions
Named functions are useful when you want to reuse the same handler or remove it later.
function handleClick() {
console.log("Button clicked!");
}
button.addEventListener("click", handleClick);
// ❌ Common mistake — this CALLS the function immediately!
// button.addEventListener("click", handleClick());
// Pass the function reference, don't call it
⚠️ Why Not Inline Handlers?
You might see older code with onclick="..." directly in the HTML. This approach mixes HTML and JavaScript, only allows one handler per event, and is harder to maintain. Always use addEventListener instead.
<!-- ❌ Old-school inline handler — avoid this -->
<button onclick="alert('clicked!')">Click Me</button>
<!-- ✅ Clean HTML + addEventListener in your script -->
<button id="my-btn">Click Me</button>
Common Event Types
Mouse Events
const box = document.querySelector(".box");
box.addEventListener("click", () => {
console.log("Clicked!");
});
box.addEventListener("dblclick", () => {
console.log("Double-clicked!");
});
box.addEventListener("mouseenter", () => {
box.style.backgroundColor = "#eff6ff";
});
box.addEventListener("mouseleave", () => {
box.style.backgroundColor = "";
});
Keyboard Events
document.addEventListener("keydown", (event) => {
console.log(`Key pressed: ${event.key}`);
});
document.addEventListener("keyup", (event) => {
console.log(`Key released: ${event.key}`);
});
// Listen on a specific input
const searchInput = document.querySelector("#search");
searchInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
console.log("Search submitted:", searchInput.value);
}
});
Form & Input Events
const input = document.querySelector("#username");
// Fires on every keystroke / change
input.addEventListener("input", (event) => {
console.log("Current value:", event.target.value);
});
// Fires when the field loses focus (after editing)
input.addEventListener("change", (event) => {
console.log("Final value:", event.target.value);
});
// Fires when the input gains focus
input.addEventListener("focus", () => {
input.style.outline = "2px solid #3b82f6";
});
// Fires when the input loses focus
input.addEventListener("blur", () => {
input.style.outline = "";
});
Page & Window Events
// When the entire page has loaded
window.addEventListener("load", () => {
console.log("Page fully loaded (including images)");
});
// When the DOM is ready (before images finish loading)
document.addEventListener("DOMContentLoaded", () => {
console.log("DOM is ready!");
});
// When the user scrolls
window.addEventListener("scroll", () => {
console.log("Scroll position:", window.scrollY);
});
| Category | Event | When It Fires |
|---|---|---|
| Mouse | click | Element is clicked |
| Mouse | dblclick | Element is double-clicked |
| Mouse | mouseenter / mouseleave | Pointer enters / leaves element |
| Keyboard | keydown / keyup | Key is pressed / released |
| Form | input | Value changes (every keystroke) |
| Form | change | Value changes (on blur / commit) |
| Form | submit | Form is submitted |
| Focus | focus / blur | Element gains / loses focus |
| Page | DOMContentLoaded | DOM tree is built |
| Page | load | Everything has loaded |
| Page | scroll | User scrolls the page |
The Event Object
Every event handler receives an event object as its first argument. This object contains details about what happened — which element was clicked, which key was pressed, where the mouse was, and more.
button.addEventListener("click", (event) => {
// The element that was clicked
console.log(event.target); // <button id="my-button">...</button>
// The element the listener is attached to
console.log(event.currentTarget); // Same as above (usually)
// The type of event
console.log(event.type); // "click"
// Mouse position relative to the viewport
console.log(event.clientX, event.clientY);
// Timestamp
console.log(event.timeStamp);
});
event.target vs. event.currentTarget
This distinction matters when an event bubbles up from a child element.
// HTML: <div id="card"><button>Click</button></div>
const card = document.querySelector("#card");
card.addEventListener("click", (event) => {
// If the BUTTON inside the card is clicked:
console.log(event.target); // <button> (what was actually clicked)
console.log(event.currentTarget); // <div id="card"> (where the listener lives)
});
Keyboard Event Properties
document.addEventListener("keydown", (event) => {
console.log(event.key); // "a", "Enter", "ArrowUp", "Shift"
console.log(event.code); // "KeyA", "Enter", "ArrowUp", "ShiftLeft"
console.log(event.shiftKey); // true if Shift was held
console.log(event.ctrlKey); // true if Ctrl was held
console.log(event.altKey); // true if Alt was held
console.log(event.metaKey); // true if Cmd (Mac) / Win key was held
// Check for keyboard shortcuts
if (event.ctrlKey && event.key === "s") {
event.preventDefault(); // Stop the browser from saving
console.log("Custom save!");
}
});
💡 event.key vs. event.code
event.key gives the character produced (e.g., "a" or "A" with Shift). event.code gives the physical key ("KeyA" regardless of Shift or keyboard layout). Use key for character input, code for game controls or shortcuts that should work on any keyboard layout.
Preventing Default Behavior
Many events have a default action — clicking a link navigates to a URL, submitting a form reloads the page, pressing space scrolls down. preventDefault() cancels that default behavior so you can handle it yourself.
Forms — The Most Common Use Case
const form = document.querySelector("#signup-form");
form.addEventListener("submit", (event) => {
// Stop the page from reloading
event.preventDefault();
// Now handle the form data with JavaScript
const name = document.querySelector("#name").value;
const email = document.querySelector("#email").value;
console.log("Submitted:", { name, email });
});
Links
const link = document.querySelector("a.internal");
link.addEventListener("click", (event) => {
event.preventDefault(); // Don't navigate
console.log("Link clicked but not followed:", link.href);
// Handle navigation with JavaScript instead
});
Other Defaults
// Prevent right-click context menu
document.addEventListener("contextmenu", (event) => {
event.preventDefault();
console.log("Custom right-click menu would go here");
});
// Prevent spacebar from scrolling when a game is active
document.addEventListener("keydown", (event) => {
if (event.key === " " && gameIsActive) {
event.preventDefault(); // Stop scrolling
jump(); // Do game action instead
}
});
⚠️ Don't Overuse preventDefault()
Blocking default behavior can hurt usability and accessibility. Don't prevent right-click, text selection, or standard keyboard shortcuts without good reason. Users expect standard browser behavior to work.
Event Propagation
When you click a button inside a <div> inside the <body>, the click event doesn't just fire on the button — it travels through the DOM. This is called propagation, and it has three phases.
Event travels DOWN
document → html → body → div → button"] --> B["2. TARGET PHASE
Event reaches the
clicked element"] B --> C["3. BUBBLING PHASE
Event travels UP
button → div → body → html → document"] style A fill:#eff6ff,stroke:#3b82f6,stroke-width:2px,color:#1e293b style B fill:#ecfdf5,stroke:#22c55e,stroke-width:2px,color:#1e293b style C fill:#fef3c7,stroke:#f59e0b,stroke-width:2px,color:#1e293b
Bubbling (Default Behavior)
By default, event listeners fire during the bubbling phase — the event starts at the target and bubbles up through each ancestor.
// HTML: <div id="outer"><div id="inner"><button>Click</button></div></div>
document.querySelector("#outer").addEventListener("click", () => {
console.log("Outer div");
});
document.querySelector("#inner").addEventListener("click", () => {
console.log("Inner div");
});
document.querySelector("button").addEventListener("click", () => {
console.log("Button");
});
// Clicking the button logs:
// "Button" ← target
// "Inner div" ← bubbles up
// "Outer div" ← bubbles up more
stopPropagation()
If you want to stop the event from bubbling up to parent elements, use stopPropagation().
document.querySelector("button").addEventListener("click", (event) => {
event.stopPropagation(); // Stop here — don't bubble up
console.log("Only the button handler runs");
});
Capturing Phase
You can listen during the capturing phase (top-down) by passing a third argument. This is rarely needed, but good to know.
// Third argument: { capture: true } or just true
document.querySelector("#outer").addEventListener("click", () => {
console.log("Outer (capture phase — fires FIRST)");
}, true);
document.querySelector("button").addEventListener("click", () => {
console.log("Button (normal bubbling phase)");
});
// Clicking the button:
// "Outer (capture phase — fires FIRST)"
// "Button (normal bubbling phase)"
💡 When to Use stopPropagation()
Use stopPropagation() sparingly. It can break event delegation (covered next) and make debugging harder. Usually, checking event.target in your handler is a better approach than stopping propagation entirely.
Event Delegation
Event delegation is a powerful pattern that takes advantage of bubbling. Instead of attaching a listener to every child element, you attach one listener to the parent and use event.target to figure out which child was clicked.
The Problem
// ❌ Adding a listener to every button — doesn't work for new buttons!
const buttons = document.querySelectorAll(".item-btn");
buttons.forEach(btn => {
btn.addEventListener("click", () => {
console.log("Item clicked:", btn.textContent);
});
});
// If you add a new button later, it won't have a listener
// You'd have to manually attach one every time
The Solution: Delegation
// ✅ One listener on the parent — works for current AND future children
const list = document.querySelector("#task-list");
list.addEventListener("click", (event) => {
// Check if the clicked element is a button
if (event.target.matches(".item-btn")) {
console.log("Item clicked:", event.target.textContent);
}
});
// New buttons added later automatically work!
const newBtn = document.createElement("button");
newBtn.classList.add("item-btn");
newBtn.textContent = "New Task";
list.appendChild(newBtn);
// Clicking this new button triggers the same handler
Using closest() for Nested Targets
Sometimes the click target is a child of the element you care about (like an icon inside a button). Use closest() to find the nearest ancestor matching a selector.
// HTML: <button class="delete-btn"><span class="icon">🗑️</span> Delete</button>
list.addEventListener("click", (event) => {
// event.target might be the <span> icon, not the button
const deleteBtn = event.target.closest(".delete-btn");
if (deleteBtn) {
const taskId = deleteBtn.dataset.taskId;
console.log("Delete task:", taskId);
}
});
to PARENT element"] B --> C["2. Event bubbles up
from clicked child"] C --> D["3. Check event.target
or use closest()"] D --> E["4. Run handler only
if it matches"] style A fill:#eff6ff,stroke:#3b82f6,stroke-width:2px,color:#1e293b style B fill:#ecfdf5,stroke:#22c55e,stroke-width:2px,color:#1e293b style C fill:#fef3c7,stroke:#f59e0b,stroke-width:2px,color:#1e293b style D fill:#fce7f3,stroke:#ec4899,stroke-width:2px,color:#1e293b style E fill:#f3e8ff,stroke:#a855f7,stroke-width:2px,color:#1e293b
✅ Why Event Delegation Is Great
- Works with dynamic content — new elements automatically get handled
- Better performance — one listener instead of hundreds
- Less code — no need to loop through elements
- Easier cleanup — only one listener to remove
Removing Event Listeners
Sometimes you need to stop listening — maybe a button should only work once, or you're cleaning up when a component is removed. Use removeEventListener with a reference to the same function.
function handleClick() {
console.log("This only fires once!");
button.removeEventListener("click", handleClick);
}
button.addEventListener("click", handleClick);
The { once: true } Shortcut
If you want a listener to fire only once, pass { once: true } as the third argument. The browser removes it automatically after it fires.
button.addEventListener("click", () => {
console.log("This only runs once, then auto-removes!");
}, { once: true });
❌ Common Pitfall: Anonymous Functions Can't Be Removed
You can't remove an anonymous function because there's no reference to it. Always use named functions if you plan to remove a listener later.
// ❌ Can't remove — no reference to the function
button.addEventListener("click", () => {
console.log("You can't remove me!");
});
button.removeEventListener("click", ???); // What goes here?
// ✅ Named function — can be removed
function handleClick() {
console.log("You CAN remove me!");
}
button.addEventListener("click", handleClick);
button.removeEventListener("click", handleClick); // Works!
AbortController — Remove Multiple Listeners at Once
For more complex cleanup, use an AbortController. When you abort it, all listeners associated with its signal are removed at once.
const controller = new AbortController();
button.addEventListener("click", handleClick, { signal: controller.signal });
input.addEventListener("input", handleInput, { signal: controller.signal });
window.addEventListener("scroll", handleScroll, { signal: controller.signal });
// Later: remove ALL three listeners at once
controller.abort();
Hands-on Exercise
🏋️ Exercise: Interactive Task List
Objective: Build a task list that uses event delegation to handle adding, completing, and deleting tasks. Create a new HTML file or use the console.
<!-- Save this as tasks.html and open in a browser -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Task List</title>
<style>
body { font-family: sans-serif; padding: 2rem; background: #f1f5f9; }
.task-app { max-width: 500px; margin: 0 auto; }
.add-form { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
.add-form input {
flex: 1; padding: 0.5rem; border: 2px solid #cbd5e1;
border-radius: 6px; font-size: 1rem;
}
.add-form button {
padding: 0.5rem 1rem; background: #3b82f6; color: white;
border: none; border-radius: 6px; cursor: pointer; font-size: 1rem;
}
#task-list { list-style: none; padding: 0; }
.task-item {
display: flex; align-items: center; gap: 0.5rem;
padding: 0.75rem; background: white; border-radius: 8px;
margin-bottom: 0.5rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.task-item.done .task-text { text-decoration: line-through; color: #94a3b8; }
.task-text { flex: 1; }
.complete-btn { background: #22c55e; color: white; border: none;
border-radius: 4px; padding: 0.25rem 0.5rem; cursor: pointer; }
.delete-btn { background: #ef4444; color: white; border: none;
border-radius: 4px; padding: 0.25rem 0.5rem; cursor: pointer; }
.counter { text-align: center; color: #64748b; margin-top: 1rem; }
</style>
</head>
<body>
<div class="task-app">
<h1>📋 Task List</h1>
<form class="add-form" id="add-form">
<input type="text" id="task-input" placeholder="Add a new task..."
aria-label="New task" required>
<button type="submit">Add</button>
</form>
<ul id="task-list"></ul>
<p class="counter" id="counter">0 tasks</p>
</div>
<script>
// TODO: Complete each task
// 1. Listen for the form "submit" event
// - Prevent the default form submission
// - Get the input value (trim whitespace)
// - If empty, return early
// - Create a new <li class="task-item"> with this HTML inside:
// <span class="task-text">{task}</span>
// <button class="complete-btn">✅</button>
// <button class="delete-btn">🗑️</button>
// - Append it to #task-list
// - Clear the input and refocus it
// - Update the counter
// 2. Use event delegation on #task-list to handle clicks:
// - If a .complete-btn is clicked: toggle the "done" class
// on the parent .task-item
// - If a .delete-btn is clicked: remove the parent .task-item
// - Update the counter after each action
// 3. Write an updateCounter() function that counts the <li>
// elements in #task-list and updates #counter text
// 4. Bonus: Add keyboard support
// - Press Escape in the input to clear it
// - Listen on the document for "d" key to toggle
// a "dark-mode" class on the body
</script>
</body>
</html>
💡 Hint
1: Use form.addEventListener("submit", ...) with event.preventDefault(). Build the <li> with document.createElement and set its innerHTML.
2: Use taskList.addEventListener("click", ...) and check event.target.matches(".complete-btn") or event.target.matches(".delete-btn"). Use event.target.closest(".task-item") to find the parent.
3: Use taskList.querySelectorAll("li").length to count tasks.
4: Listen for keydown on the input for Escape, and on document for the "d" key (but only if the input isn't focused).
✅ Solution
const form = document.querySelector("#add-form");
const taskInput = document.querySelector("#task-input");
const taskList = document.querySelector("#task-list");
const counter = document.querySelector("#counter");
// Helper: update the task counter
function updateCounter() {
const total = taskList.querySelectorAll("li").length;
const done = taskList.querySelectorAll("li.done").length;
counter.textContent = `${total} task${total !== 1 ? "s" : ""} (${done} done)`;
}
// 1. Handle form submission
form.addEventListener("submit", (event) => {
event.preventDefault();
const text = taskInput.value.trim();
if (!text) return;
const li = document.createElement("li");
li.classList.add("task-item");
li.innerHTML = `
<span class="task-text">${text}</span>
<button class="complete-btn">✅</button>
<button class="delete-btn">🗑️</button>
`;
taskList.appendChild(li);
taskInput.value = "";
taskInput.focus();
updateCounter();
});
// 2. Event delegation for complete and delete
taskList.addEventListener("click", (event) => {
const taskItem = event.target.closest(".task-item");
if (!taskItem) return;
if (event.target.matches(".complete-btn")) {
taskItem.classList.toggle("done");
}
if (event.target.matches(".delete-btn")) {
taskItem.remove();
}
updateCounter();
});
// 4. Bonus: Keyboard support
taskInput.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
taskInput.value = "";
}
});
document.addEventListener("keydown", (event) => {
// Only toggle dark mode if not typing in the input
if (event.key === "d" && document.activeElement !== taskInput) {
document.body.classList.toggle("dark-mode");
}
});
🎯 Quick Quiz
Question 1: What does addEventListener require as its two arguments?
Question 2: What is event.target?
Question 3: What does event.preventDefault() do?
Question 4: What is the main advantage of event delegation?
Question 5: How can you make a listener fire only once?
Summary
🎉 Key Takeaways
addEventListener(type, callback)is the modern way to respond to user interactions- Common events include
click,input,submit,keydown,focus,blur, andscroll - The event object provides details like
target,key,clientX/clientY, and modifier keys preventDefault()stops the browser's default action (like form submission or link navigation)- Events bubble up from child to parent — this is normal and useful
- Event delegation puts one listener on a parent to handle all children, including dynamically added ones
- Use
event.target.matches()orclosest()in delegation to identify which child was clicked - Use named functions if you need to remove a listener later, or use
{ once: true }for one-shot listeners AbortControllercan remove multiple listeners at once with a singleabort()call
📚 Additional Resources
- MDN — addEventListener()
- MDN — Introduction to Events
- MDN — Event Interface
- javascript.info — Browser Events
- javascript.info — Event Delegation
🚀 What's Next?
You can now respond to anything a user does on the page. But what about adding entirely new elements — building lists, creating cards, or removing items dynamically? The next lesson covers creating and removing elements, completing your DOM toolkit.