DOM and events · 5 / 10
lesson 5

Events

addEventListener, the event object, preventDefault, stopPropagation, capture vs bubble.

~ 17 min read·lesson 5 of 10
0 / 10

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.

listen.js
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.

Tip

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.

event-object.js
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.target is the deepest element the event hit. If the button has an icon inside it and the user clicks the icon, event.target is the icon, not the button.
  • event.currentTarget is the element your listener is attached to. It does not change as the event travels — it is always "the thing I subscribed on".
  • event.type is 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.

check your understanding
A button contains a <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.

prevent.js
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.

Watch out

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:

  1. Capture — from the document down to the target. Listeners along the way fire on the way in.
  2. Target — at the deepest element. The listener on the actual target fires.
  3. 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.

bodysectionbuttoncapturebubble
An event travels down through ancestors (capture), reaches its target, then bubbles back up. Listeners on ancestors fire on each side of the target unless they opt into a phase.

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.

stop.js
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.

Watch out

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.

check your understanding
You add a click handler on a card that opens a detail view. The card contains a "delete" icon button with its own click handler that removes the card. The user clicks delete. What happens by default?

Removing listeners

To stop listening, call removeEventListener with the same event name and the same function reference.

off.js
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:

broken-off.js
btn.addEventListener('click', () => doIt());
btn.removeEventListener('click', () => doIt()); // does nothing — different function

If 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:

once.js
btn.addEventListener('click', () => fire(), { once: true });
// fires once, then auto-removes

The 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.

check your understanding
You attach a click listener with btn.addEventListener('click', () => save()) and try to remove it later with the same expression. The listener still runs. Why?
check your understanding
You want a form submit to send a fetch instead of navigating. What's the right shape?
← prevnext lesson →
KeepLearningcertificate
for completing
DOM and events
0 of 10 read