Lesson 18 of 30

Event Listeners

addEventListener in Full: the Third Argument

addEventListener(type, handler, options) takes a third argument that most introductions skip entirely, and three of its options are genuinely worth knowing.

{ once: true } removes the listener automatically after it has run one time. That is exactly right for a welcome dialog's dismiss button, a one-off animation, or anything that must not fire twice — and it saves you writing the removal code, which is the part people reliably forget.

{ capture: true } runs your handler on the way down the tree instead of on the way back up. The next section but one explains what that means in practice. You will rarely need it, and you should be able to recognise it when you meet it in someone else's code.

{ passive: true } is a promise to the browser that your handler will never call preventDefault(). Without that promise the browser has to run your handler before it can start scrolling, just in case you cancel it. With it, scrolling begins immediately, which is a real and measurable improvement on touch devices. Use it on scroll, touchstart and wheel handlers that only read. If you break the promise, the preventDefault() call is ignored and the console warns you about it.

Example
const btn = document.querySelector('#saveBtn');

function handler() {
  console.log('handled');
}

// Runs at most once, then removes itself
btn.addEventListener('click', () => console.log('first click only'), { once: true });

// A scroll handler that only reads - say so, and scrolling stays smooth
window.addEventListener('scroll', () => {
  console.log(window.scrollY);
}, { passive: true });

// The old boolean third argument still works and means capture
document.addEventListener('click', handler, true);
document.addEventListener('click', handler, { capture: true });   // the same

// Options can be combined
btn.addEventListener('click', () => {}, { once: true, capture: true });
Notes
  • { passive: true } is already the default for touchstart and wheel listeners attached to window, document and document.body in modern browsers. Adding it explicitly is still worth doing, because it documents the intent and applies everywhere else too.

removeEventListener, and Why It Usually Fails

To remove a listener you must supply the same three things you added: the same event type, the same function reference, and a matching capture setting. Two of those are easy, and the third catches almost everyone at least once.

The problem is the function reference. An anonymous function or an arrow written inline creates a brand-new function every time that line is evaluated, so the function you hand to removeEventListener is not the one you added, however identical the two look on screen. Nothing is removed, and nothing tells you — removeEventListener returns no value and never complains. If you intend to remove a listener later, keep the function in a variable and pass that.

The same trap applies to a function produced by another function, and to bind: handler.bind(this) creates a new function on each call, so you must store the bound version and remove exactly that one.

When do you actually need to remove a listener? When the element itself is thrown away the browser cleans up, so most of the time you do not. You need it for listeners attached to document or window on behalf of something temporary — a dialog that adds an Escape handler, a drag operation that adds a mousemove handler. Those outlive the thing that created them and keep firing forever, which is both a bug and a memory leak. { once: true } and AbortController both remove the need to remember.

Example
const btn = document.querySelector('#saveBtn');

// This can never be removed - the two arrows are different functions
btn.addEventListener('click', () => console.log('hi'));
btn.removeEventListener('click', () => console.log('hi'));   // does nothing

// Keep the reference and it works
function onSave() {
  console.log('saved');
}
btn.addEventListener('click', onSave);
btn.removeEventListener('click', onSave);    // actually removed

// A dialog that cleans up after itself
function openDialog() {
  function onKey(e) {
    if (e.key === 'Escape') close();
  }
  function close() {
    document.removeEventListener('keydown', onKey);
    console.log('closed, and the listener is gone');
  }
  document.addEventListener('keydown', onKey);
  return close;
}
const closeDialog = openDialog();

// AbortController removes any number of listeners in one call
const controller = new AbortController();
window.addEventListener('resize', () => {}, { signal: controller.signal });
window.addEventListener('scroll', () => {}, { signal: controller.signal });
controller.abort();     // both are now removed
Notes
  • A listener holds a reference to its handler, and the handler's closure holds everything it can see. A forgotten listener on window can therefore keep an entire removed component alive in memory. This is the most common kind of memory leak in front-end code.

How an Event Travels Through the Tree

An event does not simply happen on one element. It travels through the tree in three phases, and understanding that journey is what makes delegation and stopPropagation stop feeling arbitrary.

First comes the capture phase: the event starts at the top of the document and travels down through every ancestor of the element concerned. Then the target phase, where it reaches that element. Then the bubble phase, where it travels back up through the same ancestors to the top. At every step, any handler registered for that phase on that element runs.

By default addEventListener registers for the bubble phase, which is why a click on a button inside a card fires the button's handler first, then the card's, then the document's. Registering with { capture: true } puts your handler in the downward phase instead, so it runs before the target's own handler — which is occasionally useful for intercepting something before the rest of the page can react.

Not every event bubbles. focus and blur do not, which is why a delegated focus handler on a parent never seems to fire and why beginners conclude that delegation is unreliable. Use focusin and focusout, which are the bubbling versions of the same two events and exist for exactly this purpose.

Example
// markup: <div id="card"><button id="save">Save</button></div>
const card = document.querySelector('#card');
const save = document.querySelector('#save');

document.addEventListener('click', () => console.log('3. document (bubble)'));
card.addEventListener('click', () => console.log('2. card (bubble)'));
save.addEventListener('click', () => console.log('1. button (target)'));

// Clicking the button prints 1, then 2, then 3

// Capture runs on the way DOWN, before the target
card.addEventListener('click', () => console.log('0. card (capture)'), { capture: true });
// Now the order is 0, 1, 2, 3

// focus does not bubble, so this never fires for child inputs
card.addEventListener('focus', () => console.log('never runs'));

// focusin does bubble - use it when you need delegation
card.addEventListener('focusin', (e) => console.log('focused:', e.target.id));
Notes
  • event.eventPhase tells you which phase a handler is running in: 1 for capture, 2 for target, 3 for bubble. Logging it once while clicking through a nested layout makes the whole model concrete in about a minute.

stopPropagation, and When Not to Use It

event.stopPropagation() stops the event travelling any further through the tree. Handlers it has already reached still run; handlers on ancestors above it never see the event at all.

The legitimate use is a nested interactive element. Think of a card that opens a detail view when clicked, containing a delete button that must not also open that view. The button's handler calls stopPropagation so the card's handler never receives the click, and there is no cleaner way to express that relationship.

The illegitimate use is reaching for it whenever something behaves unexpectedly. It is a blunt instrument: it silently breaks anything else listening higher up — including code written by someone else, in a file you have never opened. Analytics that quietly stop recording, dropdowns that no longer close when you click elsewhere, and delegated handlers that mysteriously never fire are all classic results of a stopPropagation added months earlier as a quick fix.

stopImmediatePropagation() goes further still and also prevents other handlers on the same element from running. It is very rarely the right answer. Before reaching for either, ask whether a condition in the outer handler would do the job instead: if (event.target.closest('.delete-btn')) return; is explicit, local, and cannot surprise anybody.

Example
const card = document.querySelector('#card');
const del = document.querySelector('#delete');

card.addEventListener('click', () => console.log('open the card'));

// Legitimate: the delete button must not also open the card
del.addEventListener('click', (event) => {
  event.stopPropagation();
  console.log('deleted');
});

// The alternative, which breaks nothing further up the tree
card.addEventListener('click', (event) => {
  if (event.target.closest('#delete')) return;
  console.log('open the card');
});
Notes
  • stopPropagation does not stop the browser's default behaviour, and preventDefault does not stop propagation. They are independent, and needing both means calling both.

Event Delegation

Attaching one listener to a parent and letting bubbling bring you every child's events is called event delegation, and it is the most useful single technique in this lesson.

It solves two problems at once. A list of two hundred rows needs one listener instead of two hundred, which is less memory and much faster to set up. More importantly, it works for rows that do not exist yet. A listener attached to a row can only ever handle that row; a listener attached to the list handles every row you add in the future, so you never have to remember to wire up newly created elements — which is precisely the bug that makes an "add" button appear to work while its new row's delete button does nothing.

The pattern always has the same three parts. Attach the listener to a parent that will not be replaced. Inside the handler, check whether the event came from something you care about, using event.target.closest(selector). If it did not, return immediately. That guard is what stops the handler reacting to clicks on padding, on a heading, or on the gap between two rows.

Use closest rather than comparing event.target directly, for the reason the previous lesson gave: target may be a span or an icon inside the element you actually meant. Once you have the matched element, read whatever you need from its data- attributes, exactly as Lesson 16 did.

Example
const list = document.querySelector('#tasks');

// One listener, however many rows exist now or are added later
list.addEventListener('click', function (event) {
  const btn = event.target.closest('.delete');
  if (!btn) return;                  // clicked somewhere else - ignore it

  const row = btn.closest('li');
  const id = Number(row.dataset.id);
  console.log('delete task', id);
  row.remove();
});

// A row created afterwards needs no extra wiring at all
const li = document.createElement('li');
li.dataset.id = '99';
const label = document.createElement('span');
label.textContent = 'New task';
const del = document.createElement('button');
del.className = 'delete';
del.textContent = 'x';
li.append(label, del);
list.append(li);
One listener for a whole list
HTML
<div class="ev-demo">
  <h3>Delegation demo</h3>
  <button id="addRow">Add a row</button>
  <ul id="rows"></ul>
  <p id="log">Click a row to mark it done. Click x to delete it.</p>
</div>
CSS
.ev-demo { padding: 20px; background: #f0f0f0; border-radius: 8px; font-family: system-ui, sans-serif; }
button { padding: 9px 15px; background: #d1039e; color: white; border: none; border-radius: 5px; cursor: pointer; }
ul { list-style: none; padding: 0; margin: 15px 0; }
li { display: flex; justify-content: space-between; align-items: center; background: white; padding: 10px 12px; border-radius: 5px; margin-bottom: 6px; cursor: pointer; border-left: 4px solid #d1039e; }
li.done span { text-decoration: line-through; opacity: 0.55; }
li button.del { background: #666; padding: 4px 10px; font-size: 0.8rem; }
#log { color: #444; font-size: 0.9rem; }
JavaScript
const rows = document.getElementById('rows');
const log = document.getElementById('log');
let nextId = 1;

function addRow() {
  const li = document.createElement('li');
  li.dataset.id = nextId;

  const span = document.createElement('span');
  span.textContent = 'Row ' + nextId;

  const del = document.createElement('button');
  del.textContent = 'x';
  del.className = 'del';

  li.append(span, del);
  rows.append(li);
  nextId = nextId + 1;
}

// ONE listener on the parent. It handles rows that do not exist yet.
rows.addEventListener('click', function (event) {
  const row = event.target.closest('li');
  if (!row) return;                    // clicked the padding - ignore

  if (event.target.closest('.del')) {
    log.textContent = 'Deleted row ' + row.dataset.id;
    row.remove();
    return;                            // do not also toggle it
  }

  row.classList.toggle('done');
  log.textContent = 'Toggled row ' + row.dataset.id;
});

document.getElementById('addRow').addEventListener('click', addRow);

addRow();
addRow();
addRow();
Notes
  • Add several rows and then delete one. The delete button on a row created after the listener was attached works exactly like the others, because the listener was never on the row in the first place — it is on the list, and the click simply bubbled up to it.

Listeners That Fire Too Often

scroll, resize, mousemove and input can fire dozens of times a second. A handler that is cheap on its own becomes expensive when it runs sixty times, and if it touches the page each time — reading a position, writing a style — the result is visible stutter on exactly the devices your users have.

Debounce waits until the events stop before running once. That is what a search box needs: run the search when the user pauses, not after every letter. Throttle runs at most once per interval no matter how many events arrive, which is what a scroll position indicator needs — regular updates, but not sixty a second.

Both are built from setTimeout and a closure, holding the timer between calls in exactly the way Lesson 4 described. Every utility library ships a version, and they are still worth writing out by hand once so the mechanism stops being magic. Notice that both return a new function, which is the one you attach as the listener.

One more habit worth forming now: a handler that adds a listener every time it runs is a leak in the making. Adding a mousemove listener inside a mousedown handler is correct only when the matching mouseup removes it again. For every listener you attach to something long-lived, be able to point at the moment it goes away.

Example
// Debounce: run once, after the events have stopped
function debounce(fn, wait) {
  let timer;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), wait);
  };
}

const search = document.querySelector('#search');
search.addEventListener('input', debounce(function (event) {
  console.log('searching for', event.target.value);
}, 300));

// Throttle: at most once per interval
function throttle(fn, wait) {
  let ready = true;
  return function (...args) {
    if (!ready) return;
    ready = false;
    fn.apply(this, args);
    setTimeout(() => { ready = true; }, wait);
  };
}

window.addEventListener('scroll', throttle(function () {
  console.log(window.scrollY);
}, 200), { passive: true });
  • { once: true } — a listener that removes itself after one run
  • { passive: true } — for scroll and touch handlers that never cancel the default
  • removeEventListener needs the same function reference; an inline arrow can never be removed
  • Events travel down (capture), reach the target, then travel back up (bubble)
  • focus and blur do not bubble — use focusin and focusout
  • Delegation: one listener on a stable parent plus event.target.closest(...)
  • Debounce a search box; throttle a scroll handler
  • Every listener on window or document should have a defined moment where it is removed
Notes
  • The difference in one line: debounce answers "tell me when they have stopped", throttle answers "tell me at most every N milliseconds". Choosing the wrong one is why a search box either fires on every keystroke or feels unresponsive.
Ask AI