| |

JavaScript 62 🧬 Events

Events are the nervous system of a web page. Every click, keystroke, scroll, and network response fires an event, and JavaScript listens. Understanding events — how they propagate, how to listen, how to stop them, and how to delegate — is what separates a static page from a responsive application.

The core of it is addEventListener, but that’s just the entry point. What really matters is knowing how events travel through the DOM (capturing, targeting, bubbling), how to intercept them without breaking the page, and how to handle hundreds of interactive elements without attaching hundreds of listeners.

Key point: Events flow in three phases — capturing (top down), target (the actual element), and bubbling (bottom up). By default, listeners fire during bubbling. Understanding this phase order is the key to predicting when your handlers run.


a – What is an event

An event is a signal that something happened. The browser fires an event when the user interacts with the page, when the DOM changes, when a resource loads, or when a timer completes.

The event object:

Every event handler receives an event object with details about what happened.

button.addEventListener('click', (event) => {
  console.log(event.type);          // 'click'
  console.log(event.target);        // the element that was clicked
  console.log(event.currentTarget); // the element the listener is on
  console.log(event.timeStamp);     // when it happened
  console.log(event.clientX, event.clientY); // mouse position
});

The event object carries information specific to the event type. A click has coordinates. A keydown has key and code. A form submission has the form data.

event.target vs event.currentTarget:

This is one of the most misunderstood parts of the DOM.

  • event.target — the actual element that triggered the event
  • event.currentTarget — the element the listener is attached to
<div id="outer">
  <button id="inner">Click me</button>
</div>
outer.addEventListener('click', (event) => {
  console.log(event.target.id);        // 'inner' — clicked the button
  console.log(event.currentTarget.id); // 'outer' — listener is on outer
});

The user clicked the inner button, so target is the button. But the listener is on the outer div, so currentTarget is the div. This distinction matters for event delegation.

The main event categories:

CategoryExamplesWhen they fire
Mouseclick, dblclick, mousedown, mouseup, mouseover, mouseout, mousemoveUser interacts with mouse
Keyboardkeydown, keyupUser presses/releases a key
Formsubmit, change, input, focus, blur, resetForm interaction
DocumentDOMContentLoaded, load, beforeunload, unloadPage lifecycle
Touchtouchstart, touchmove, touchendTouchscreen interaction
Scrollscroll, wheelScrolling
Dragdragstart, drag, dragend, dropDragging elements
Clipboardcopy, cut, pasteClipboard operations
Mediaplay, pause, ended, timeupdateAudio/video
Animationanimationstart, animationend, transitionendCSS animations
CustomCustomEvent('my-event')User-defined

Events as objects:

Events are instances of Event or subclasses. The browser creates them, but you can also create custom events.

const event = new Event('build');
element.dispatchEvent(event);

The event lifecycle:

Every event goes through a fixed sequence:

  1. Capturing phase — event travels from the root (window) down through ancestors
  2. Target phase — event reaches the actual element
  3. Bubbling phase — event travels back up through ancestors

By default, addEventListener fires during bubbling. You can opt into capturing.

Why events matter:

  • Interaction — respond to user input
  • Reactivity — update UI based on state changes
  • Decoupling — components communicate through events
  • Performance — event delegation handles many elements with one listener
  • Lifecycle — hooks into loading, animations, and unload

Registering a listener:

element.addEventListener('click', handler);

You can attach multiple handlers:

element.addEventListener('click', handler1);
element.addEventListener('click', handler2);
// Both run, in order of registration

Removing a listener:

element.removeEventListener('click', handler);

The handler reference must match. Anonymous functions can’t be removed:

element.addEventListener('click', () => {});
element.removeEventListener('click', () => {}); // ❌ does nothing

The old inline way (don’t use it):

<button onclick="alert('hi')">Click</button>

Inline handlers are hard to maintain, mix HTML and JS, and only allow one per element. addEventListener is the right way.


b – Event propagation and phases

Events don’t just fire on one element — they travel through the DOM. Understanding propagation is essential for handling nested elements.

The three phases:

┌──────────────────────────────────────────────┐
│                   window                     │
│                     │                        │
│                     ▼                        │
│  CAPTURING PHASE                             │
│  (root → target)                             │
│                     │                        │
│                     ▼                        │
│                 document                     │
│                     │                        │
│                     ▼                        │
│                   <html>                     │
│                     │                        │
│                     ▼                        │
│                   <body>                     │
│                     │                        │
│                     ▼                        │
│  TARGET PHASE    <button>                    │
│                     │                        │
│                     ▼                        │
│  BUBBLING PHASE                              │
│  (target → root)                             │
│                     │                        │
│                     ▼                        │
│                   <body>                     │
│                     │                        │
│                     ▼                        │
│                   <html>                     │
│                     │                        │
│                     ▼                        │
│                   document                   │
│                     │                        │
│                     ▼                        │
│                   window                     │
│                                              │
└──────────────────────────────────────────────┘

Capturing — top down:

During capturing, the event travels from window down to the target. Listeners registered with capture: true fire here.

parent.addEventListener('click', handler, { capture: true });
// or
parent.addEventListener('click', handler, true);

Target — the element:

The event reaches the element that triggered it. Both capturing and bubbling listeners on the target fire here (capture first).

Bubbling — bottom up:

By default, events bubble from the target back up through ancestors. This is where most listeners fire.

parent.addEventListener('click', handler);
// capture: false (default)

A demonstration:

<div id="outer">
  <div id="inner">
    <button id="btn">Click</button>
  </div>
</div>
document.querySelector('#outer').addEventListener('click', () => {
  console.log('outer capture');
}, true);

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

document.querySelector('#btn').addEventListener('click', () => {
  console.log('btn');
});

document.querySelector('#inner').addEventListener('click', () => {
  console.log('inner bubble');
});

document.querySelector('#outer').addEventListener('click', () => {
  console.log('outer bubble');
});

Clicking the button produces:

[ outer capture ]
[ inner capture ]
[ btn ]
[ inner bubble ]
[ outer bubble ]

The order is exactly as the diagram shows: capture from the outside in, target, bubble from the inside out.

stopPropagation — halt the event:

inner.addEventListener('click', (event) => {
  event.stopPropagation();
});

Calling stopPropagation stops the event from moving to the next phase. The current phase’s remaining listeners still run, but no further propagation.

Variants:

MethodEffect
event.stopPropagation()Stops propagation, current listeners run
event.stopImmediatePropagation()Stops immediately — no more listeners at all
event.cancelBubble = trueLegacy, same as stopPropagation

preventDefault — stop default behavior:

Different from stopPropagation. preventDefault stops the browser’s built-in behavior — following a link, submitting a form, checking a checkbox.

form.addEventListener('submit', (event) => {
  event.preventDefault();
  // handle submission manually
});
MethodStops
stopPropagationEvent propagation
preventDefaultDefault action

They’re independent:

link.addEventListener('click', (event) => {
  event.preventDefault();  // don't navigate
  event.stopPropagation(); // don't bubble
});

once option — auto-remove:

button.addEventListener('click', handler, { once: true });

The listener runs once and is automatically removed.

passive option — better scroll performance:

element.addEventListener('scroll', handler, { passive: true });

Tells the browser the handler won’t call preventDefault. Enables scrolling optimizations.

Common listener options:

OptionDefaultEffect
capturefalseFire during capturing
oncefalseAuto-remove after firing
passivefalsePromise not to preventDefault
signalAbortController support

Aborting listeners with AbortController:

const controller = new AbortController();

element.addEventListener('click', handler, {
  signal: controller.signal
});

// Later
controller.abort();   // removes all listeners using this signal

Clean way to remove many listeners at once.

Non-bubbling events:

Some events don’t bubble — focus, blur, load, mouseenter, mouseleave.

input.addEventListener('focus', handler);
// Fires only on the input, not on ancestors

For delegation on these, use capture phase:

document.addEventListener('focus', handler, true);

Custom events:

const event = new CustomEvent('user-login', {
  detail: { userId: 42 },
  bubbles: true,
  cancelable: true
});

element.dispatchEvent(event);

Listen for them like any other:

element.addEventListener('user-login', (event) => {
  console.log(event.detail.userId);  // 42
});

Event delegation — the powerful pattern:

Attach one listener to a parent instead of many to children.

<ul id="list">
  <li><button class="del" data-id="1">Delete</button></li>
  <li><button class="del" data-id="2">Delete</button></li>
  <!-- 100 more -->
</ul>
document.querySelector('#list').addEventListener('click', (event) => {
  const button = event.target.closest('.del');
  if (!button) return;
  deleteItem(button.dataset.id);
});

One listener handles all 100+ buttons — existing and future.

Why delegation works:

Events bubble from the target up to ancestors. A listener on the parent catches events from any descendant.

Delegation with closest:

list.addEventListener('click', (event) => {
  const item = event.target.closest('li');
  if (!item) return;
  item.classList.toggle('selected');
});

closest walks up from target to find the matching ancestor.

Delegation caveats:

  • Event must bubble (or use capture)
  • event.target may be a nested child — use closest
  • Can’t use stopPropagation inside if you want the parent to hear it
  • Not suitable for non-bubbling events without capture

c – Common event patterns

These patterns come up in almost every web application.

Pattern 1 — Debounce input events:

Input events fire on every keystroke. Debouncing waits until typing stops.

function debounce(fn, delay) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  };
}

search.addEventListener('input', debounce((event) => {
  console.log('Search for:', event.target.value);
}, 300));

The handler runs 300ms after the last keystroke — not on every one.

Pattern 2 — Throttle scroll events:

Scroll fires dozens of times per second. Throttling limits how often the handler runs.

function throttle(fn, interval) {
  let last = 0;
  return (...args) => {
    const now = Date.now();
    if (now - last >= interval) {
      last = now;
      fn(...args);
    }
  };
}

window.addEventListener('scroll', throttle(() => {
  console.log('Scrolled');
}, 100));

Pattern 3 — Form submission with Fetch:

form.addEventListener('submit', async (event) => {
  event.preventDefault();

  const data = Object.fromEntries(new FormData(form));

  try {
    const response = await fetch('/api/submit', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data)
    });

    if (!response.ok) throw new Error('Request failed');

    const result = await response.json();
    console.log('Success:', result);
  } catch (err) {
    console.error('Error:', err);
  }
});

Pattern 4 — Click outside to close:

function onClickOutside(element, callback) {
  document.addEventListener('click', (event) => {
    if (!element.contains(event.target)) {
      callback();
    }
  });
}

onClickOutside(modal, () => closeModal());

The contains check determines if the click was inside the element.

Pattern 5 — Keyboard shortcuts:

document.addEventListener('keydown', (event) => {
  if (event.key === 'Escape') closeModal();
  if (event.ctrlKey && event.key === 's') {
    event.preventDefault();
    save();
  }
  if (event.metaKey && event.key === 'k') {
    event.preventDefault();
    openSearch();
  }
});

Pattern 6 — Drag and drop:

draggable.addEventListener('dragstart', (event) => {
  event.dataTransfer.setData('text/plain', event.target.id);
});

dropZone.addEventListener('dragover', (event) => {
  event.preventDefault();   // required to allow drop
});

dropZone.addEventListener('drop', (event) => {
  event.preventDefault();
  const id = event.dataTransfer.getData('text/plain');
  console.log('Dropped:', id);
});

Pattern 7 — Toggle active state:

tabs.addEventListener('click', (event) => {
  const tab = event.target.closest('.tab');
  if (!tab) return;

  tabs.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
  tab.classList.add('active');
});

Pattern 8 — Hover with mouseenter/mouseleave:

card.addEventListener('mouseenter', () => {
  card.classList.add('hovered');
});

card.addEventListener('mouseleave', () => {
  card.classList.remove('hovered');
});

mouseenter/mouseleave don’t bubble — no accidental triggers from children.

Pattern 9 — Input validation on blur:

input.addEventListener('blur', () => {
  const value = input.value.trim();
  if (value && !isValid(value)) {
    input.classList.add('error');
  } else {
    input.classList.remove('error');
  }
});

Fires when the input loses focus — a natural validation point.

Pattern 10 — Custom events for component communication:

// Dispatcher
class Modal {
  open() {
    this.element.dispatchEvent(new CustomEvent('modal-open', {
      bubbles: true,
      detail: { id: this.id }
    }));
  }
}

// Listener
document.addEventListener('modal-open', (event) => {
  console.log('Modal opened:', event.detail.id);
});

Custom events let components communicate without direct references.

Pattern 11 — Clean up listeners:

class Widget {
  constructor(element) {
    this.element = element;
    this.handleClick = this.handleClick.bind(this);
    this.element.addEventListener('click', this.handleClick);
  }

  handleClick(event) {
    console.log('clicked');
  }

  destroy() {
    this.element.removeEventListener('click', this.handleClick);
  }
}

Bound references are essential for cleanup — you need the same function reference.

Pattern 12 — Observer for lazy loading:

const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      entry.target.src = entry.target.dataset.src;
      observer.unobserve(entry.target);
    }
  });
});

document.querySelectorAll('img[data-src]').forEach(img => {
  observer.observe(img);
});

The image loads only when it enters the viewport.

Pattern 13 — MutationObserver for dynamic content:

const observer = new MutationObserver((mutations) => {
  for (const mutation of mutations) {
    if (mutation.type === 'childList') {
      console.log('Children changed:', mutation.addedNodes);
    }
  }
});

observer.observe(container, { childList: true, subtree: true });

Pattern 14 — Async event handler with race protection:

let currentRequest = 0;

input.addEventListener('input', async (event) => {
  const id = ++currentRequest;
  const results = await fetchResults(event.target.value);

  if (id !== currentRequest) return;   // stale response
  renderResults(results);
});

Prevents older async responses from overwriting newer ones.

Pattern 15 — Passive scroll for performance:

window.addEventListener('scroll', () => {
  updateProgressBar();
}, { passive: true });

Tells the browser the handler won’t block scrolling — enables optimizations.


Complete Example Session

// ============================================
// PART 1: BASIC LISTENER
// ============================================

const button = document.querySelector('button');

button.addEventListener('click', () => {
  console.log('clicked');
});
// [ clicked ] (when button is clicked)

// ============================================
// PART 2: EVENT OBJECT
// ============================================

button.addEventListener('click', (event) => {
  console.log(event.type);       // 'click'
  console.log(event.target);     // the button element
  console.log(event.timeStamp);  // timestamp in ms
});
// [ click ]
// [ <button>...</button> ]
// [ 1234.567 ]

// ============================================
// PART 3: TARGET VS CURRENTTARGET
// ============================================

const outer = document.querySelector('#outer');
const inner = document.querySelector('#inner');

outer.addEventListener('click', (event) => {
  console.log('target:', event.target.id);
  console.log('currentTarget:', event.currentTarget.id);
});
// Click on inner:
// [ target: inner ]
// [ currentTarget: outer ]

// ============================================
// PART 4: MULTIPLE LISTENERS
// ============================================

button.addEventListener('click', () => console.log('first'));
button.addEventListener('click', () => console.log('second'));
// Click:
// [ first ]
// [ second ]

// ============================================
// PART 5: REMOVE LISTENER
// ============================================

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

button.addEventListener('click', handler);
button.removeEventListener('click', handler);

// ============================================
// PART 6: ONCE OPTION
// ============================================

button.addEventListener('click', () => {
  console.log('only once');
}, { once: true });

// ============================================
// PART 7: PREVENT DEFAULT
// ============================================

const form = document.querySelector('form');

form.addEventListener('submit', (event) => {
  event.preventDefault();
  console.log('handled manually');
});
// Form doesn't reload the page

// ============================================
// PART 8: STOP PROPAGATION
// ============================================

inner.addEventListener('click', (event) => {
  event.stopPropagation();
  console.log('stopped');
});

outer.addEventListener('click', () => {
  console.log('never fires');
});
// Click inner:
// [ stopped ] only

// ============================================
// PART 9: CAPTURING
// ============================================

outer.addEventListener('click', () => {
  console.log('capture');
}, true);

inner.addEventListener('click', () => {
  console.log('bubble');
});
// Click inner:
// [ capture ]
// [ bubble ]

// ============================================
// PART 10: EVENT DELEGATION
// ============================================

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

list.addEventListener('click', (event) => {
  const button = event.target.closest('.del');
  if (!button) return;
  console.log('delete', button.dataset.id);
});

// ============================================
// PART 11: DEBOUNCE
// ============================================

function debounce(fn, delay) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  };
}

const search = document.querySelector('#search');
search.addEventListener('input', debounce((event) => {
  console.log('Searching:', event.target.value);
}, 300));

// ============================================
// PART 12: THROTTLE
// ============================================

function throttle(fn, interval) {
  let last = 0;
  return (...args) => {
    const now = Date.now();
    if (now - last >= interval) {
      last = now;
      fn(...args);
    }
  };
}

window.addEventListener('scroll', throttle(() => {
  console.log('scroll');
}, 100));

// ============================================
// PART 13: CUSTOM EVENT
// ============================================

const customEvent = new CustomEvent('user-login', {
  detail: { userId: 42 },
  bubbles: true
});

document.addEventListener('user-login', (event) => {
  console.log('User:', event.detail.userId);
});

document.dispatchEvent(customEvent);
// [ User: 42 ]

// ============================================
// PART 14: KEYBOARD
// ============================================

document.addEventListener('keydown', (event) => {
  if (event.key === 'Escape') {
    console.log('escape pressed');
  }
  if (event.ctrlKey && event.key === 's') {
    event.preventDefault();
    console.log('saving');
  }
});

// ============================================
// PART 15: ABORT CONTROLLER
// ============================================

const controller = new AbortController();

button.addEventListener('click', () => {
  console.log('handler');
}, { signal: controller.signal });

// Later
controller.abort();   // removes listener

// ============================================
// PART 16: CLICK OUTSIDE
// ============================================

function onClickOutside(element, callback) {
  document.addEventListener('click', (event) => {
    if (!element.contains(event.target)) {
      callback();
    }
  });
}

// ============================================
// PART 17: INTERSECTION OBSERVER
// ============================================

const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      console.log('visible:', entry.target.id);
    }
  });
});

// ============================================
// PART 18: MUTATION OBSERVER
// ============================================

const mutObserver = new MutationObserver((mutations) => {
  console.log('changes:', mutations.length);
});

mutObserver.observe(document.body, { childList: true, subtree: true });

// ============================================
// PART 19: CLEANUP
// ============================================

class Component {
  constructor(el) {
    this.el = el;
    this.handleClick = this.handleClick.bind(this);
    this.el.addEventListener('click', this.handleClick);
  }

  handleClick() {
    console.log('clicked');
  }

  destroy() {
    this.el.removeEventListener('click', this.handleClick);
  }
}

// ============================================
// PART 20: FULL EXAMPLE
// ============================================

const searchInput = document.querySelector('#search');
const results = document.querySelector('#results');

let requestId = 0;

searchInput.addEventListener('input', debounce(async (event) => {
  const id = ++requestId;
  const query = event.target.value.trim();

  if (!query) {
    results.innerHTML = '';
    return;
  }

  try {
    const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
    const data = await response.json();

    if (id !== requestId) return;   // stale

    results.innerHTML = data.items
      .map(item => `<li>${item.name}</li>`)
      .join('');
  } catch (err) {
    console.error('Search failed:', err);
  }
}, 300));

Quick Reference

Adding and Removing

MethodPurpose
addEventListener(type, fn, options)Attach
removeEventListener(type, fn, options)Detach
dispatchEvent(event)Fire

Common Events

CategoryEvents
Mouseclick, dblclick, mousedown, mouseup, mousemove, mouseenter, mouseleave, mouseover, mouseout
Keyboardkeydown, keyup
Formsubmit, change, input, focus, blur, reset
DocumentDOMContentLoaded, load, beforeunload
Touchtouchstart, touchmove, touchend
Scrollscroll, wheel
Dragdragstart, drag, dragover, drop, dragend

Event Object

PropertyMeaning
typeEvent name
targetTriggering element
currentTargetListener element
timeStampWhen it fired
detailCustom data (CustomEvent)
keyKey name (keyboard)
clientX, clientYMouse coordinates

Event Methods

MethodEffect
preventDefault()Stop default action
stopPropagation()Stop bubbling/capturing
stopImmediatePropagation()Stop all listeners
composedPath()Event path

Listener Options

OptionDefaultPurpose
capturefalseFire during capture
oncefalseAuto-remove
passivefalseWon’t preventDefault
signalAbortController signal

Propagation Phases

PhaseDirectionListeners
Capturingwindow → targetcapture: true
TargetOn targetBoth
Bubblingtarget → windowcapture: false (default)

Non-Bubbling Events

EventBubbles?
focus / blur
load
mouseenter / mouseleave
DOMContentLoaded
click
keydown
submit

Delegation

StepCode
Attach to parentparent.addEventListener('click', fn)
Check targetevent.target.matches(sel)
Find nearest ancestorevent.target.closest(sel)

Best Practices

Do This:

// Use addEventListener, not inline onclick
button.addEventListener('click', handler);         // ✅

// Use event delegation for lists
list.addEventListener('click', handler);            // ✅

// Use closest for delegation
const item = event.target.closest('li');            // ✅

// Use once for one-time handlers
button.addEventListener('click', fn, { once: true }); // ✅

// Use passive for scroll
window.addEventListener('scroll', fn, { passive: true }); // ✅

// Clean up listeners
el.removeEventListener('click', handler);           // ✅

// Use AbortController for bulk cleanup
const c = new AbortController();
el.addEventListener('click', fn, { signal: c.signal });
c.abort();                                          // ✅

// Debounce input handlers
input.addEventListener('input', debounce(fn, 300)); // ✅

// Bind handlers for removal
this.handleClick = this.handleClick.bind(this);     // ✅

Don’t Do This:

// Don't use inline onclick
<button onclick="doThing()">                     // ❌

// Don't attach listener per item
items.forEach(i => i.addEventListener(...));     // ⚠️  use delegation

// Don't forget to remove listeners
// (causes memory leaks)                          // ❌

// Don't use anonymous functions if you need to remove
el.addEventListener('click', () => {});          // ❌ can't remove
el.removeEventListener('click', () => {});       // ❌ different ref

// Don't use stopPropagation liberally
event.stopPropagation();                         // ⚠️  breaks other handlers

// Don't preventDefault on passive
el.addEventListener('scroll', (e) => {
  e.preventDefault();                            // ⚠️  ignored if passive
}, { passive: true });                           // ❌

// Don't ignore once/AbortController for cleanup
// Repeated adds without removes → leaks          // ❌

// Don't rely on event.target for delegation without closest
if (event.target.classList.contains('x'));       // ⚠️  breaks on nested

Common Pitfalls

PitfallProblemSolution
Anonymous handler removalCan’t removeNamed function
Forgetting to removeMemory leakRemove on cleanup
stopPropagation everywhereBreaks other listenersUse sparingly
preventDefault on passiveIgnoredRemove passive
event.target is nestedWrong elementUse closest
Non-bubbling eventsDelegation failsUse capture
Too many listenersPerformanceDelegate
Race conditions in asyncStale responseCompare IDs
this in handlersLost contextArrow or bind
Not binding for removalWrong referenceBind once, store

Real-World Examples

1. Click Handler

button.addEventListener('click', () => console.log('clicked'));

Fires when the button is clicked. Simple, one-time binding.

2. Event Object

button.addEventListener('click', (event) => {
  console.log(event.target);
});

Access details about the event — target element, coordinates, timestamp.

3. Prevent Form Submission

form.addEventListener('submit', (event) => {
  event.preventDefault();
  handleSubmit();
});

Stops the page from reloading on submit — standard for single-page apps.

4. Stop Propagation

child.addEventListener('click', (event) => {
  event.stopPropagation();
});

Prevents the parent from also receiving the event.

5. Capturing

parent.addEventListener('click', handler, true);

Fires before the target’s own handlers, during the capturing phase.

6. Event Delegation

list.addEventListener('click', (event) => {
  const item = event.target.closest('li');
  if (item) item.classList.toggle('done');
});

One listener handles all list items, including future ones.

7. Debounce Input

input.addEventListener('input', debounce(fn, 300));

Waits for a pause in typing before firing.

8. Throttle Scroll

window.addEventListener('scroll', throttle(fn, 100));

Limits how often the handler runs during scrolling.

9. Keyboard Shortcut

document.addEventListener('keydown', (event) => {
  if (event.ctrlKey && event.key === 's') {
    event.preventDefault();
    save();
  }
});

Listens for Ctrl+S and prevents the browser’s save dialog.

10. Custom Event

el.dispatchEvent(new CustomEvent('done', {
  detail: { id: 42 }
}));

Components communicate through custom events.

11. Once Option

button.addEventListener('click', fn, { once: true });

Auto-removes after the first click — useful for one-time setup.

12. Passive Scroll

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

Tells the browser the handler won’t block scrolling — improves performance.

13. Abort Controller

const controller = new AbortController();
el.addEventListener('click', fn, { signal: controller.signal });
controller.abort();

Removes all listeners tied to the controller in one call.

14. Click Outside

document.addEventListener('click', (event) => {
  if (!modal.contains(event.target)) closeModal();
});

Detects clicks outside an element — common for dropdowns and modals.

15. Intersection Observer

const observer = new IntersectionObserver(entries => {
  entries.forEach(e => {
    if (e.isIntersecting) e.target.classList.add('visible');
  });
});
observer.observe(el);

Fires when elements enter or leave the viewport — used for lazy loading and animations.

16. Mutation Observer

const observer = new MutationObserver(mutations => {
  console.log('changed');
});
observer.observe(target, { childList: true });

Fires when the DOM under target changes.

17. Drag and Drop

draggable.addEventListener('dragstart', (event) => {
  event.dataTransfer.setData('text', event.target.id);
});

Standard pattern for drag-and-drop.

18. Async with Race Protection

let id = 0;
input.addEventListener('input', async () => {
  const currentId = ++id;
  const result = await search(input.value);
  if (currentId !== id) return;
  render(result);
});

Ignores stale responses from earlier requests.

19. Cleanup on Destroy

destroy() {
  this.el.removeEventListener('click', this.handleClick);
  this.observer.disconnect();
}

Prevents memory leaks when components unmount.

20. Full Search Example

const searchInput = document.querySelector('#search');
const results = document.querySelector('#results');

let requestId = 0;

searchInput.addEventListener('input', debounce(async (event) => {
  const id = ++requestId;
  const query = event.target.value.trim();

  if (!query) {
    results.innerHTML = '';
    return;
  }

  try {
    const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
    const data = await response.json();

    if (id !== requestId) return;

    results.innerHTML = data.items
      .map(item => `<li>${item.name}</li>`)
      .join('');
  } catch (err) {
    console.error('Search failed:', err);
  }
}, 300));

Debounced input handler that fetches results and ignores stale responses.


Visual: Event Flow

┌──────────────────────────────────────────────┐
│  User clicks a button nested in <div>        │
│                                              │
│  1. window                                   │
│       │                                      │
│       ▼                                      │
│     document    ← capturing phase            │
│       │                                      │
│       ▼                                      │
│     <html>                                   │
│       │                                      │
│       ▼                                      │
│     <body>                                   │
│       │                                      │
│       ▼                                      │
│     <div>      ← parent listener (capture)   │
│       │                                      │
│       ▼                                      │
│     <button>   ← target phase                │
│       │                                      │
│       ▼                                      │
│     <div>      ← parent listener (bubble)    │
│       │                                      │
│       ▼                                      │
│     <body>                                   │
│       │                                      │
│       ▼                                      │
│     <html>                                   │
│       │                                      │
│       ▼                                      │
│     document                                 │
│       │                                      │
│       ▼                                      │
│     window     ← bubbling phase              │
│                                              │
└──────────────────────────────────────────────┘

Visual: stopPropagation vs preventDefault

┌──────────────────────────────────────────────┐
│  stopPropagation                             │
│                                              │
│  Stops the event from reaching ancestors     │
│  Current handlers still run                  │
│                                              │
│  child  ──► parent ✗                         │
│                                              │
└──────────────────────────────────────────────┘

┌──────────────────────────────────────────────┐
│  preventDefault                              │
│                                              │
│  Stops the browser's default action          │
│  Event still propagates normally             │
│                                              │
│  <a href>     → no navigation                │
│  <form>       → no submission                │
│  checkbox     → no toggle                    │
│  keydown      → no default action            │
│                                              │
└──────────────────────────────────────────────┘

Visual: Event Delegation

┌──────────────────────────────────────────────┐
│  Without delegation                          │
│                                              │
│  <ul>                                        │
│    <li> <button>   ← listener #1             │
│    <li> <button>   ← listener #2             │
│    <li> <button>   ← listener #3             │
│    ... 97 more     ← 97 more listeners       │
│  </ul>                                       │
│                                              │
│  ❌ 100 listeners, slow to add/remove        │
│                                              │
└──────────────────────────────────────────────┘

┌──────────────────────────────────────────────┐
│  With delegation                             │
│                                              │
│  <ul>  ← one listener                        │
│    <li> <button>                             │
│    <li> <button>                             │
│    <li> <button>                             │
│    ... 97 more                               │
│  </ul>                                       │
│                                              │
│  ✅ 1 listener handles everything            │
│                                              │
└──────────────────────────────────────────────┘

Visual: Debounce vs Throttle

┌──────────────────────────────────────────────┐
│  Input events (typing)                       │
│                                              │
│  a a a a a b b b c c c                       │
│  ▲ ▲ ▲ ▲ ▲ ▲ ▲ ▲ ▲ ▲ ▲                       │
│                                              │
│  Every keystroke fires a handler            │
│                                              │
└──────────────────────────────────────────────┘

┌──────────────────────────────────────────────┐
│  Debounce (300ms)                            │
│                                              │
│  a a a a a b b b c c c  [wait 300ms]         │
│                                    ▲         │
│                          fires once, at end  │
│                                              │
└──────────────────────────────────────────────┘

┌──────────────────────────────────────────────┐
│  Throttle (100ms)                            │
│                                              │
│  a a a a a b b b c c c                       │
│  ▲         ▲         ▲                       │
│                                              │
│  fires at most every 100ms                   │
│                                              │
└──────────────────────────────────────────────┘

Summary

ConceptMethodExample
ListenaddEventListenerel.addEventListener('click', fn)
RemoveremoveEventListenerel.removeEventListener('click', fn)
DispatchdispatchEventel.dispatchEvent(event)
DefaultpreventDefaultevent.preventDefault()
PropagatestopPropagationevent.stopPropagation()
Targetevent.targetTriggering element
Listenerevent.currentTargetListener element
DelegationParent listenerparent.addEventListener
Once{ once: true }Auto-remove
Passive{ passive: true }Scroll perf
Capture{ capture: true }Capturing phase
Signal{ signal }AbortController
CustomCustomEventUser-defined events

Key takeaways:

  • Events flow in three phases: capture, target, bubble
  • addEventListener is the standard — never use inline onclick
  • event.target is the triggered element; event.currentTarget is the listener element
  • preventDefault stops the browser default; stopPropagation stops propagation
  • Event delegation — one parent listener handles many children via bubbling
  • Use event.target.closest(sel) to find the right ancestor during delegation
  • once, passive, capture, and signal are listener options worth knowing
  • Non-bubbling events (focus, blur, mouseenter) need capture for delegation
  • Debounce and throttle control how often high-frequency events fire
  • AbortController removes many listeners in one call
  • IntersectionObserver, MutationObserver, ResizeObserver replace polling with reactive hooks
  • Always clean up listeners to prevent memory leaks

Remember: Events are how your page reacts. Listen with addEventListener, delegate when you have many elements, and always clean up. Understand the three phases — capturing, target, bubbling — so you know when your handlers fire. Prevent the default when you’re taking over, and stop propagation sparingly. Debounce input, throttle scroll, and use observers for visibility and mutations. Master events, and your UI responds to the user without a hitch.


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!