JavaScript Practice #2 Event Handling and Delegation

6 min read

In #1 DOM Manipulation Basics you saw the tools for working with elements; this post is about handling user interaction with them.

Registering events — addEventListener #

basic event registration
const btn = document.querySelector('#submit');

btn.addEventListener('click', (e) => {
  console.log('clicked');
});

addEventListener('type', handler) — the modern JavaScript standard.

In old code you’ll see direct assignment like btn.onclick = () => {...} — but you can register only one handler per element that way. addEventListener allows multiple handlers for the same event.

Common event types #

common events
click          mouse click
input          input field value change (live)
change         value change (on blur)
submit         form submit
keydown/keyup  keypress
focus/blur     gaining/losing focus
mouseenter     mouse enter
mouseleave     mouse leave
scroll         scroll
load           load complete
DOMContentLoaded  HTML parsing complete

The event object — e #

The argument the handler receives is the event object. Here are the common properties.

event object
btn.addEventListener('click', (e) => {
  e.target;           // the element where the event started
  e.currentTarget;    // the element on which the handler was registered
  e.type;             // 'click'

  e.preventDefault();    // block the default behavior
  e.stopPropagation();   // stop bubbling
});

e.target vs e.currentTarget #

The most confusing part.

HTML
<button class="card">
  <span>click me</span>
</button>
target vs currentTarget
btn.addEventListener('click', (e) => {
  console.log(e.target);         // <span> (the actually clicked element)
  console.log(e.currentTarget);  // <button> (where the handler is registered)
});
  • target — the element where the event originated (innermost click target)
  • currentTarget — the element where the handler is registered

In the event-delegation pattern, the difference matters — more on that coming up.

Event flow — capturing and bubbling #

When an event fires, it propagates in two stages.

event propagation
1. Capturing — document → event location, top→down
2. Target — arrives at the location
3. Bubbling — location → document, bottom→up

By default, handlers run in the bubbling phase.

nested structure
<div id="outer">
  <div id="inner">
    <button id="btn">click</button>
  </div>
</div>
bubbling check
document.querySelector('#outer').addEventListener('click', () => console.log('outer'));
document.querySelector('#inner').addEventListener('click', () => console.log('inner'));
document.querySelector('#btn').addEventListener('click', () => console.log('button'));

// On click of the button:
// button
// inner
// outer

The event bubbles up button → inner → outer, running each handler in turn. That’s bubbling.

Listening on capture #

capture phase
document.querySelector('#outer').addEventListener(
  'click',
  () => console.log('outer (capture)'),
  { capture: true }
);

Pass { capture: true } as the third argument to run on capture. Rarely used — but useful when “the outer must handle first.”

stopPropagation and preventDefault #

stopPropagation — halt propagation #

stop bubbling
button.addEventListener('click', (e) => {
  e.stopPropagation();   // doesn't go further up
});

If the handler above calls stopPropagation, the outer/inner handlers don’t run.

preventDefault — block the default #

block default
form.addEventListener('submit', (e) => {
  e.preventDefault();   // form doesn't reload the page
  // ... handle manually
});

link.addEventListener('click', (e) => {
  e.preventDefault();   // link doesn't navigate
});

Blocks the browser’s default behavior. Common cases include form submission, link navigation, and right-click menus.

The two methods serve different purposes. stopPropagation controls JavaScript handler propagation; preventDefault blocks browser default behavior. Keep them distinct so you don’t mix them up.

Event delegation — efficient pattern #

Suppose a list has 100 items and each needs a click handler:

Bad pattern — handler per item #

inefficient
document.querySelectorAll('.item').forEach((item) => {
  item.addEventListener('click', (e) => {
    console.log('clicked:', item.dataset.id);
  });
});

100 handlers in memory. New items added dynamically have no handler.

Good pattern — one handler on the parent #

event delegation
const list = document.querySelector('.list');

list.addEventListener('click', (e) => {
  const item = e.target.closest('.item');
  if (!item || !list.contains(item)) return;

  console.log('clicked:', item.dataset.id);
});

Just one handler. On click, the event bubbles up to the parent, and closest checks which item was clicked.

Benefits of delegation #

  1. Memory — one handler
  2. Auto-handles dynamic items — items added later work with the same handler
  3. Concise — no register/unregister as the list changes

In lists, tables, card grids — almost the standard pattern.

Removing handlers — removeEventListener #

remove
function onClick() {
  console.log('click');
}

btn.addEventListener('click', onClick);

// later
btn.removeEventListener('click', onClick);

The handler reference must match to remove. An inline arrow can’t be removed.

can't remove
btn.addEventListener('click', () => console.log('click'));
btn.removeEventListener('click', () => console.log('click'));   // ✗ different function

AbortController — modern handler cleanup #

The AbortController from Intermediate #6 fetch API also helps with event cleanup.

cleanup with AbortController
const controller = new AbortController();

btn.addEventListener('click', onClick, { signal: controller.signal });
input.addEventListener('input', onInput, { signal: controller.signal });
window.addEventListener('resize', onResize, { signal: controller.signal });

// remove all at once
controller.abort();

Clean up multiple handlers in one shot. Fits component cleanup nicely.

Run once — { once: true } #

once option
btn.addEventListener('click', () => {
  console.log('first and last');
}, { once: true });

It removes itself automatically. Very clean for one-shot handlers.

Passive events — scroll performance #

For events like scroll, touchmove, registering as passive is recommended.

passive option
window.addEventListener('scroll', onScroll, { passive: true });

A passive handler promises not to call preventDefault. Given that promise, the browser can scroll faster.

Common form/input patterns #

input vs change #

live vs completed
input.addEventListener('input', (e) => {
  // every keypress
  console.log(e.target.value);
});

input.addEventListener('change', (e) => {
  // when input ends and focus is lost (or on select change)
  console.log(e.target.value);
});

For live processing like search autocomplete — input. For heavier work like validation/saving — change or debounced input.

Keyboard events #

key check
input.addEventListener('keydown', (e) => {
  if (e.key === 'Enter') submit();
  if (e.key === 'Escape') close();
  if (e.ctrlKey && e.key === 's') {
    e.preventDefault();
    save();
  }
});

e.key is the modern standard. The old e.keyCode is deprecated.

Custom events #

You can create and dispatch your own events.

custom event
const event = new CustomEvent('user-login', {
  detail: { id: 'u1', name: 'Curtis' },
});

document.dispatchEvent(event);

// elsewhere
document.addEventListener('user-login', (e) => {
  console.log(e.detail);   // { id: 'u1', name: 'Curtis' }
});

Useful for cross-module communication in large apps, but overuse makes the event flow hard to trace — use it sparingly.

Wrap-up #

What we covered:

  • addEventListener is the modern standard
  • e.target (origin) vs e.currentTarget (registered location)
  • Event flow: capturing → target → bubbling (default is bubbling)
  • stopPropagation for propagation, preventDefault for defaults
  • Event delegation — register once on the parent, locate with closest
  • removeEventListener needs the same function reference
  • AbortController cleans many handlers at once
  • { once: true }, { passive: true } options
  • input is live, change is on completion
  • e.key is the modern keyboard standard

In the next post (#3 fetch and async UI) we cover patterns for receiving data with fetch and reflecting it in the DOM — loading/error states, debounce, and AbortController together.

X