Back to Blog
JavaScriptEventsDOMPerformance

JavaScript Event Delegation

Master JavaScript event delegation. From bubbling to dynamic elements to performance optimization.

B
Bootspring Team
Engineering
June 14, 2020
6 min read

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 handling

Multiple 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.

Share this article

Help spread the word about Bootspring