Event delegation leverages event bubbling to handle events efficiently. Here's how to use it.
Event Bubbling Basics#
1// Events bubble up the DOM tree
2document.querySelector('.child').addEventListener('click', () => {
3 console.log('Child clicked');
4});
5
6document.querySelector('.parent').addEventListener('click', () => {
7 console.log('Parent clicked');
8});
9
10document.querySelector('.grandparent').addEventListener('click', () => {
11 console.log('Grandparent clicked');
12});
13
14// Clicking .child logs:
15// "Child clicked"
16// "Parent clicked"
17// "Grandparent clicked"
18
19// Stop propagation
20element.addEventListener('click', (e) => {
21 e.stopPropagation(); // Prevents bubbling
22});
23
24// Stop immediate propagation
25element.addEventListener('click', (e) => {
26 e.stopImmediatePropagation(); // Stops other handlers on same element too
27});Basic Event Delegation#
1// Instead of attaching to each item
2const items = document.querySelectorAll('.item');
3items.forEach(item => {
4 item.addEventListener('click', handleClick);
5});
6
7// Delegate to parent
8document.querySelector('.list').addEventListener('click', (e) => {
9 if (e.target.matches('.item')) {
10 handleClick(e);
11 }
12});
13
14// With closest() for nested elements
15document.querySelector('.list').addEventListener('click', (e) => {
16 const item = e.target.closest('.item');
17 if (item) {
18 handleClick(item);
19 }
20});Dynamic Elements#
1// Problem: New elements don't have listeners
2function addItem() {
3 const item = document.createElement('li');
4 item.className = 'item';
5 item.textContent = 'New Item';
6 // item.addEventListener('click', handleClick); // Would need this
7 list.appendChild(item);
8}
9
10// Solution: Event delegation handles dynamic elements
11document.querySelector('.list').addEventListener('click', (e) => {
12 const item = e.target.closest('.item');
13 if (item) {
14 // Works for existing AND dynamically added items
15 console.log('Item clicked:', item.textContent);
16 }
17});
18
19// Now this just works
20addItem(); // New items automatically have click handlingMultiple Actions#
1// Handle different actions on same container
2document.querySelector('.card').addEventListener('click', (e) => {
3 const target = e.target;
4
5 if (target.matches('.edit-btn')) {
6 handleEdit(target.closest('.card'));
7 } else if (target.matches('.delete-btn')) {
8 handleDelete(target.closest('.card'));
9 } else if (target.matches('.share-btn')) {
10 handleShare(target.closest('.card'));
11 }
12});
13
14// Using data attributes
15document.querySelector('.toolbar').addEventListener('click', (e) => {
16 const button = e.target.closest('[data-action]');
17 if (!button) return;
18
19 const action = button.dataset.action;
20
21 switch (action) {
22 case 'save':
23 save();
24 break;
25 case 'delete':
26 remove();
27 break;
28 case 'copy':
29 copy();
30 break;
31 }
32});
33
34// HTML:
35// <div class="toolbar">
36// <button data-action="save">Save</button>
37// <button data-action="delete">Delete</button>
38// <button data-action="copy">Copy</button>
39// </div>Table Handling#
1// Efficient table event handling
2const table = document.querySelector('.data-table');
3
4table.addEventListener('click', (e) => {
5 const cell = e.target.closest('td');
6 const row = e.target.closest('tr');
7
8 if (!cell || !row) return;
9
10 // Handle cell click
11 const columnIndex = cell.cellIndex;
12 const rowIndex = row.rowIndex;
13
14 console.log(`Cell clicked: row ${rowIndex}, column ${columnIndex}`);
15
16 // Handle specific buttons in cells
17 if (e.target.matches('.edit-btn')) {
18 editRow(row);
19 } else if (e.target.matches('.delete-btn')) {
20 deleteRow(row);
21 }
22});
23
24// Sortable columns
25table.querySelector('thead').addEventListener('click', (e) => {
26 const th = e.target.closest('th');
27 if (!th) return;
28
29 const column = th.dataset.column;
30 sortTable(column);
31});Form Handling#
1// Form input delegation
2const form = document.querySelector('form');
3
4form.addEventListener('input', (e) => {
5 const field = e.target;
6 validateField(field);
7});
8
9form.addEventListener('focus', (e) => {
10 const field = e.target;
11 showHelp(field);
12}, true); // Capture phase for focus
13
14form.addEventListener('blur', (e) => {
15 const field = e.target;
16 hideHelp(field);
17}, true); // Capture phase for blur
18
19// Change event delegation
20form.addEventListener('change', (e) => {
21 const field = e.target;
22
23 if (field.matches('select')) {
24 handleSelectChange(field);
25 } else if (field.matches('input[type="checkbox"]')) {
26 handleCheckboxChange(field);
27 } else if (field.matches('input[type="radio"]')) {
28 handleRadioChange(field);
29 }
30});Keyboard Events#
1// Keyboard navigation in list
2const list = document.querySelector('.navigable-list');
3
4list.addEventListener('keydown', (e) => {
5 const item = e.target.closest('.item');
6 if (!item) return;
7
8 switch (e.key) {
9 case 'Enter':
10 case ' ':
11 e.preventDefault();
12 selectItem(item);
13 break;
14 case 'ArrowDown':
15 e.preventDefault();
16 focusNext(item);
17 break;
18 case 'ArrowUp':
19 e.preventDefault();
20 focusPrevious(item);
21 break;
22 case 'Delete':
23 deleteItem(item);
24 break;
25 }
26});
27
28function focusNext(current) {
29 const next = current.nextElementSibling;
30 if (next) next.focus();
31}
32
33function focusPrevious(current) {
34 const prev = current.previousElementSibling;
35 if (prev) prev.focus();
36}Drag and Drop#
1// Delegated drag and drop
2const container = document.querySelector('.draggable-container');
3
4container.addEventListener('dragstart', (e) => {
5 const item = e.target.closest('.draggable');
6 if (!item) return;
7
8 item.classList.add('dragging');
9 e.dataTransfer.setData('text/plain', item.id);
10});
11
12container.addEventListener('dragend', (e) => {
13 const item = e.target.closest('.draggable');
14 if (item) {
15 item.classList.remove('dragging');
16 }
17});
18
19container.addEventListener('dragover', (e) => {
20 e.preventDefault();
21 const dropZone = e.target.closest('.drop-zone');
22 if (dropZone) {
23 dropZone.classList.add('drag-over');
24 }
25});
26
27container.addEventListener('dragleave', (e) => {
28 const dropZone = e.target.closest('.drop-zone');
29 if (dropZone) {
30 dropZone.classList.remove('drag-over');
31 }
32});
33
34container.addEventListener('drop', (e) => {
35 e.preventDefault();
36 const dropZone = e.target.closest('.drop-zone');
37 if (!dropZone) return;
38
39 const id = e.dataTransfer.getData('text/plain');
40 const item = document.getElementById(id);
41
42 dropZone.appendChild(item);
43 dropZone.classList.remove('drag-over');
44});Event Delegation Utilities#
1// Reusable delegation helper
2function delegate(parent, selector, event, handler) {
3 parent.addEventListener(event, (e) => {
4 const target = e.target.closest(selector);
5 if (target && parent.contains(target)) {
6 handler.call(target, e, target);
7 }
8 });
9}
10
11// Usage
12delegate(document.body, '.btn', 'click', function(e, element) {
13 console.log('Button clicked:', element);
14});
15
16// With event types map
17function delegateEvents(parent, eventsMap) {
18 for (const [eventType, handlers] of Object.entries(eventsMap)) {
19 for (const [selector, handler] of Object.entries(handlers)) {
20 delegate(parent, selector, eventType, handler);
21 }
22 }
23}
24
25// Usage
26delegateEvents(document.body, {
27 click: {
28 '.btn-save': handleSave,
29 '.btn-delete': handleDelete,
30 '.link': handleLinkClick,
31 },
32 change: {
33 'select': handleSelectChange,
34 'input[type="checkbox"]': handleCheckbox,
35 },
36});Performance Benefits#
1// Without delegation: N event listeners
2const items = document.querySelectorAll('.item'); // 1000 items
3items.forEach(item => {
4 item.addEventListener('click', handleClick); // 1000 listeners
5});
6
7// With delegation: 1 event listener
8document.querySelector('.list').addEventListener('click', (e) => {
9 if (e.target.closest('.item')) {
10 handleClick(e);
11 }
12}); // 1 listener
13
14// Memory comparison
15// Without: ~1000 function references
16// With: 1 function reference
17
18// Initialization time
19console.time('without delegation');
20items.forEach(item => item.addEventListener('click', () => {}));
21console.timeEnd('without delegation');
22
23console.time('with delegation');
24list.addEventListener('click', () => {});
25console.timeEnd('with delegation');Events That Don't Bubble#
1// focus, blur, mouseenter, mouseleave don't bubble
2// Use capture phase instead
3
4// Focus delegation with capture
5document.addEventListener('focus', (e) => {
6 if (e.target.matches('input')) {
7 e.target.parentElement.classList.add('focused');
8 }
9}, true); // capture: true
10
11document.addEventListener('blur', (e) => {
12 if (e.target.matches('input')) {
13 e.target.parentElement.classList.remove('focused');
14 }
15}, true);
16
17// Or use focusin/focusout which do bubble
18document.addEventListener('focusin', (e) => {
19 if (e.target.matches('input')) {
20 e.target.parentElement.classList.add('focused');
21 }
22});
23
24document.addEventListener('focusout', (e) => {
25 if (e.target.matches('input')) {
26 e.target.parentElement.classList.remove('focused');
27 }
28});Best Practices#
When to Use:
✓ Many similar elements
✓ Dynamic content
✓ Performance sensitive lists
✓ Shared behavior patterns
Implementation:
✓ Use closest() for nested elements
✓ Check element existence
✓ Use data attributes for actions
✓ Consider capture phase for non-bubbling events
Performance:
✓ Attach to closest common ancestor
✓ Keep selectors simple
✓ Avoid deep nesting checks
✓ Use event.target.matches() efficiently
Avoid:
✗ Delegating to document for everything
✗ Complex selector chains
✗ Forgetting stopPropagation implications
✗ Over-delegating simple cases
Conclusion#
Event delegation improves performance and handles dynamic elements automatically. Use closest() for reliable element matching, data attributes for action identification, and remember to use capture phase for events that don't bubble. Delegate to the closest common ancestor, not always to document.