Problem Solved

JavaScript · event delegation

Home › Courses › JavaScript › Problems › Event delegation
🟨 Concept · 3 min read

Event delegation.
One listener for many.

Event delegation is the pattern of attaching one listener to a parent instead of many listeners to children. When a child is clicked, the event bubbles up, and the parent handles it. This works for static lists, dynamic lists, and lists that don't exist yet. The key tools: event.target (what was clicked), event.currentTarget (where the listener lives), and closest() (find the real target). This page covers the pattern, the difference between target and currentTarget, and when delegation wins.

Short answer: Attach the listener to a parent container. Inside the handler, use event.target.closest('.item') to find the actual clicked item. Check if it exists (null if the click was outside any item). Act on it. This works for any number of items, including ones added later. Only use for events that bubble — click, input, keydown, etc. — not focus, scroll, or mouseenter. Delegation wins for lists of 20+ items; below that, individual listeners are fine.

✅ Before and after

The same list, two patterns.

delegation.js
// ❌ Individual listeners — doesn't work for new items
const items = document.querySelectorAll('.item');

items.forEach(item => {
  item.addEventListener('click', () => {
    console.log('Clicked:', item.textContent);
  });
});

// Problem: adding a new item to the list
// requires attaching a new listener. Forget it,
// and the new item doesn't respond to clicks.

// ✅ Event delegation — works for all items, current and future
const list = document.querySelector('.list');

list.addEventListener('click', (event) => {
  // Find the actual item that was clicked
  const item = event.target.closest('.item');

  // Ignore clicks outside any item
  if (!item) return;

  console.log('Clicked:', item.textContent);
});

// Now: adding items to the list works automatically.
// The new items respond to clicks without any extra code.

const newItem = document.createElement('li');
newItem.className = 'item';
newItem.textContent = 'New item';
list.appendChild(newItem);
// ✅ This new item is clickable immediately.
How it works:

1. One listener on .list, not on each .item.

2. User clicks something inside .list.
   → The event fires on the actual clicked element
     (could be the .item itself, or a child).

3. The event bubbles up to .list.

4. The listener runs. event.target is the
   actual clicked element.

5. event.target.closest('.item') walks up
   from the target to find the nearest
   .item ancestor (including the target itself).

6. If found, we know an item was clicked.
   If null, the click was elsewhere in .list
   (a header, a gap, etc.).

7. Act on the item.

The key line:

  const item = event.target.closest('.item');

Without closest, you'd need to check at each level:

  let el = event.target;
  while (el && !el.classList.contains('item')) {
    el = el.parentElement;
  }

That's exactly what closest does — but built in,
and faster.

⚠️ Always check for null:
   closest returns null if no ancestor matches.
   The click might have been on the container itself,
   not on any item.

   if (!item) return;   // or handle the outside-click case

⚠️ closest walks UP. It includes the element itself.
   If event.target IS the .item, closest returns it.

⚠️ closest is case-sensitive.
   .closest('.Item') will not match .item.
   Use consistent class names.

✅ target vs currentTarget

The distinction that trips people up.

target-vs-current.js
// HTML:
// 
    //
  • // Click me //
  • //
const list = document.querySelector('.list'); list.addEventListener('click', (event) => { console.log('target:', event.target); console.log('currentTarget:', event.currentTarget); console.log('closest item:', event.target.closest('.item')); }); // If user clicks the : // target: Click me (the span) // currentTarget:
    ...
(the parent) // closest item:
  • ...
  • (the item) // If user clicks the
  • (not the span): // target:
  • ...
  • // currentTarget:
      ...
    // closest item:
  • ...
  • // If user clicks the
      (padding area): // target:
        ...
      // currentTarget:
        ...
      // closest item: null (no .item ancestor)
    The difference:
    
    event.target:
      → The element that triggered the event
      → The actual clicked element
      → Can be any descendant of the listener
    
    event.currentTarget:
      → The element the listener is attached to
      → Always the parent in delegation
      → Equal to `this` in the handler (for non-arrow functions)
    
    In delegation, you want:
      event.target — to know what was clicked
      closest() — to find the semantic element
    
    You rarely use currentTarget in delegation.
    You attach the listener to the container,
    and you already know what container that is.
    
    But currentTarget is useful:
    
    ✅ In code that might be attached to different
       containers — you can always find the parent.
    
    ✅ When you're not sure where the listener is
       attached (a helper function that accepts
       a listener).
    
    ✅ In event handler libraries, where the
       listener is attached inside the library.
    
    For a plain delegation handler:
    
    list.addEventListener('click', (event) => {
      const item = event.target.closest('.item');
      if (!item) return;
      // item is the actual clicked item
    });
    
    You don't need currentTarget.
    
    ⚠️ `this` in the handler:
       For regular functions, `this` === currentTarget.
       For arrow functions, `this` is the outer scope.
       Since delegation handlers are usually arrow
       functions (to preserve context), use
       event.currentTarget, not `this`.
    
    The rule: target = what fired. currentTarget = where the listener is.

    ✅ Which events bubble

    Delegation works for bubbling events only.

    bubbling.js
    // ✅ Events that bubble — delegation works
    // click, dblclick, mousedown, mouseup, mousemove,
    // keydown, keyup, keypress, input, change, submit,
    // focusin, focusout, mouseover, mouseout,
    // touchstart, touchend, pointerdown, pointerup
    
    // ❌ Events that DON'T bubble — delegation doesn't work
    // focus, blur (use focusin, focusout instead)
    // mouseenter, mouseleave (use mouseover, mouseout instead)
    // scroll (no bubbling equivalent — attach directly)
    // resize (on window only — attach directly)
    // load, unload (element-specific — attach directly)
    
    // ✅ focus → use focusin (which bubbles)
    list.addEventListener('focusin', (event) => {
      const input = event.target.closest('input');
      if (input) console.log('Focused:', input.name);
    });
    
    // ✅ mouseenter → use mouseover (which bubbles)
    list.addEventListener('mouseover', (event) => {
      const item = event.target.closest('.item');
      if (item) item.classList.add('hover');
    });
    
    // ❌ scroll doesn't bubble — attach directly, or use capture
    // Option 1: attach directly to each scrollable element
    document.querySelectorAll('.scrollable').forEach(el => {
      el.addEventListener('scroll', handleScroll);
    });
    
    // Option 2: capture phase — fires on the way down
    document.addEventListener('scroll', handleScroll, true);
    // Note: capture phase events fire for ALL scroll events,
    // not just those inside a specific container.
    The bubbling rule:
    
    An event bubbles if it has bubbles: true in its spec.
    Most UI events do. A few don't.
    
    Bubbling events (delegation works):
    
      Mouse:
        ✅ click, dblclick
        ✅ mousedown, mouseup, mousemove
        ✅ contextmenu (right-click)
        ✅ mouseover, mouseout (the bubbling versions)
        ❌ mouseenter, mouseleave (the non-bubbling versions)
    
      Keyboard:
        ✅ keydown, keyup, keypress
    
      Form:
        ✅ input, change, submit, reset
        ✅ focusin, focusout (bubbling)
        ❌ focus, blur (non-bubbling)
    
      Touch / Pointer:
        ✅ touchstart, touchend, touchmove
        ✅ pointerdown, pointerup, pointermove
    
      Other:
        ❌ scroll (doesn't bubble)
        ❌ resize (window only)
        ❌ load, unload (element-specific)
    
    The pairs to know:
      focus / blur         → don't bubble
      focusin / focusout   → do bubble
      mouseenter / mouseleave → don't bubble
      mouseover / mouseout → do bubble
    
    The mental model:
      focus/blur fire when the element itself gains
      or loses focus. They don't bubble because
      focus is element-specific.
      focusin/focusout fire when the element OR
      a descendant gains or loses focus. They bubble.
    
      The same logic for mouseenter/mouseover:
      mouseenter fires only when the mouse enters
      the element itself (not children).
      mouseover fires when entering the element or
      any child, so it can bubble.
    
    For scroll: there's no bubbling equivalent.
    Scroll events fire on the scrolling element.
    To handle them from a parent, use capture phase
    (third argument true), or attach directly.
    
    ⚠️ Capture phase events fire in the CAPTURE order
       (top-down), before the target.
       Not the same as delegation (which relies on
       bubble order, bottom-up).
       Use capture only when you must.

    Don't forget to check for null after closest(). event.target.closest('.item') returns null if no ancestor matches — this happens when the click is on the container itself (padding, gaps between items) or on a non-item child. If you don't check, you'll try to call methods on null and throw TypeError: Cannot read property ... of null. The pattern is: const item = event.target.closest('.item'); if (!item) return;. This is the single most common event delegation bug. Add the guard first, always. It's one line and it prevents a whole class of runtime errors.

    ⚠️ Common mistakes

    The event delegation mistakes that cost real time.

    1. Forgetting the null check

    closest() can return null. Fix: if (!item) return; before acting.

    2. Using event.target directly

    It's the deepest clicked element, not the item. Fix: event.target.closest('.item').

    3. Delegating non-bubbling events

    focus, blur, scroll don't bubble. Fix: focusin, focusout, or direct listeners.

    4. Delegating to document for everything

    Performance and specificity issues. Fix: delegate to the nearest common parent.

    5. Closest with wrong selector

    .closest('.Item') — case-sensitive. Fix: match the class name exactly.

    6. Expecting delegation for shadow DOM

    Events don't cross shadow boundaries. Fix: delegate inside the shadow root.

    7. Using capture phase for delegation

    Events fire on the way down, not up. Fix: bubble phase (default).

    8. Forgetting to remove the listener

    Memory leaks on SPA navigation. Fix: removeEventListener on unmount, or use an AbortController.

    ❓ Frequently asked

    What is event delegation?

    Event delegation is a pattern where you attach one event listener to a parent element instead of many listeners to individual children. When a child element is clicked, the event bubbles up to the parent, where the single listener handles it. You use event.target to identify which child was clicked. This works because events bubble.

    What is the difference between event.target and event.currentTarget?

    event.target is the element that triggered the event — the actual clicked element. event.currentTarget is the element with the listener attached — the parent you attached it to. For delegation, you usually want event.target (to know what was clicked). In simple handlers, they're the same. In delegation, they differ.

    What does closest() do?

    closest(selector) walks up the DOM from the element (including itself) and returns the first ancestor that matches the selector, or null if none. It's the standard way to find the 'real' target in event delegation. Without closest, you'd have to walk up the DOM manually.

    Why use event delegation?

    Three reasons. (1) Performance — one listener is cheaper than 100. (2) Dynamic content — listeners attached to the parent automatically handle new children. (3) Memory — fewer listeners mean less memory. The trade-off: you have to use closest() to find the right target.

    Does event delegation work for all events?

    Not all — only events that bubble. click, mousedown, keydown, input, change, submit all bubble. But focus and blur don't bubble (use focusin and focusout). mouseenter and mouseleave don't bubble (use mouseover and mouseout). scroll doesn't bubble (use capture phase or direct listeners).

    What are the performance implications?

    Delegation is usually faster for large lists, but not always. One listener on the parent is cheaper than 1000 listeners on children. But each event still fires the handler and runs closest(). The break-even point is around 20-50 items. Above that, delegate. Optimize only if you've measured.

    🔗 Continue learning

    🗺️ JavaScript learning path
    The full roadmap from variables to async — every concept in order

    You've reached the end of the JavaScript problems series. That's all 20 — from var/let/const to event delegation, covering the queries that matter most.

    ← All JavaScript problems
    Problem solved · JavaScript