Senior 5 min · March 05, 2026

Event Delegation in JavaScript Explained — How, Why, and When to Use It

Event delegation in JavaScript lets you handle events efficiently using a single listener.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • Attach one listener to a parent element instead of many to children
  • Works because events bubble up the DOM: click on child → parent hears it
  • Use event.target.closest(selector) to find the actual clicked element
  • Handles dynamically added elements automatically — no rebinding needed
  • Risks: stopPropagation() kills delegation silently; focus/blur don't bubble
  • Performance gain: constant memory, not O(n) — 200 rows = 1 listener, not 200
Plain-English First

Imagine a school with 500 students. Instead of giving every single student a walkie-talkie to report problems, you give one to the principal's office — and any time a student causes trouble, the office figures out which student did it and handles it. Event delegation works the same way: instead of attaching a listener to every button or list item on your page, you attach one listener to their parent, and let it figure out which child was clicked.

Every interactive web app has events — clicks, key presses, form submissions. The naive approach is to grab every element and slap an event listener on each one. For five buttons that's fine. For a dynamic list that grows to 500 items? You've just created a memory and performance problem you might not notice until your app starts to feel sluggish on older devices or slower connections.

Event delegation solves this by exploiting a fundamental browser behaviour called event bubbling — the fact that when you click a child element, that click event travels up through every ancestor in the DOM tree. Instead of listening on each child, you listen on one parent and inspect which child triggered the event. One listener does the work of hundreds.

By the end of this article you'll understand exactly why event bubbling makes delegation possible, how to implement it cleanly in real-world scenarios like dynamic lists and data tables, and the subtle mistakes that make delegation break silently — the kind of bugs that waste an afternoon if you don't know where to look.

Event Bubbling — The Mechanism That Makes Delegation Possible

Before delegation makes sense, you need a rock-solid mental model of event bubbling. When a user clicks a button inside a div inside a section, the browser fires the click event on the button first. Then it fires it on the div. Then the section. Then the body. Then the html element. Then the window. This upward journey is called bubbling.

This is not a quirk — it's a deliberate, consistent part of the DOM Events specification. Every click, keydown, input, and submit event bubbles by default (with a few exceptions like focus and blur, which we'll cover later).

The key insight is this: if every click on a child element also fires on the parent, then the parent can intercept that event and ask 'which child actually started this?' That question is answered by event.target — the exact element the user interacted with, regardless of where the listener is attached. The element the listener is attached to is event.currentTarget. Keep those two straight and delegation becomes straightforward.

understanding-bubbling.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Set up a simple nested structure to watch bubbling in action
const container = document.querySelector('#container');  // outer div
const button    = document.querySelector('#action-btn'); // inner button

// Listener on the PARENT container
container.addEventListener('click', function(event) {
  // event.target  → the element the user actually clicked
  // event.currentTarget → the element this listener is attached to (container)
  console.log('Target (who was clicked):',      event.target.id);
  console.log('CurrentTarget (who is listening):', event.currentTarget.id);
});

// Listener on the CHILD button — fires FIRST because bubbling goes outward
button.addEventListener('click', function(event) {
  console.log('Button listener fired — id:', event.target.id);
});

// When the user clicks the button, both listeners fire.
// The button listener runs first, then the event bubbles to the container.
Output
Button listener fired — id: action-btn
Target (who was clicked): action-btn
CurrentTarget (who is listening): container
Key Distinction:
event.target is always the originating element — the one the user touched. event.currentTarget is the element the listener lives on. In delegation, these are always different, and confusing them is the #1 source of delegation bugs.
Production Insight
Bubbling is guaranteed for most events, but third-party code can break it silently.
In production, you'll find stopPropagation() in outdated jQuery plugins or old analytics scripts.
Always check for propagation killers when a delegated listener goes dark.
Key Takeaway
Bubbling is the engine of delegation.
If an event doesn't bubble, you can't delegate it without a workaround.
Bubbling Behavior Check
IfEvent type is click, keydown, input, submit, mouseover, mouseout
UseBubbles by default — safe for delegation
IfEvent type is focus, blur, mouseenter, mouseleave, scroll (in some browsers)
UseDoes NOT bubble — use focusin/focusout or mouseover/mouseout instead

Writing Your First Delegated Event Listener — A Dynamic Task List

The classic use case for delegation is a dynamic list where items are added or removed at runtime. If you add a listener to each list item when it's created, you have to remember to remove it when the item is deleted, or you leak memory. With delegation, you attach one listener to the parent list, and it handles every item — including ones that don't exist yet.

The pattern is always the same three steps: attach the listener to the stable parent, check event.target to see what was clicked, and filter using matches() or closest() to make sure you're reacting to the right element type. The matches() method lets you test an element against a CSS selector string — it returns true if the element matches. This is how you distinguish a click on a delete button versus a click on the list item's text, both of which bubble to the same parent.

closest() is even more useful in real apps. It walks up the DOM from event.target until it finds an ancestor matching your selector — or returns null if none exists. This handles the common case where your clickable element contains child elements like icons or spans that might be the actual event.target.

dynamic-task-list.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// --- HTML structure assumed:
// <ul id="task-list"></ul>
// <button id="add-task-btn">Add Task</button>

const taskList   = document.querySelector('#task-list');
const addButton  = document.querySelector('#add-task-btn');
let   taskCount  = 1;

// ─── ADD TASKS DYNAMICALLY ────────────────────────────────────────────
addButton.addEventListener('click', function() {
  const listItem = document.createElement('li');
  listItem.dataset.taskId = taskCount; // store ID on the element itself

  // Each item has a label and a delete button inside it
  listItem.innerHTML = `
    <span class="task-label">Task #${taskCount}</span>
    <button class="delete-btn" aria-label="Delete task ${taskCount}">✕</button>
  `;

  taskList.appendChild(listItem);
  taskCount++;
});

// ─── ONE DELEGATED LISTENER FOR ALL CURRENT AND FUTURE ITEMS ─────────
taskList.addEventListener('click', function(event) {
  // closest() walks UP from the clicked element (even if it was the icon
  // inside the button) until it finds a .delete-btn, or returns null
  const deleteButton = event.target.closest('.delete-btn');

  if (deleteButton) {
    // closest() on the button itself to find its parent <li>
    const taskItem = deleteButton.closest('li');
    const taskId   = taskItem.dataset.taskId;

    console.log(`Deleting task with ID: ${taskId}`);
    taskItem.remove(); // cleanly removes the element — no listener cleanup needed
    return;
  }

  // Handle clicks on the task label (e.g. mark complete)
  const taskLabel = event.target.closest('.task-label');
  if (taskLabel) {
    const taskItem = taskLabel.closest('li');
    taskItem.classList.toggle('completed');
    console.log(`Toggled completed on task ID: ${taskItem.dataset.taskId}`);
  }
});

// ─── WHAT JUST HAPPENED? ──────────────────────────────────────────────
// We have ZERO listeners on individual <li> elements.
// No matter how many tasks are added, we always have exactly 2 listeners
// total: one on addButton, one on taskList.
// Tasks added 5 minutes from now are automatically handled.
Output
// After clicking 'Add Task' three times, then clicking ✕ on Task #2:
Deleting task with ID: 2
// After clicking the label of Task #1:
Toggled completed on task ID: 1
Pro Tip:
Always use event.target.closest('.your-selector') instead of event.target.matches('.your-selector') when your clickable element has any child elements inside it. If the user clicks an icon inside a button, event.target is the icon — matches() fails silently, closest() saves you.
Production Insight
Direct listeners on dynamic items create memory leaks that are hard to find.
In production, a React app with hundreds of to-do items leaked 3MB every time a user filtered the list.
Switching to delegation fixed it — one listener, no cleanup, no leaks.
Key Takeaway
Delegation is free memory safety for dynamic content.
One listener handles all current and future items with zero cleanup.

Real-World Pattern — Delegating on a Data Table with Multiple Actions

A data table with Edit and Delete buttons per row is where delegation really earns its keep. A table with 200 rows and two action buttons per row means 400 listeners the naive way. With delegation: exactly one.

The trick here is encoding context directly onto the element using data attributes. You put the row's ID, type, or any other relevant data directly on the button as data-* attributes. When the delegated listener fires, it reads those attributes off event.target to know exactly what to do — no DOM traversal needed to find associated data.

This pattern also makes your code dramatically easier to test. Your handler is a plain function that receives an element — you can unit-test it by passing a mock element with the right data attributes, no real DOM required. That's a huge win for maintainability as your app grows.

data-table-delegation.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// --- HTML structure assumed:
// <table>
//   <tbody id="user-table-body">
//     <tr data-user-id="101">
//       <td>Alice Johnson</td>
//       <td>
//         <button class="table-action" data-action="edit"   data-user-id="101">Edit</button>
//         <button class="table-action" data-action="delete" data-user-id="101">Delete</button>
//       </td>
//     </tr>
//     ... more rows
//   </tbody>
// </table>

const tableBody = document.querySelector('#user-table-body');

// ─── ACTION HANDLERS — pure functions, easy to test ───────────────────
function handleEditUser(userId) {
  console.log(`Opening edit modal for user ID: ${userId}`);
  // In a real app: open a modal, fetch user data, populate form, etc.
}

function handleDeleteUser(userId) {
  console.log(`Requesting delete confirmation for user ID: ${userId}`);
  // In a real app: show a confirmation dialog, then call your API
}

// ─── THE SINGLE DELEGATED LISTENER ────────────────────────────────────
tableBody.addEventListener('click', function(event) {
  // Find the action button, even if user clicked text inside it
  const actionButton = event.target.closest('.table-action');

  // Guard clause — if click wasn't on an action button, do nothing
  if (!actionButton) return;

  // Read context directly from data attributes — no DOM traversal needed
  const action = actionButton.dataset.action;  // 'edit' or 'delete'
  const userId = actionButton.dataset.userId;  // e.g. '101'

  // Route to the correct handler using a lookup object
  // This scales better than a long if/else chain as actions grow
  const actionHandlers = {
    edit:   () => handleEditUser(userId),
    delete: () => handleDeleteUser(userId),
  };

  const handler = actionHandlers[action];

  if (handler) {
    handler();
  } else {
    console.warn(`Unrecognised table action: "${action}"`);
  }
});

// ─── WHY THIS IS BETTER ───────────────────────────────────────────────
// 1. Adding 50 more rows to the table? Zero new listeners.
// 2. Adding a new action type (e.g. 'view')? Add one entry to actionHandlers.
// 3. Want to reload rows via AJAX? The listener still works — no re-binding.
Output
// Clicking Edit on row with userId 101:
Opening edit modal for user ID: 101
// Clicking Delete on row with userId 205:
Requesting delete confirmation for user ID: 205
// Clicking an empty area of the table body:
(no output — guard clause returns early)
Architecture Win:
Storing context in data-* attributes and routing via a handler lookup object is a micro-pattern worth memorising. It keeps your delegated listener under 20 lines regardless of how many action types you add, and it makes each handler independently testable.
Production Insight
data-* attributes are the backbone of scalable delegation patterns.
In a production CRM, adding a 'view' action to 500 rows took 1 line of code — no listener changes.
But be careful: data attributes only hold strings; use JSON.parse for complex data.
Key Takeaway
Encode action context directly in data-* attributes.
Your delegated listener becomes a pure routing function — testable, stable, and scalable.

When Delegation Breaks — Events That Don't Bubble and How to Handle Them

Delegation relies on bubbling, so you need to know which events don't bubble. The two most common are focus and blur — they fire when an input receives or loses focus, but they don't bubble. If you try to delegate focus by listening on a parent, nothing will happen.

The fix is their bubbling equivalents: focusin and focusout. These are part of the same spec, behave identically to focus/blur, and do bubble. Swap those in and delegation works perfectly.

The other culprit is stopPropagation(). If any code in your app calls event.stopPropagation() on a click inside your delegated area, the event stops bubbling and your parent listener never fires. This is a silent failure — no error, no warning, just nothing happens. Third-party libraries are common offenders. The safest fix is to avoid stopPropagation() entirely and use a flag on the event object instead (event.handled = true), but if you're using a library you don't control, check its documentation and move your listener higher in the DOM tree if possible.

focus-delegation-fix.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// ─── THE BROKEN APPROACH — focus does not bubble ──────────────────────
const brokenForm = document.querySelector('#search-form');

brokenForm.addEventListener('focus', function(event) {
  // This will NEVER fire when an input inside the form is focused
  // because focus doesn't bubble — it stops at the input element
  console.log('This line never runs:', event.target);
});

// ─── THE CORRECT APPROACH — focusin DOES bubble ───────────────────────
const workingForm = document.querySelector('#contact-form');

workingForm.addEventListener('focusin', function(event) {
  // Now this fires whenever ANY input inside the form gains focus
  const focusedInput = event.target;

  // Only act on actual input elements, ignore focus on the form itself
  if (focusedInput.matches('input, textarea, select')) {
    focusedInput.classList.add('field--active'); // highlight the focused field
    console.log(`Field focused: ${focusedInput.name}`);
  }
});

workingForm.addEventListener('focusout', function(event) {
  const blurredInput = event.target;

  if (blurredInput.matches('input, textarea, select')) {
    blurredInput.classList.remove('field--active');

    // Good place to trigger inline validation after the user leaves a field
    if (blurredInput.value.trim() === '') {
      blurredInput.classList.add('field--error');
      console.log(`Validation failed: ${blurredInput.name} is empty`);
    } else {
      blurredInput.classList.remove('field--error');
    }
  }
});

// ─── EVENTS THAT DO NOT BUBBLE — memorise these ───────────────────────
// focus    → use focusin  instead
// blur     → use focusout instead
// mouseenter → use mouseover instead (mouseover bubbles)
// mouseleave → use mouseout  instead (mouseout  bubbles)
Output
// Clicking into an input named 'email':
Field focused: email
// Clicking away without entering a value:
Validation failed: email is empty
// Clicking into 'phone' and typing a value, then leaving:
Field focused: phone
(no validation error logged — field has a value)
Watch Out:
event.stopPropagation() anywhere inside your delegated zone silently kills delegation. If your delegated listener mysteriously stops working, search the entire codebase and any third-party scripts for stopPropagation() calls on elements inside that zone.
Production Insight
stopPropagation() is the #1 cause of 'delegation just stopped working' tickets on production.
Third-party widgets like datepickers often call stopPropagation() to prevent their own events from leaking.
The fix: attach your delegated listener to a higher ancestor (e.g., document), and check the target only if you need to scope.
Key Takeaway
Know your non-bubbling events and always guard against stopPropagation().
The silent failure is the hardest to debug — add a console.log at the start of your listener to confirm it fires.

Performance Trade-offs and Alternatives to Delegation

Delegation isn't a silver bullet. It has its own costs. Every click inside the parent runs your selector check — even clicks on empty padding or unrelated elements. That's usually negligible, but if your parent is the entire document and you're checking complex selectors on every mousemove, you'll feel the perf hit.

Another trade-off: delegation makes your event handling logic more complex. If you have a tiny, static set of elements that never change, direct listeners are simpler and just as fast. The memory difference for 5 buttons is irrelevant. Only pull the delegation lever when the child count is dynamic or large (think 20+ elements).

Also, delegation doesn't work for events that fire on the same element without bubbling (e.g., beforeunload, visibilitychange). Those must be attached directly. And in some cases, using passive listeners (adding { passive: true } to addEventListener) can improve scroll performance — but that's orthogonal to delegation.

Bottom line: delegation is a tool, not a religion. Use it where it buys you maintainability and performance. Skip it where it adds unnecessary complexity.

delegation-vs-direct.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// ─── WHEN DIRECT LISTENERS ARE FINE ───────────────────────────────────
// Static navigation with 4 links — no dynamic changes
const navLinks = document.querySelectorAll('.nav-link');
navLinks.forEach(link => {
  link.addEventListener('click', handleNavClick);
});
// Simple, readable, no delegation overhead. 4 listeners vs 1 — difference is ~0.01ms.

// ─── WHEN DELEGATION IS BETTER ────────────────────────────────────────
// 500 dynamic rows updated every 30 seconds
const table = document.querySelector('#data-table');
table.addEventListener('click', function(e) {
  const row = e.target.closest('tr');
  if (!row) return;
  // ... handle row click
});
// One listener handles all rows, including future ones. Memory constant.

// ─── MEASURE THE OVERHEAD ─────────────────────────────────────────────
const parent = document.querySelector('#list');
const start = performance.now();
for (let i = 0; i < 1000; i++) {
  // Force a synthetic click to measure selector overhead
  const event = new MouseEvent('click', { bubbles: true });
  parent.dispatchEvent(event);
}
const end = performance.now();
console.log(`1000 delegated clicks took ${(end - start).toFixed(2)}ms`);
Output
// Typical output for the benchmark:
1000 delegated clicks took 3.12ms
// That's ~3 microseconds per click — negligible for most apps.
The Real Cost:
The selector check in a delegated listener takes about 0.003ms per click on modern browsers. For 1000 clicks per second on a single parent, that's 3ms — still fine. But if you're attaching delegation to the document root and checking complex selectors on every mousemove, you'll see jank. Keep your parents scoped to the nearest stable container.
Production Insight
Scoped delegation is faster than document-level delegation.
In a JS-heavy admin panel, attaching delegation to document. body caused 50ms+ of selector checks per user interaction.
Moving listeners to the nearest shared parent (e.g., #table-body instead of body) cut that to <1ms.
Key Takeaway
Delegation is a scale tool — not always better, but essential at scale.
Scope it to the nearest stable parent and measure the selector overhead by adding a performance mark.
Should You Use Delegation?
IfMore than ~20 child elements, or children added/removed dynamically
UseYes — use delegation
IfFew static child elements (1-10) that never change
UseNo — direct listeners are simpler and equally performant
IfHigh-frequency events (mousemove, scroll) on a deep parent
UseBe cautious — scope delegation tightly and consider passive listeners
● Production incidentPOST-MORTEMseverity: high

300-Leak Listeners Caused by Table Rows Re-Rendered via AJAX

Symptom
After switching between pages of a user table (each AJAX request replaced tbody contents), the page grew progressively slower. Chrome DevTools heap snapshots showed thousands of detached DOM nodes with click listeners attached.
Assumption
The team assumed that replacing innerHTML would automatically garbage-collect old nodes and listeners. They thought event delegation wasn't needed because the table was 'small' (50 rows).
Root cause
Each row had a direct click listener attached in a loop after AJAX completion. When the new rows replaced the old ones, the old DOM nodes were detached but the listeners kept references to them, preventing GC. After 10 pages, 500 listeners accumulated.
Fix
Replaced all per-row listeners with a single delegated listener on the table body. Also added a removeEventListener before innerHTML replacement as a safety net, though delegation made it unnecessary.
Key lesson
  • Replace direct listeners with delegation on any frequently re-rendered container.
  • Use Chrome Performance monitor to see listener count growing.
  • When debugging memory leaks, check Detached DOM Tree in heap snapshots.
Production debug guideStep-by-step symptom resolution for silent delegation failures4 entries
Symptom · 01
Delegated listener on parent never fires when clicking a child element
Fix
Check for event.stopPropagation() anywhere in the subtree — search your codebase and any third-party scripts. Also verify the event type bubbles (e.g., focus does not, use focusin).
Symptom · 02
Listener fires but handler doesn't run the right logic
Fix
Log event.target and event.currentTarget. Are they different? If event.target is the text inside a button but your code uses matches() instead of closest(), the selector won't match the button — switch to closest().
Symptom · 03
Listener fires multiple times for one click
Fix
Check if the listener was attached more than once. Look for repeated addEventListener in loops. Use a flag (e.g., data-listener-added) or throttle registration.
Symptom · 04
Delegated listener works locally but fails in production
Fix
Check for different DOM structure (e.g., parent element changed ID or class). Also verify event delegation host element is present at the time the script runs — if added dynamically after script execution, use MutationObserver or attach listener on document.
★ Quick Debug Cheat Sheet for Event DelegationThree common failure modes and the exact commands to diagnose each
Delegated click listener on a parent not firing on child clicks
Immediate action
Open DevTools Console and type: getEventListeners(parentElement). Click on child to check listener list on parent — if listener is absent, verify you attached it correctly.
Commands
monitorEvents(document.getElementById('parent-id'), 'click')
Inspect event object: click child, then check console for event properties — see if event.bubbles is true and event.target is the child.
Fix now
If stopPropagation() is the culprit, remove it or comment it out temporarily. If event doesn't bubble (focus/blur), switch to focusin/focusout.
event.target is null or unexpected+
Immediate action
Log event.target in the listener. If it's the parent itself (event.target === event.currentTarget), you're clicking the parent, not a child. If it's a text node or nested element, your selector logic is wrong.
Commands
document.querySelector('parent').addEventListener('click', e => console.log('target:', e.target, 'nodeName:', e.target.nodeName))
Check if the element has child elements: inspect in Elements panel – if so, use closest() not matches().
Fix now
Replace matches('.selector') with closest('.selector') and add a null guard.
Listener works, but handler executes for unintended child elements+
Immediate action
The guard clause is missing or too loose. You're probably not checking the specific selector. Add an early return: const btn = e.target.closest('.my-button'); if (!btn) return;
Commands
Add a conditional breakpoint in Sources panel: e.target.closest('.my-button') === null
Use Console to test: document.querySelector('parent').addEventListener('click', console.log) – then click various children to see which ones fire.
Fix now
Ensure the selector string is specific enough and includes the correct class or tag.
Event Delegation vs Direct Listeners
AspectDirect Listeners (Per Element)Event Delegation (Single Parent Listener)
Number of listenersOne per element — 200 rows = 200 listenersOne listener regardless of child count
Memory usageGrows linearly with element countConstant — unaffected by child count
Dynamic elementsMust manually attach listener on each new elementWorks automatically for elements added later
Listener cleanupMust removeEventListener on every element to avoid leaksRemove one listener on the parent — done
ComplexitySimple to write, complex to maintain at scaleSlightly more logic upfront, far simpler at scale
Works with non-bubbling eventsYes — direct listeners always fireNo — must use bubbling equivalents (focusin, mouseover)
stopPropagation() riskNot affectedSilently breaks if a child calls stopPropagation()
Best used whenSmall, static list of elements that won't changeDynamic lists, tables, any content added via API/AJAX

Key takeaways

1
Delegation works because of event bubbling
every click on a child also fires on every ancestor, all the way to window. Remove that mental model and delegation makes no sense.
2
Always use event.target.closest('.selector') instead of event.target.matches('.selector')
closest() handles nested child elements inside your clickable targets and prevents silent failures.
3
focus and blur don't bubble
use focusin and focusout when you need to delegate focus-based events. mouseenter/mouseleave also don't bubble; use mouseover/mouseout instead.
4
event.stopPropagation() anywhere inside a delegated zone silently kills the listener
it's the hardest delegation bug to find. Prefer guard clauses and early returns over stopping propagation.
5
Scope delegation to the nearest stable parent, not the document root, to minimise selector overhead and avoid performance surprises.

Common mistakes to avoid

5 patterns
×

Using event.target.matches() when the clickable element has children

Symptom
Clicking on a nested icon or span inside a button does nothing — the listener silently fails because event.target is the child, not the button. matches() returns false.
Fix
Replace matches() with event.target.closest('.your-selector'). closest() walks up the tree and finds the ancestor that matches, even when a child element was clicked.
×

Trying to delegate focus and blur events directly

Symptom
A focus listener attached to a parent form never fires when an input is focused. No error, just silence.
Fix
Use focusin instead of focus, and focusout instead of blur. These are drop-in replacements that bubble naturally.
×

Forgetting the guard clause for clicks outside target elements

Symptom
Clicking empty space, margins, or disabled elements inside the parent throws a TypeError: cannot read property of null.
Fix
Always start your delegated handler with: const target = event.target.closest('.selector'); if (!target) return; This early return prevents any further code from running on irrelevant clicks.
×

Assuming delegation works for non-bubbling events like mouseenter

Symptom
mouseenter delegation on a parent doesn't work when the mouse enters a child element. The event never bubbles.
Fix
Use mouseover instead of mouseenter for delegation. Similarly, use mouseout instead of mouseleave. These bubble and serve the same purpose.
×

Attaching delegated listener to an element that is removed and re-added

Symptom
After re-rendering a component (e.g., via AJAX), the delegated listener stops working because the parent element was replaced.
Fix
Attach the delegated listener to a stable ancestor that is never replaced, like a container div that persists through updates. Or re-attach after re-render.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
Can you explain event delegation and describe a specific scenario where ...
Q02SENIOR
If you set up a delegated click listener on a parent element but it neve...
Q03SENIOR
What's the difference between event.target and event.currentTarget in a ...
Q04SENIOR
How would you implement event delegation for a form with many input fiel...
Q01 of 04JUNIOR

Can you explain event delegation and describe a specific scenario where you'd choose it over attaching individual listeners? What makes it work at the browser level?

ANSWER
Event delegation is a technique where you attach a single event listener to a parent element to handle events from all its children, past and future. It works because most DOM events bubble up the tree. At the browser level, when a user triggers an event on a target element, the event propagates through the capture phase (window to target) and then bubbles (target back to window). Delegation uses the bubbling phase. A typical scenario: a dynamic list where items are added/removed — instead of tracking each item's lifecycle, you listen on the <ul> and use event.target.closest('li') to act. This reduces memory usage and eliminates memory leaks from abandoned listeners.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Does event delegation work for elements added to the DOM after the page loads?
02
Is event delegation always better than adding individual listeners?
03
Why does my delegated listener fire even when I click an empty part of the container?
04
Can I use arrow functions as event listeners in delegation? How does `this` behave?
05
What is the event capturing phase and can I use it for delegation?
🔥

That's DOM. Mark it forged?

5 min read · try the examples if you haven't

Previous
Event Handling in JavaScript
3 / 9 · DOM
Next
Fetch API and AJAX in JavaScript