Event Listeners
addEventListener in depth — attaching, removing, options, and the event object.
1. Introduction
addEventListener is the core API for responding to events. While you have used it in previous lessons, this lesson goes deeper: removing listeners, the once and passive options, preventDefault, stopPropagation, this context, and common patterns like throttling and debouncing.
2. Theory
2.1 addEventListener syntax
element.addEventListener(eventType, handler, options);
// eventType: string, e.g. 'click', 'keydown', 'submit'
// handler: function to call when event fires
// options: boolean (capture) or object { capture, once, passive }
2.2 Named vs anonymous handlers
// Anonymous — cannot be removed later
btn.addEventListener('click', () => console.log('clicked'));
// Named — can be referenced and removed
function handleClick(e) {
console.log('clicked', e.target);
}
btn.addEventListener('click', handleClick);
btn.removeEventListener('click', handleClick); // works!
To remove a listener, you must pass the exact same function reference. Anonymous functions create a new reference each time, so they cannot be removed.
2.3 removeEventListener
function onScroll() {
if (window.scrollY > 500) {
console.log('Scrolled past 500px');
window.removeEventListener('scroll', onScroll); // only fire once
}
}
window.addEventListener('scroll', onScroll);
2.4 The once option
// Fire once then automatically remove
btn.addEventListener('click', handler, { once: true });
// Equivalent to:
btn.addEventListener('click', function h(e) {
handler(e);
btn.removeEventListener('click', h);
});
2.5 The passive option (performance)
// Tells browser: "this handler won't call preventDefault"
// Browser can optimise scrolling without waiting for JS
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('touchmove', onTouch, { passive: true });
// Improves scroll performance significantly on mobile
2.6 event.preventDefault()
Prevents the browser's default behaviour for the event:
// Prevent form from reloading the page on submit
form.addEventListener('submit', e => {
e.preventDefault();
const data = new FormData(form);
console.log(Object.fromEntries(data));
// Handle submission with JS instead
});
// Prevent link from navigating
link.addEventListener('click', e => {
e.preventDefault();
console.log('Link clicked but not navigated');
});
// Prevent right-click context menu
document.addEventListener('contextmenu', e => e.preventDefault());
2.7 event.stopPropagation()
// Stop event from bubbling up to parent elements
btn.addEventListener('click', e => {
e.stopPropagation(); // parent click handlers won't fire
console.log('Button clicked');
});
parent.addEventListener('click', () => {
console.log('Parent clicked — WON'T fire if button clicked');
});
2.8 this inside event handlers
// 'this' inside a regular function refers to the element
btn.addEventListener('click', function() {
this.textContent = 'Clicked!'; // 'this' is the button
});
// Arrow functions DON'T have their own 'this' — they inherit outer scope
btn.addEventListener('click', () => {
this.textContent = 'Clicked!'; // 'this' is NOT the button (usually window)
});
// Use e.currentTarget for the element in both cases
btn.addEventListener('click', e => {
e.currentTarget.textContent = 'Clicked!'; // always works
});
2.9 Debouncing — limit rapid-fire events
// Without debounce — fires on EVERY keystroke (too many calls)
input.addEventListener('input', searchAPI);
// With debounce — fires only after user STOPS typing for 300ms
function debounce(fn, delay) {
let timer;
return function(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
const debouncedSearch = debounce(searchAPI, 300);
input.addEventListener('input', debouncedSearch);
2.10 Throttling — cap event frequency
// Throttle — fire at most once per N ms regardless of trigger rate
function throttle(fn, limit) {
let lastCall = 0;
return function(...args) {
const now = Date.now();
if (now - lastCall >= limit) {
lastCall = now;
fn.apply(this, args);
}
};
}
const throttledScroll = throttle(updateNavbar, 100);
window.addEventListener('scroll', throttledScroll, { passive: true });
3. Real World Example
// Form with validation — preventDefault + named handlers
const form = document.querySelector('#signup-form');
const emailIn = form.querySelector('[name="email"]');
const passIn = form.querySelector('[name="password"]');
function validateEmail(e) {
const valid = /\S+@\S+\.\S+/.test(emailIn.value);
emailIn.classList.toggle('invalid', !valid);
}
function handleSubmit(e) {
e.preventDefault();
if (form.querySelector('.invalid')) {
return console.error('Fix errors first');
}
console.log('Submitting:', emailIn.value);
// POST to server here...
form.removeEventListener('submit', handleSubmit); // one-time submit
}
emailIn.addEventListener('blur', validateEmail);
emailIn.addEventListener('input', validateEmail);
form.addEventListener('submit', handleSubmit);
4. Code Example
<script>
// 1. Named handler with removeEventListener
const btn = document.querySelector('#btn');
const count = document.querySelector('#count');
let clicks = 0;
function handleClick() {
clicks++;
count.textContent = clicks;
if (clicks >= 5) {
btn.removeEventListener('click', handleClick);
btn.textContent = 'Limit reached';
btn.disabled = true;
}
}
btn.addEventListener('click', handleClick);
// 2. once option
document.querySelector('#once-btn').addEventListener('click', () => {
alert('This only fires once!');
}, { once: true });
// 3. preventDefault on form
document.querySelector('form').addEventListener('submit', e => {
e.preventDefault();
console.log('Form handled by JS — no page reload');
});
// 4. Debounced input
function debounce(fn, ms) {
let t;
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
}
document.querySelector('#search').addEventListener('input',
debounce(e => console.log('Search:', e.target.value), 400)
);
// 5. Passive scroll
window.addEventListener('scroll', () => {
document.querySelector('#scroll-pos').textContent = window.scrollY + 'px';
}, { passive: true });
</script>
5. Code Breakdown
Named handler + removeEventListener
Keeping a reference (handleClick) allows removing the listener after 5 clicks. Anonymous functions cannot be removed once added.
{ once: true }
The listener automatically detaches after firing once. Equivalent to calling removeEventListener inside the handler but far simpler.
e.preventDefault() on form submit
The default form submit action reloads the page (or navigates to the action URL). preventDefault() stops this so JavaScript can handle the data instead.
debounce pattern
Returns a new function that resets a timer on every call. Only when the timer completes (no new calls for 400 ms) does the original function run. Prevents searching the API on every single keystroke.
6. Common Mistakes
Mistake 1 — Trying to remove an anonymous listener
btn.addEventListener('click', () => doThing()); // anonymous
btn.removeEventListener('click', () => doThing()); // FAILS — different reference
// Fix: use named function
const handler = () => doThing();
btn.addEventListener('click', handler);
btn.removeEventListener('click', handler); // works
Mistake 2 — Calling preventDefault on non-cancellable events
// scroll, mousemove are not cancellable
window.addEventListener('scroll', e => e.preventDefault()); // no effect + warning
// Use CSS: overscroll-behavior, or pointer-events for non-scrollable areas
Mistake 3 — Memory leaks from unremoved listeners
If you add many listeners to DOM elements that are later removed from the page without removing their listeners, the elements and their closures stay in memory. Always clean up in single-page apps.
7. Best Practices
- Use named functions for any listener you might need to remove.
- Use { once: true } for one-time handlers instead of removing inside the handler.
- Use { passive: true } for scroll/touch listeners that don't call preventDefault.
- Always call preventDefault() on form submits when handling with JavaScript.
- Debounce input/resize/scroll listeners that trigger expensive operations.
- Use e.currentTarget rather than
thisin arrow-function handlers for reliability.
8. Practice Exercise
- Create a button that can be clicked exactly 3 times before becoming disabled. Use a named handler and removeEventListener.
- Create a search input that debounces 500ms and logs the value to the console.
- Create a form that validates on blur (each field individually) and prevents submit if any field is invalid.
9. Assignment
Build a "Smart Form" with real-time validation.
- Fields: username (min 3 chars), email, password (min 8, must contain a number), confirm password (must match).
- Validate on blur (when the user leaves a field) and on input (as they type).
- Prevent submit if any field is invalid. On valid submit, log values to console and show a success message.
- Debounce the username field's input event to check availability (simulated — just log "Checking...").
Deliverable: One HTML file.
10. Interview Questions
- How do you remove an event listener?
Call removeEventListener with the same event type AND the same function reference. Anonymous functions cannot be removed because each declaration creates a new reference. - What does event.preventDefault() do?
Cancels the browser's default action for that event — e.g., stops a form from submitting/reloading, stops a link from navigating, stops a checkbox from toggling. - What is debouncing?
A technique that delays executing a function until after a quiet period — e.g., wait until the user stops typing for 300ms before calling an API. Prevents a function from firing on every single event when events fire rapidly. - What is the passive option in addEventListener?
It tells the browser that the handler will never call preventDefault(), so the browser can optimise the event (especially scroll/touch) without waiting for JavaScript to finish. Improves scroll performance. - What is the difference between e.target and e.currentTarget?
e.target is the element that originally fired the event (the deepest element clicked). e.currentTarget is the element the listener is attached to. They differ when event bubbling is involved.
11. Additional Resources
- MDN — EventTarget.addEventListener() — full options reference
- MDN — Event.preventDefault()
- javascript.info — Event handling
- css-tricks.com — Debouncing and Throttling Explained