⚡ 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 here (the listener is on the button itself)
// 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 and most shortcuts (so Ctrl + S follows the letter S on every layout), and code for game controls where the key's physical position matters, like WASD movement.
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("The inner and outer div handlers won't run");
});
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
⚠️ Not Every Event Bubbles
Delegation only works for events that bubble. click, input, change, submit, and keydown all bubble, but focus, blur, mouseenter, and mouseleave do not. If you need to delegate focus events, use their bubbling cousins focusin and focusout; for hover, use mouseover / mouseout.
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. If you plan to remove a listener later, use a named function (or store the function in a variable) and pass that same reference to both calls.
// ❌ Can't remove — no reference to the function
button.addEventListener("click", () => {
console.log("You can't remove me!");
});
// button.removeEventListener("click", ???); // What goes here?
// (Writing a new, identical-looking arrow function won't work —
// it's a different function object, so nothing is removed.)
// ✅ 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: #2563eb; 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: #64748b; }
.task-text { flex: 1; }
.complete-btn { background: #15803d; color: white; border: none;
border-radius: 4px; padding: 0.25rem 0.5rem; cursor: pointer; }
.delete-btn { background: #dc2626; color: white; border: none;
border-radius: 4px; padding: 0.25rem 0.5rem; cursor: pointer; }
.counter { text-align: center; color: #64748b; margin-top: 1rem; }
button:focus-visible, input:focus-visible { outline: 3px solid #2563eb; outline-offset: 2px; }
body.dark-mode { background: #0f172a; color: #e2e8f0; }
/* Cards need a dark background too, or light text lands on white */
body.dark-mode .task-item { background: #1e293b; }
body.dark-mode .task-item.done .task-text,
body.dark-mode .counter { color: #94a3b8; }
</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"></span>
// <button class="complete-btn" aria-label="Mark done">✅</button>
// <button class="delete-btn" aria-label="Delete task">🗑️</button>
// - Put the task text into the span with textContent
// (never paste user input into innerHTML — remember XSS!)
// - 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
// (skip it if Ctrl/Cmd/Alt is held or the user is typing in a field)
</script>
</body>
</html>
💡 Hint
1: Use form.addEventListener("submit", ...) with event.preventDefault(). Build the <li> with document.createElement, set its innerHTML to the fixed span-and-buttons markup, then fill the span with li.querySelector(".task-text").textContent = text. The user's text never goes through innerHTML.
2: Use taskList.addEventListener("click", ...) and check event.target.closest(".complete-btn") or event.target.closest(".delete-btn") (safer than matches() if a button ever gets an icon or other child element inside it). 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 skip it when a modifier key (Ctrl/Cmd/Alt) is held or the user is typing in a field.
✅ 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");
// Fixed markup only — no user data inside the template
li.innerHTML = `
<span class="task-text"></span>
<button class="complete-btn" aria-label="Mark done">✅</button>
<button class="delete-btn" aria-label="Delete task">🗑️</button>
`;
// User text goes in with textContent, so "<img onerror=...>" stays harmless text
li.querySelector(".task-text").textContent = text;
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.closest(".complete-btn")) {
taskItem.classList.toggle("done");
}
if (event.target.closest(".delete-btn")) {
taskItem.remove();
}
updateCounter();
});
// 4. Bonus: Keyboard support
taskInput.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
taskInput.value = "";
}
});
document.addEventListener("keydown", (event) => {
// Skip browser shortcuts (Ctrl+D bookmarks!) and typing in a field
if (event.ctrlKey || event.metaKey || event.altKey) return;
if (event.target.matches("input, textarea")) return;
if (event.key === "d") {
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?
Question 6: A delegated listener on a list checks event.target.matches(".delete-btn"), but clicking the 🗑️ <span> inside the button does nothing. What's the fix?
📓 Learning Journal
Take five minutes to add to your learning journal. Write down:
- Key concepts you learned
- What clicked — the idea or technique that made sense
- Questions or confusion, so you know what to revisit
- Ideas to try on your own pages
- How you feel about your progress
✍️ This lesson's prompt: Before you click, predict the order of the console messages when you click the button inside #inner in the bubbling example, then explain in your own words how event delegation uses that same bubbling to handle tasks that didn't exist when the page loaded. Name one thing on your own site that could respond to a click, key press, or form submit. What about event.target, preventDefault(), or delegation are you still unsure about?
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 - Delegation relies on bubbling —
focus,blur,mouseenter, andmouseleavedon't bubble - 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.