Events
addEventListener, the event object, preventDefault, stopPropagation, capture vs bubble.
The page is interactive because the browser fires events — small objects that say "the user just clicked", "the user just pressed Enter", "this video just started playing", "the form just submitted". Your code listens for the ones it cares about and runs a function when they happen.
This lesson covers the event vocabulary you actually use: how to subscribe, what arrives in your handler, how to opt out of the browser's default behavior, and how an event travels through the tree before reaching you. It is not exhaustive — there are more event types than any human can memorize — but the shape of events is the same everywhere, and once you have that shape the rest is lookup.
addEventListener
The one method to subscribe to events is addEventListener. It takes the name of an event and a function to call.
const btn = document.querySelector('#save');
btn.addEventListener('click', () => {
console.log('saved');
});You attached a function to the button's click event. Every time the user clicks that button — with the mouse, with a tap, by pressing Enter while it is focused — your function runs. The browser builds an event object and passes it as the first argument; you can ignore it (as above) or use it.
You will see older code use the inline onclick attribute (<button onclick="save()">) or the el.onclick = … property. Both still work, but they have one big limitation: only one listener per event, per element. Setting onclick overwrites any previous onclick. addEventListener lets you attach as many handlers as you want, and they all run.
Common event names: click, input, change, submit, keydown, focus, blur, scroll, resize. There are dozens more — every kind of user input has a name.
The event object
Your handler receives an event object — a description of what happened. Three of its properties cover almost everything you ask of it.
btn.addEventListener('click', (event) => {
console.log(event.type); // "click"
console.log(event.target); // the element the user actually clicked
console.log(event.currentTarget); // the element the listener is on (btn)
});event.targetis the deepest element the event hit. If the button has an icon inside it and the user clicks the icon,event.targetis the icon, not the button.event.currentTargetis the element your listener is attached to. It does not change as the event travels — it is always "the thing I subscribed on".event.typeis the event name as a string, useful when one handler covers several event types.
For keyboard events, you also get event.key (the character or key name like 'Enter' or 'a') and event.code (the physical key, like 'KeyA'). For pointer events, event.clientX and clientY give the coordinates.
A useful picture: the event object is the form the user filled out by acting. Your handler reads the form to decide what to do — what they clicked, what they typed, where on the screen.
<span class="label">Save</span>. The user clicks the word "Save". You wrote btn.addEventListener('click', e => …). What is e.target?preventDefault
Some events come with default behavior — work the browser does on its own unless you tell it not to. A click on a link follows the URL. A submit on a form sends a request. A keydown of a printable key types into the focused input.
Calling event.preventDefault() cancels that default. Your code still runs; the browser's built-in follow-up is skipped.
form.addEventListener('submit', (event) => {
event.preventDefault(); // do not do the page navigation
const data = new FormData(form); // grab the form values yourself
fetch('/save', { method: 'POST', body: data });
});You stopped the browser from navigating. You took over the submission yourself — read the values, send a fetch, update the page in place. This is the standard shape of any form handled by JavaScript.
preventDefault only suppresses the default action. It does not stop other listeners from running. It does not stop the event from continuing to travel up the tree. Those are different concerns, addressed by different methods.
Calling preventDefault on an event that has no default does nothing. You will not get an error — the browser silently ignores it. So the call works on click of a button, but only matters on events like submit, link clicks, form-input keydowns, and a handful of others.
Capture, target, bubble
When the user clicks an element, the event does not just fire on that element. It travels through the tree in three phases:
- Capture — from the document down to the target. Listeners along the way fire on the way in.
- Target — at the deepest element. The listener on the actual target fires.
- Bubble — back up from the target to the document. Listeners along the way fire on the way out.
By default, listeners fire on the bubble phase. That is what most code wants and what beginners always assume. You can opt into the capture phase by passing { capture: true } as the third argument to addEventListener, but you almost never need to.
Why does this matter at all? Because of delegation — putting a single listener on a parent that handles events from all its descendants. That is the next lesson, and it works because of bubbling.
For now: if you only ever attach listeners with addEventListener('click', handler) and ignore the third argument, you are using bubble-phase listening. That is correct for almost every event you handle.
stopPropagation, and when not to
event.stopPropagation() halts the event's travel. Listeners further along the path — including bubble-phase listeners on ancestors — do not run.
innerBtn.addEventListener('click', (event) => {
event.stopPropagation();
// an outer click handler on a parent will NOT see this click
});This sounds tempting. It is also one of the most overused tools in DOM code, and it makes large applications harder to debug.
The reason: stopping propagation means somebody else's listener — analytics, focus management, modal close-on-outside-click — silently does not run. That listener was added by another part of the system, and you may not even know it exists. When the modal stops closing, when the analytics stop firing, you will spend an hour finding which child swallowed the event.
The rule: don't reach for stopPropagation to "fix" parent handlers. Fix the parent handler instead. Use event.target to check whether the click was on something you care about; ignore it otherwise. If you genuinely need to stop the event — say, a click on a "more" menu inside a card that should not also count as a card click — that is a fine use, but be deliberate.
stopPropagation stops other listeners from seeing the event. preventDefault stops the browser's default behaviour. Two different jobs. Don't reach for the wrong one.
Removing listeners
To stop listening, call removeEventListener with the same event name and the same function reference.
function onClick() { console.log('hi'); }
btn.addEventListener('click', onClick);
// later:
btn.removeEventListener('click', onClick);The "same function reference" part is the gotcha. Two anonymous functions are not the same function, even if their bodies are identical:
btn.addEventListener('click', () => doIt());
btn.removeEventListener('click', () => doIt()); // does nothing — different functionIf you need to remove a listener later, store the function in a variable. If you want a one-shot listener, use the { once: true } option:
btn.addEventListener('click', () => fire(), { once: true });
// fires once, then auto-removesThe once option is the cleanest fit for many "first-click" cases. The other useful option is signal: abortController.signal, which lets you cancel many listeners with one controller.abort() — handy when a component unmounts.
btn.addEventListener('click', () => save()) and try to remove it later with the same expression. The listener still runs. Why?