Beginner45 minutesJavaScriptDOM

Advanced Event Handling

Learning Objectives

By the end of this lesson, you'll be able to:

  • Create Create and dispatch custom events with CustomEvent
  • Pass data through custom events using the detail property
  • Remove event listeners properly to prevent memory leaks
  • Implement throttling to limit how often a handler fires
  • Implement debouncing to delay a handler until activity stops
  • Use once, passive, and capture options with addEventListener
  • Build a focus trap for accessible modal dialogs
  • Apply Apply event.preventDefault() and event.stopPropagation() strategically

Custom Events

The browser has dozens of built-in events — click, keydown, submit — but sometimes you need your own. Custom events let different parts of your code communicate without being tightly coupled.

Creating a Custom Event

Use the CustomEvent constructor to create an event with any name you choose:

// Create a custom event
const cartEvent = new CustomEvent('item-added', {
  detail: {
    productId: 42,
    name: 'Wireless Mouse',
    price: 29.99
  },
  bubbles: true
});

// Dispatch the event on any element
document.querySelector('.add-to-cart').dispatchEvent(cartEvent);

Listening for Custom Events

Custom events are listened for the same way as built-in events. The detail property carries your data:

// Listen anywhere up the DOM tree (because bubbles: true)
document.addEventListener('item-added', (event) => {
  console.log('Added:', event.detail.name);
  console.log('Price:', event.detail.price);
  updateCartBadge();
});

When to use custom events: They shine when you have independent UI components that need to communicate. A product card can fire item-added, and the cart icon can listen for it — neither needs to know the other exists.

Removing Event Listeners

Every addEventListener allocates memory. If you add listeners to elements that get removed from the page (like items in a dynamic list), those listeners stay in memory unless you explicitly remove them. This is called a memory leak.

The Named Function Pattern

To remove a listener, you need a reference to the exact same function you passed to addEventListener:

// ✅ Named function — can be removed
function handleClick(event) {
  console.log('Clicked!', event.target);
}

button.addEventListener('click', handleClick);
// Later, when done:
button.removeEventListener('click', handleClick);

// ❌ Anonymous function — CANNOT be removed
button.addEventListener('click', (event) => {
  console.log('Clicked!');
});
// No way to reference this function to remove it!

The AbortController Pattern

Modern JavaScript offers a cleaner way to manage listener cleanup using AbortController:

const controller = new AbortController();

// Add multiple listeners with the same signal
button.addEventListener('click', handleClick, { signal: controller.signal });
button.addEventListener('mouseenter', handleHover, { signal: controller.signal });
window.addEventListener('resize', handleResize, { signal: controller.signal });

// Remove ALL of them at once
controller.abort();

Memory leak warning: If you create a list of 100 items with click listeners, then replace the list with new HTML, those 100 old listeners still exist in memory. Always clean up before replacing content, or use event delegation on a stable parent element.

⏸️ Pause & Check: Do You Understand?

Before moving forward, can you answer these?

  1. What is the difference between a regular Event and a CustomEvent?
  2. Why can’t you remove an anonymous arrow function passed to addEventListener?
  3. How does AbortController simplify listener cleanup?
Check Your Answers
  1. CustomEvent has a detail property where you can attach any data you want to pass along with the event. Regular Event objects don’t carry custom data.
  2. removeEventListener needs a reference to the exact same function object. Anonymous functions create a new function object each time, so there’s no reference to pass to removeEventListener.
  3. You pass the same AbortSignal to multiple addEventListener calls. When you call controller.abort(), all listeners registered with that signal are removed at once — no need to track individual function references.

How confident are you with this concept?

😕 Still confused | 🤔 Getting there | 😊 Got it! | 🎉 Could explain it to a friend!

Listener Options

addEventListener accepts a third argument — an options object that gives you fine-grained control over how the listener behaves:

OptionTypeWhat it does
onceBooleanListener automatically removes itself after firing once
passiveBooleanPromises not to call preventDefault() — lets the browser optimise scrolling
captureBooleanFires during the capture phase instead of the bubble phase
signalAbortSignalRemoves the listener when the signal is aborted

Practical Examples

// Only fire once (great for one-time animations)
dialog.addEventListener('transitionend', () => {
  dialog.remove();
}, { once: true });

// Passive scroll listener (smoother performance)
window.addEventListener('scroll', handleScroll, { passive: true });

// Capture phase — fires BEFORE the target element
document.addEventListener('click', (e) => {
  console.log('Captured on document first!');
}, { capture: true });

Performance tip: Always use { passive: true } for scroll, touchstart, and touchmove listeners. This tells the browser it can scroll immediately without waiting for your JavaScript to finish, resulting in noticeably smoother scrolling on mobile devices.

Throttle and Debounce

Some events fire extremely frequently — scroll, resize, and mousemove can fire hundreds of times per second. Running expensive operations on every single event kills performance. Two techniques solve this: throttling and debouncing.

Throttle

“Run at most once every N milliseconds”

Like a speed limit. No matter how fast events fire, the handler only runs at regular intervals.

Use for: scroll position checks, window resize, mouse tracking

Debounce

“Wait until activity stops, then run once”

Like an elevator — the door only closes when people stop getting on. Resets the timer on each new event.

Use for: search input, form validation, auto-save

Throttle Implementation

function throttle(fn, limit) {
  let waiting = false;
  return function(...args) {
    if (!waiting) {
      fn.apply(this, args);
      waiting = true;
      setTimeout(() => { waiting = false; }, limit);
    }
  };
}

// Scroll handler runs at most once every 200ms
window.addEventListener('scroll',
  throttle(updateScrollIndicator, 200),
  { passive: true }
);

Debounce Implementation

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

// Only search after user stops typing for 300ms
searchInput.addEventListener('input',
  debounce(performSearch, 300)
);

⏸️ Pause & Check: Do You Understand?

Before moving forward, can you answer these?

  1. When would you use throttle instead of debounce?
  2. What does the passive option do, and why does it improve scrolling performance?
  3. A listener set with { once: true } fires. What happens to it after?
Check Your Answers
  1. Use throttle when you want regular updates at a controlled rate (e.g., updating a scroll progress bar every 200ms). Use debounce when you only care about the final value after activity stops (e.g., search-as-you-type after the user finishes typing).
  2. passive: true tells the browser the listener will never call preventDefault(). This means the browser can start scrolling immediately without waiting for your JavaScript to finish executing, because it knows you won’t try to cancel the scroll.
  3. It automatically removes itself. The browser calls removeEventListener for you after the handler runs once. It’s equivalent to calling removeEventListener inside the handler.

How confident are you with this concept?

😕 Still confused | 🤔 Getting there | 😊 Got it! | 🎉 Could explain it to a friend!

Controlling Event Flow

Two powerful methods on the event object let you override default browser behaviour and control how events travel through the DOM:

event.preventDefault()

Stops the browser’s default action for an event. The event still propagates normally.

// Prevent a form from submitting (validate first)
form.addEventListener('submit', (event) => {
  if (!isFormValid()) {
    event.preventDefault();
    showErrors();
  }
});

// Prevent a link from navigating
link.addEventListener('click', (event) => {
  event.preventDefault();
  smoothScrollTo(link.hash);
});

event.stopPropagation()

Prevents the event from bubbling up to parent elements. Use sparingly — it can break event delegation.

// Clicking the close button shouldn't also trigger the card click
card.addEventListener('click', () => {
  openCardDetail();
});

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

Warning: Avoid using stopPropagation() as a quick fix. It prevents all parent listeners from seeing the event, which can break analytics tracking, accessibility features, and event delegation patterns. Often there’s a better solution.

Focus Trapping for Modals

When a modal dialog opens, keyboard users pressing Tab can accidentally focus elements behind the overlay. A focus trap keeps focus inside the modal until it’s closed. This is essential for accessibility.

function trapFocus(modal) {
  const focusable = modal.querySelectorAll(
    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
  );
  const first = focusable[0];
  const last = focusable[focusable.length - 1];

  modal.addEventListener('keydown', (e) => {
    if (e.key !== 'Tab') return;

    if (e.shiftKey) {
      // Shift+Tab: if on first element, wrap to last
      if (document.activeElement === first) {
        e.preventDefault();
        last.focus();
      }
    } else {
      // Tab: if on last element, wrap to first
      if (document.activeElement === last) {
        e.preventDefault();
        first.focus();
      }
    }
  });

  first.focus();
}

Accessibility note: The <dialog> HTML element provides built-in focus trapping when opened with showModal(). Use it when possible. The manual approach above is useful for custom modal implementations or when supporting older browsers.

Common Event Patterns

Here are three patterns that professional developers use daily:

1. Pub/Sub with Custom Events

Use a single element as an event bus to decouple components:

const bus = document.createElement('div');

// Module A publishes
bus.dispatchEvent(new CustomEvent('user-logged-in', {
  detail: { username: 'alice' }
}));

// Module B subscribes
bus.addEventListener('user-logged-in', (e) => {
  showWelcomeMessage(e.detail.username);
});

2. One-Time Setup with { once: true }

// Lazy-load a library the first time a feature is used
mapButton.addEventListener('click', async () => {
  const maps = await import('./maps.js');
  maps.init();
}, { once: true });

3. Escape Key to Close

function openModal(modal) {
  modal.hidden = false;
  trapFocus(modal);

  const handleEsc = (e) => {
    if (e.key === 'Escape') {
      closeModal(modal);
      document.removeEventListener('keydown', handleEsc);
    }
  };
  document.addEventListener('keydown', handleEsc);
}

⏸️ Pause & Check: Do You Understand?

Before moving forward, can you answer these?

  1. Why is stopPropagation() considered risky to use?
  2. What is the purpose of a focus trap in a modal?
  3. Describe the Pub/Sub pattern using custom events.
Check Your Answers
  1. It prevents ALL parent elements from seeing the event, not just the one you’re worried about. This can silently break analytics tracking, accessibility features, event delegation, and other listeners that depend on events bubbling up.
  2. It keeps keyboard focus inside the modal while it’s open. Without it, a user pressing Tab could focus elements behind the overlay that they can’t see, which breaks keyboard navigation and is an accessibility violation.
  3. A shared element acts as a message bus. One module dispatches custom events (publish), and other modules listen for those events (subscribe). The modules don’t need references to each other — they only know about the bus and the event names.

How confident are you with this concept?

😕 Still confused | 🤔 Getting there | 😊 Got it! | 🎉 Could explain it to a friend!

Guided Practice: Notification System

Build a Toast Notification System

Create the HTML structure

Create a file called notifications.html with a container for toast notifications and a few trigger buttons:




  
  Notification System
  


  

Notification System

💡 Need a hint?
The toast container is fixed in the top-right corner. Each toast will be appended here.

Create and dispatch a custom event

In notifications.js, listen for button clicks and dispatch a custom notify event:

document.addEventListener('click', (e) => {
  const btn = e.target.closest('button[data-type]');
  if (!btn) return;

  const event = new CustomEvent('notify', {
    detail: {
      type: btn.dataset.type,
      message: `This is a ${btn.dataset.type} notification!`
    },
    bubbles: true
  });
  document.dispatchEvent(event);
});
💡 Need a hint?
We use event delegation on document and filter by data-type. The custom event bubbles so any listener on document will catch it.

Listen for the custom event and create toasts

Add a listener for the notify event that creates a toast element:

const container = document.querySelector('.toast-container');

document.addEventListener('notify', (e) => {
  const toast = document.createElement('div');
  toast.className = `toast ${e.detail.type}`;
  toast.textContent = e.detail.message;
  container.appendChild(toast);

  // Auto-dismiss after 3 seconds
  setTimeout(() => toast.classList.add('fade-out'), 3000);

  // Remove from DOM after the fade transition ends
  toast.addEventListener('transitionend', () => {
    toast.remove();
  }, { once: true });
});
💡 Need a hint?
The { once: true } option ensures the transitionend listener is automatically cleaned up when the toast is removed.

Add Escape key to dismiss all

Add a keydown listener that clears all toasts when Escape is pressed:

document.addEventListener('keydown', (e) => {
  if (e.key === 'Escape') {
    container.querySelectorAll('.toast').forEach(toast => {
      toast.classList.add('fade-out');
      toast.addEventListener('transitionend', () => {
        toast.remove();
      }, { once: true });
    });
  }
});
💡 Need a hint?
We reuse the same fade-out and cleanup pattern. Each toast removes itself after its transition completes.

Test the system

Open your page in a browser and verify:

  • Clicking each button creates a coloured toast notification
  • Toasts auto-dismiss after 3 seconds with a fade animation
  • Pressing Escape dismisses all open notifications
  • Removed toasts are completely gone from the DOM (inspect with DevTools)

Check the Elements panel in DevTools to confirm that no orphaned elements or listeners remain after toasts disappear.

💡 Need a hint?
Use the Performance tab in DevTools to watch for memory that doesn’t get released.

You're on track if you can:

  • ☐ Custom events fire when notifications are created
  • ☐ Notifications auto-dismiss using { once: true } on transitionend
  • ☐ Escape key closes all open notifications
  • ☐ No memory leaks — listeners are cleaned up when notifications are removed

Independent Practice

💪 Independent Challenge

Now try this on your own without hints!

Your Task:

Build an accessible modal dialog with proper event handling. The modal should open and close with animations, trap keyboard focus, close on Escape or backdrop click, and use custom events to notify the rest of the page when it opens and closes.

Requirements:
  • Modal opens with a fade-in transition and closes with fade-out
  • Focus is trapped inside the modal while open (Tab wraps around)
  • Pressing Escape closes the modal
  • Clicking the backdrop (outside the modal content) closes the modal
  • Dispatch custom events: modal-opened and modal-closed with detail data
  • All event listeners are properly cleaned up when the modal closes
Stretch Goals (Optional):
  • Stack multiple modals (closing the top one reveals the one beneath)
  • Add a debounced search input inside the modal
  • Use AbortController for all listener cleanup
  • Restore focus to the element that opened the modal after closing

Success Criteria:

CriteriaYou've succeeded if...
Custom events dispatch with dataCustomEvent fires with relevant detail on open and close
Focus trapping works correctlyTab and Shift+Tab wrap within the modal
Escape and backdrop close workModal closes cleanly with both methods
No memory leaksAll listeners removed after modal closes
Smooth transitionsCSS transitions work with once: true on transitionend

Lesson Complete: What You Learned

Key Takeaways:

  • CustomEvent lets you create your own events with data in the detail property
  • Always use named functions or AbortController so you can remove listeners and prevent memory leaks
  • Use { once: true } for one-time listeners and { passive: true } for scroll/touch performance
  • Throttle limits how often a handler runs; debounce waits until activity stops
  • Use preventDefault() to override default browser behaviour; use stopPropagation() sparingly
  • Focus trapping is essential for accessible modal dialogs

Learning Objectives Review:

Look back at what you set out to learn. Can you now:

  • ✅ Create and dispatch custom events with CustomEvent Check!
  • ✅ Pass data through custom events using the detail property Got it!
  • ✅ Remove event listeners properly to prevent memory leaks Can explain it!
  • ✅ Implement throttling and debouncing Could teach this!
  • ✅ Use once, passive, and capture options with addEventListener Check!
  • ✅ Build a focus trap for accessible modal dialogs Got it!
  • ✅ Apply preventDefault() and stopPropagation() strategically Can explain it!

If you can confidently answer "yes" to most of these, you're ready to move on!

Think & Reflect:

💭 💭 Reflection Questions

  • When would you choose custom events over a simple function call between modules?
  • Can you think of a feature on a website you use daily that likely uses throttle or debounce?
  • Why is it important to restore focus to the trigger element after closing a modal?
  • How would memory leaks from unremoved listeners manifest to a user?

🤔 Real-World Test:

Advanced event handling is the backbone of every modern web application. Social media feeds use throttled scroll listeners for infinite scrolling. Search bars use debounced input handlers to avoid hammering APIs. Modal dialogs use focus traps for accessibility compliance. Shopping carts use custom events to decouple the product listing from the cart icon.

Understanding these patterns is what separates a developer who can build features from one who can build features that are fast, accessible, and leak-free.

🎯 Looking Ahead:

Now that you can handle events like a pro, it’s time to learn how to create and manage dynamic content. In the Dynamic Content tutorial, you’ll learn to build, update, and efficiently render content on the fly — combining everything you’ve learned about DOM manipulation, traversal, and events.

Recommended Next Steps

Continue Learning

Ready to move forward? Continue with the next tutorial in this series:

DOM Traversal

Related Topics

Explore these related tutorials to expand your knowledge:

Additional Resources

Deepen your understanding with these helpful resources:

Progress tracking is disabled. Enable it in to track your completed tutorials.