JavaScript Events
An event is a signal that something has happened in the browser — a user clicked a button, a key was pressed, an image finished loading, or a form was submitted. JavaScript’s event system lets you write code that reacts to these moments. Almost every interactive web page — menus, forms, drag-and-drop, animations — is built on top of events, so understanding how they work, in what order handlers fire, and how to avoid the common traps is essential for any front-end developer.
Overview: How Events Work
The browser is constantly watching the page for things happening: mouse movement, clicks, key presses, network responses, timers firing, and more. Whenever one of these things happens, the browser creates an event object describing it (what kind of event, which element it happened on, and extra details like mouse coordinates or the key pressed) and dispatches it to the relevant DOM element.
By default, nothing happens when an event fires — you have to explicitly tell JavaScript "when this event happens on this element, run this function." That function is called an event handler or event listener, and the act of registering it is done with the addEventListener() method, which every DOM node inherits from EventTarget.
Internally, each DOM node keeps an internal list of listeners keyed by event type (click, keydown, submit, and so on). When a matching event is dispatched, the browser calls every registered listener for that type, in the order they were added, passing each one the same event object. This means you can attach multiple listeners to the same element for the same event, and all of them will run — unlike the older element.onclick = fn style, which can only ever hold one function at a time (assigning a second one silently replaces the first).
Event Flow: Capturing and Bubbling
When an event happens on a nested element (say, a button inside a div inside the body), the event doesn’t just fire on that one element — it travels through the DOM tree in three phases:
- Capturing phase — the event starts at the
window/documentand travels down toward the target element, passing through each ancestor. - Target phase — the event fires on the actual element that triggered it.
- Bubbling phase — the event then travels back up from the target through every ancestor, all the way to the
window.
By default, addEventListener() registers a listener for the bubbling phase. You can opt into the capturing phase by passing true (or { capture: true }) as the third argument. Most real-world code relies on bubbling, because it enables a powerful pattern called event delegation (covered below).
Syntax
target.addEventListener(type, listener, options);
target.removeEventListener(type, listener, options);
- target — any
EventTarget: a DOM element,document, orwindow. - type — a string naming the event, without the
onprefix, e.g.'click','keydown','submit','input'. - listener — the function to run. It receives one argument: the event object.
- options (optional) — either a boolean (shorthand for
capture) or an object with:capture— listen during the capturing phase instead of bubbling.once— automatically remove the listener after it fires once.passive— promises the listener will never callpreventDefault(), letting the browser optimize scrolling performance (important fortouchstart/wheel).
To stop listening, call removeEventListener() with the same function reference and options that were used to add it — anonymous functions can never be removed this way, since you have no reference to pass back.
Examples
Example 1: A Basic Click Listener
const button = document.querySelector('#saveButton');
function handleClick(event) {
console.log('Button clicked!');
console.log('Event type:', event.type);
}
button.addEventListener('click', handleClick);
Output (after the user clicks the button):
Button clicked!
Event type: click
The named function handleClick is registered once and runs every time the button is clicked. The event parameter is automatically supplied by the browser and carries details about what happened — here we simply read its type property.
Example 2: Reading Event Details and Preventing Default Behavior
Many events have a default action — submitting a form reloads the page, clicking a link navigates away. Calling event.preventDefault() cancels that default action while still letting your own code run.
const form = document.querySelector('#signupForm');
form.addEventListener('submit', (event) => {
event.preventDefault();
const formData = new FormData(form);
console.log('Form submission blocked. Email:', formData.get('email'));
});
Output (assuming the email field contains someone@example.com when submitted):
Form submission blocked. Email: someone@example.com
Without preventDefault(), the browser would immediately navigate to the form’s action URL, and the console.log would never get a chance to matter to the user. This pattern is the backbone of every JavaScript-validated or AJAX-submitted form.
Example 3: Event Delegation
Instead of attaching a listener to every single item in a list (which also fails for items added later), attach one listener to a shared parent and inspect event.target to see which child was actually clicked.
const list = document.querySelector('#todoList');
list.addEventListener('click', (event) => {
if (event.target.matches('li')) {
console.log(`You clicked: ${event.target.textContent}`);
console.log('currentTarget is the list:', event.currentTarget === list);
}
});
Output (after clicking a list item with the text "Buy milk"):
You clicked: Buy milk
currentTarget is the list: true
This works because of bubbling: the click starts on the <li> and travels up through the <ul>, where our single listener catches it. event.target is always the exact element the user interacted with, while event.currentTarget is the element the listener is attached to — here, the list itself. Delegation means new <li> elements added later automatically work too, with zero extra code.
Under the Hood: Tracing the Event Flow
Consider a click inside a nested structure with listeners registered on both an outer and inner element, one in the capturing phase:
const outer = document.querySelector('#outer');
const inner = document.querySelector('#inner');
outer.addEventListener('click', () => console.log('outer (capture)'), true);
inner.addEventListener('click', () => console.log('inner (target)'));
outer.addEventListener('click', () => console.log('outer (bubble)'), false);
Output (clicking on #inner):
outer (capture)
inner (target)
outer (bubble)
Step by step, the browser: (1) starts the capturing phase at the document root and walks down, firing outer‘s capture listener as it passes through; (2) reaches the target, #inner, and fires its listener; (3) begins the bubbling phase, walking back up and firing outer‘s bubble listener. If event.stopPropagation() were called inside the target listener, the outer bubble listener would never run — the event stops traveling any further along its path.
Common Mistakes
Mistake 1: Trying to remove an anonymous listener.
element.addEventListener('click', () => console.log('one'));
element.removeEventListener('click', () => console.log('one'));
This looks like it should work, but each arrow function expression creates a brand-new function object. removeEventListener compares by reference, so the second arrow function is a completely different object from the first — the listener is never actually removed. The fix is to keep a named reference to the same function:
function logClick() {
console.log('one');
}
element.addEventListener('click', logClick);
element.removeEventListener('click', logClick);
Mistake 2: Assuming this works the same in arrow function listeners. Inside a regular function listener, this refers to the element the listener is attached to. Inside an arrow function listener, this is inherited from the surrounding scope (often window or undefined in strict mode), not the element. If your handler relies on this to reference the clicked element, use event.currentTarget instead of this, or use a regular function declaration.
Mistake 3: Forgetting that event.target and event.currentTarget differ. When delegating events from a parent, beginners often check conditions against the parent element instead of realizing event.target is the specific descendant that was actually clicked, which may itself contain nested elements (like an icon inside a button).
Best Practices
- Prefer
addEventListener()over inlineonclick=\"...\"HTML attributes orelement.onclick = fn— it supports multiple listeners and keeps behavior out of markup. - Use event delegation for lists, tables, or any collection of similar elements, especially when items are added or removed dynamically.
- Always keep a named reference to a listener function if you’ll need to call
removeEventListener()later. - Use the
{ once: true }option for one-time reactions (like dismissing a tooltip) instead of manually callingremoveEventListener()inside the handler. - Add
{ passive: true }to high-frequency scroll/touch listeners that never callpreventDefault(), so the browser can scroll smoothly without waiting on your JS. - Call
event.preventDefault()as early as possible in the handler for forms and links, so the default action is reliably cancelled even if later code throws. - Avoid overusing
stopPropagation()— it can silently break other code (like analytics or global keyboard shortcuts) that also listens on ancestor elements.
Practice Exercises
Exercise 1: Attach a listener to a button that logs "clicked N times", incrementing a counter variable each time it’s clicked.
Exercise 2: Given a <ul id=\"menu\"> containing several <li> items, use event delegation on the <ul> to log the text of whichever <li> is clicked, without attaching a listener to each item individually.
Exercise 3: Register two listeners on the same button for 'click' — one with { once: true } and one without. Predict, then explain, what happens after the button is clicked three times.
Summary
- Events are signals dispatched by the browser when something happens (clicks, key presses, form submissions, etc.).
addEventListener(type, listener, options)is the standard way to react to events, and supports multiple listeners per element.- Events travel through three phases: capturing (top down), target, and bubbling (bottom up); listeners default to the bubbling phase.
event.targetis the element the event actually happened on;event.currentTargetis the element the listener is attached to.event.preventDefault()cancels the default browser action;event.stopPropagation()stops the event from continuing its capture/bubble journey.- Event delegation — listening on a shared parent instead of every child — is efficient and automatically covers dynamically added elements.
- To remove a listener with
removeEventListener(), you must pass the exact same function reference used to add it.
