π±οΈ Event Listeners and Handlers
A web page that can't respond to the user is just a poster. Events are how JavaScript listens for the things people (and browsers) do β clicks, keystrokes, scrolls, form submissions β and runs your code in response. Master events and static pages become living applications.
Week 2 · Day 3 (Wednesday: Events and Event Handling) · Lecture 1
π― Learning Objectives
By the end of this lesson, you will be able to:
- Explain what an event is and trace the flow from user action to handler
- Attach and remove handlers with
addEventListenerand know why it beatsonclick - Read the event object to learn what happened, where, and how
- Handle the most common mouse, keyboard, form, and window events
- Use
preventDefault()and theoptionsobject (once,passive,capture) - Create and dispatch custom events, and clean up listeners to avoid memory leaks
Estimated Time: 65 minutes
Practice: Build a live character counter and a keyboard-driven shortcut handler.
In This Lesson
What Is an Event?
An event is a signal that something happened. Think of a doorbell: someone presses the button (the action), the bell rings (the event fires), and you decide what to do β answer, ignore, or peek through the window (your handler). The browser is constantly firing events as the user interacts with the page; your job is to listen for the ones you care about and respond.
Three pieces make this work, and it helps to name them precisely:
- Event target β the element the event happens on (a button, an input, the whole
window). - Event listener β the registration that says "when this event happens on this target, call that function."
- Event handler β the function that actually runs and does the work.
click, keypress, scroll] --> B[Browser Fires Event] B --> C[Listener Registered
with addEventListener] C --> D[Handler Function Runs] D --> E[Page Updates / Response]
Everything interactive you'll ever build β a menu that opens, a form that validates, a game that responds to arrow keys β is this loop repeated thousands of times a second. Let's learn how to hook into it.
Three Ways to Handle Events
Historically there have been three ways to attach a handler. Modern code uses exactly one of them β but you should recognize all three, because you'll meet them in the wild.
1. Inline HTML attributes (avoid)
<!-- Behavior tangled into markup β hard to reuse, hard to test -->
<button onclick="alert('Hello!')">Click me</button>
π This mixes JavaScript into your HTML, can only hold one handler, and is a security and maintainability headache. Fine for a five-second demo, wrong for real projects.
2. DOM event properties (onclick)
const button = document.querySelector('#save');
button.onclick = function () {
console.log('Button clicked!');
};
// The catch: assigning again OVERWRITES the first handler.
button.onclick = () => console.log('Only this one survives');
π Better than inline, but each element can hold only one onclick. A second assignment silently erases the first.
3. addEventListener (the modern standard)
const button = document.querySelector('#save');
button.addEventListener('click', () => {
console.log('Button clicked!');
});
// You can attach as MANY listeners as you like β they all run:
button.addEventListener('click', () => console.log('First handler'));
button.addEventListener('click', () => console.log('Second handler'));
ππ This is the one to use. It supports multiple handlers per event, works with every event type, can be removed later, and accepts an options object for finer control.
The options object
function handleClick() { console.log('clicked once'); }
button.addEventListener('click', handleClick, {
once: true, // auto-remove the listener after it fires one time
capture: false, // listen during the bubbling phase (the default)
passive: true // promise you won't call preventDefault() β speeds up scroll/touch
});
π Why addEventListener wins
It separates behavior from markup, lets multiple independent features respond to the same event without stepping on each other, and β crucially β pairs with removeEventListener so you can tear listeners down when a component goes away. Reach for it every time.
The Event Object
Every handler automatically receives one argument: the event object. It's a detailed report of what just happened β which element, where the mouse was, which key was pressed, and methods to change the browser's default reaction.
button.addEventListener('click', (event) => {
// What kind of event and where it came from
console.log(event.type); // "click"
console.log(event.target); // the exact element that was clicked
console.log(event.currentTarget); // the element the listener is attached to
console.log(event.timeStamp); // ms since the page loaded
// Mouse position (for mouse events)
console.log(event.clientX, event.clientY); // relative to the viewport
console.log(event.pageX, event.pageY); // relative to the whole document
// Modifier keys held during the event
console.log(event.shiftKey, event.ctrlKey, event.altKey, event.metaKey);
});
π‘ target vs currentTarget
event.target is where the event originated β the exact thing clicked, even if it's a child. event.currentTarget is the element whose listener is running. When you click an icon inside a button, target is the icon but currentTarget is the button. This distinction becomes essential for event delegation (next lesson).
Changing the browser's default: preventDefault()
Many elements have built-in behavior: links navigate, form submit buttons reload the page, right-clicks open a context menu. preventDefault() cancels that default so you can supply your own.
const link = document.querySelector('#details-link');
link.addEventListener('click', (event) => {
event.preventDefault(); // stop the browser from navigating away
console.log('Loading details without leaving the pageβ¦');
loadDetailsInPlace(link.href); // your custom behavior instead
});
β οΈ preventDefault() is not stopPropagation()
preventDefault() cancels the browser's built-in action but the event still travels through the DOM. stopPropagation() stops the event from reaching other elements but does not cancel default behavior. They solve different problems β we cover propagation in depth in the next lesson.
Mouse & Keyboard Events
These are the events you'll reach for constantly. Here are the ones worth memorizing.
Mouse events
| Event | Fires when⦠|
|---|---|
click | The element is pressed and released (also fires on keyboard "activation") |
dblclick | Two quick clicks land on the element |
contextmenu | The user right-clicks (call preventDefault() to replace the menu) |
mouseenter / mouseleave | The pointer enters/leaves the element (does not bubble β clean for hover) |
mousemove | The pointer moves over the element (fires a lot β throttle it) |
mousedown / mouseup | A button is pressed / released (the two halves of a click) |
const box = document.querySelector('.interactive');
box.addEventListener('click', (e) => {
console.log('Clicked at', e.clientX, e.clientY);
});
box.addEventListener('mouseenter', () => box.classList.add('is-hovered'));
box.addEventListener('mouseleave', () => box.classList.remove('is-hovered'));
// Right-click: replace the native menu with your own
box.addEventListener('contextmenu', (e) => {
e.preventDefault();
openCustomMenu(e.pageX, e.pageY);
});
Keyboard events
Use keydown for shortcuts and navigation. The modern property to read is event.key β a human-readable string like "Enter", "Escape", "ArrowUp", or "a". (Avoid the deprecated event.keyCode.)
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeModal();
// A "save" shortcut: Ctrl+S (or Cmd+S on Mac)
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault(); // stop the browser's Save dialog
saveDocument();
}
// Arrow-key navigation
switch (e.key) {
case 'ArrowUp': moveSelection(-1); break;
case 'ArrowDown': moveSelection(1); break;
}
});
π‘ input vs keydown for text
To react to what a user is typing, prefer the input event β it fires after the field's value actually changes, and it also catches paste, autofill, and mobile keyboards. Save keydown for shortcuts and navigation, where you need the specific key.
A practical example: live password strength
const password = document.querySelector('#password');
const meter = document.querySelector('#strength');
password.addEventListener('input', (e) => {
const value = e.target.value;
let score = 0;
if (value.length >= 8) score++;
if (/[a-z]/.test(value) && /[A-Z]/.test(value)) score++;
if (/[0-9]/.test(value)) score++;
if (/[^a-zA-Z0-9]/.test(value)) score++;
const labels = ['Too weak', 'Weak', 'Fair', 'Good', 'Strong'];
meter.textContent = labels[score];
meter.className = `strength-${score}`;
});
Form & Window Events
Forms and the window itself fire their own useful events. We'll go deep on forms next lesson; here's the essential vocabulary.
Form events
| Event | Fires when⦠|
|---|---|
submit | The form is submitted (attach to the <form>, not the button) |
input | A field's value changes, on every keystroke |
change | A field's value is committed (e.g. blur, or a select choice) |
focus / blur | A field gains / loses keyboard focus |
const form = document.querySelector('#signup');
form.addEventListener('submit', (e) => {
e.preventDefault(); // don't reload the page
const data = new FormData(form); // gather every named field
for (const [name, value] of data.entries()) {
console.log(`${name}: ${value}`);
}
// β¦validate and send with fetch()
});
Window & document events
// The DOM is parsed and ready (scripts with defer already run after this)
document.addEventListener('DOMContentLoaded', () => {
console.log('DOM ready β safe to query elements');
});
// A sticky header on scroll (throttle in real code β see the last section)
window.addEventListener('scroll', () => {
document.querySelector('header')
.classList.toggle('sticky', window.scrollY > 100);
});
// Warn about unsaved changes before leaving
window.addEventListener('beforeunload', (e) => {
if (hasUnsavedChanges()) {
e.preventDefault();
e.returnValue = ''; // required for the browser to show its prompt
}
});
β
Attach submit to the form, not the button
Listening for submit on the <form> catches every way a user submits β clicking the button and pressing Enter in a field. Listening for click on the button misses the keyboard path.
Custom Events & Cleanup
Making your own events
You aren't limited to built-in events. CustomEvent lets one part of your app announce that something happened and carry data along in a detail property β a clean way for components to talk without tightly coupling them.
// Create an event that carries a payload
const cartEvent = new CustomEvent('cart:add', {
detail: { productId: 42, quantity: 2 },
bubbles: true // let it travel up the DOM like a native event
});
// Dispatch it from any element
document.querySelector('#add-to-cart').dispatchEvent(cartEvent);
// Anywhere else, listen for it
document.addEventListener('cart:add', (e) => {
console.log(`Added ${e.detail.quantity} of product ${e.detail.productId}`);
updateCartBadge();
});
Removing listeners & avoiding memory leaks
Every listener holds a reference to its handler and its element. If you add listeners for a component and never remove them, the browser can't garbage-collect that memory β a classic leak in long-lived apps. To remove a listener you must pass the same function reference you added (so anonymous inline functions can't be removed).
function handleResize() {
console.log('Window is', window.innerWidth, 'px wide');
}
// Add with a named referenceβ¦
window.addEventListener('resize', handleResize);
// β¦then remove the exact same reference later:
window.removeEventListener('resize', handleResize);
// Modern shortcut: AbortController removes MANY listeners at once
const controller = new AbortController();
button.addEventListener('click', doThing, { signal: controller.signal });
input.addEventListener('input', doOther, { signal: controller.signal });
controller.abort(); // detaches both listeners in one call
β οΈ Anonymous handlers can't be removed
el.addEventListener('click', () => {...}) creates a brand-new function every call, so there's no reference to hand to removeEventListener. If a listener needs to be removed, give the handler a name or use an AbortController signal.
Practice & Quiz
ποΈ Exercise 1: Live character counter
Goal: Given a <textarea id="bio"> and a <span id="count">, show how many of a 200-character budget remain, and turn the counter red when fewer than 20 remain.
const bio = document.querySelector('#bio');
const count = document.querySelector('#count');
const MAX = 200;
// TODO: on every input, update #count and toggle a warning
π‘ Hint
Listen for the input event (not keydown β it catches paste too). The remaining count is MAX - bio.value.length. Toggle a class with count.classList.toggle('warn', remaining < 20).
β Solution
bio.addEventListener('input', () => {
const remaining = MAX - bio.value.length;
count.textContent = `${remaining} characters left`;
count.classList.toggle('warn', remaining < 20);
});
ποΈ Exercise 2: Keyboard shortcut handler
Goal: Log "Savingβ¦" on Ctrl+S (or Cmd+S) without triggering the browser's Save dialog, and log "Closing" on Escape.
β Solution
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
e.preventDefault(); // block the native Save dialog
console.log('Savingβ¦');
}
if (e.key === 'Escape') {
console.log('Closing');
}
});
π― Quick Quiz
Question 1: Why is addEventListener('click', fn) preferred over element.onclick = fn?
Question 2: Inside a click handler on a button, you click an icon inside that button. What is event.target?
Question 3: Which call stops a form's submit button from reloading the page?
Best Practices & Pitfalls
β Do
- Use
addEventListenerand keep JavaScript out of your HTML - Read
event.key(not the deprecatedkeyCode) for keyboard events - Attach
submitto the<form>so Enter and clicks both work - Throttle or debounce high-frequency events (
scroll,mousemove,resize,input) - Remove listeners you no longer need β name your handlers or use
AbortController
β Don't
- Sprinkle
onclick="..."attributes through your markup - Assume anonymous handlers can be removed later β they can't
- Do heavy work directly inside a
scrollormousemovehandler - Forget
preventDefault()onsubmithandlers when you fetch instead of reload
β οΈ Throttle the firehose
// A tiny throttle: run at most once per `limit` ms
function throttle(fn, limit) {
let waiting = false;
return function (...args) {
if (waiting) return;
fn.apply(this, args);
waiting = true;
setTimeout(() => (waiting = false), limit);
};
}
window.addEventListener('scroll', throttle(updateHeader, 100));
Events like scroll and mousemove can fire dozens of times per second. Doing layout work on every one janks the page β throttle it.
Summary
π Key Takeaways
- An event flows from user action β fired event β listener β handler β response
- Use
addEventListener; it supports multiple handlers, options, and removal - The event object tells you the type, target, position, and keys β and lets you call
preventDefault() event.targetis where it happened;event.currentTargetis where the listener lives- Prefer
inputfor typing,submiton the form, andevent.keyfor the keyboard - Clean up listeners β name handlers or use
AbortControllerβ to avoid memory leaks
π Additional Resources
- MDN β EventTarget.addEventListener()
- MDN β The Event interface
- MDN β Event reference (every event type)
π What's Next?
You can now attach a handler to any element. But events don't stay put β they travel through the DOM in phases. Next up, Event Propagation and Delegation, where you'll learn to handle events for hundreds of elements with a single listener.
π Great work!
Your pages can now respond to the people using them. That's the heart of front-end development.