JavaScript event delegation explained with practical examples

Event delegation is one of the most important performance patterns in JavaScript, yet many developers still attach individual event listeners to every button, link, or list item on the page. The problem with that approach becomes clear when you have a dynamic list — items added after the initial page load have no listeners attached, requiring you to re-run the binding logic after every DOM update. Event delegation solves both issues: instead of attaching a listener to each child element, you attach a single listener to a stable parent element and use the event.target property to check which child was actually clicked. This works because DOM events “bubble” up through the element tree — a click on a button propagates up through its parent, grandparent, and all the way to document. The pattern is standard in jQuery as $(parent).on(‘click’, ‘.child-selector’, handler), and equally easy in vanilla JavaScript with element.closest() for selector matching. Delegation is especially useful in WordPress for AJAX-loaded content, cart widgets, comment sections, and any list that can grow dynamically. Combining this with the debounce pattern for input events gives you a complete toolkit for performant DOM event handling. The example below covers both vanilla JS and jQuery patterns for click and change events on dynamic lists.

Problem: Event listeners stop working on dynamically added elements (AJAX content, infinite scroll) because they were only attached to elements that existed at page load.

Solution: Use event delegation — attach the listener to a stable parent element instead:

// Vanilla JS: delegate click to a dynamic list
const list = document.querySelector('#product-list');

list.addEventListener('click', function(event) {
    // Check if the clicked element (or an ancestor) matches our target
    const btn = event.target.closest('.add-to-cart-btn');
    if (!btn) return; // click was elsewhere inside the list

    const productId = btn.dataset.productId;
    console.log('Add to cart:', productId);
    // handle add-to-cart logic...
});

// Works for elements added AFTER page load
const newItem = document.createElement('li');
newItem.innerHTML = '<button class="add-to-cart-btn" data-product-id="999">Add</button>';
list.appendChild(newItem); // the delegated listener catches clicks on this too

// jQuery equivalent
jQuery(document).on('click', '.add-to-cart-btn', function() {
    const productId = jQuery(this).data('product-id');
    console.log('jQuery — Add to cart:', productId);
});

// Delegate a change event on a select inside a form
document.querySelector('#order-form').addEventListener('change', function(e) {
    if (e.target.matches('select[name="shipping_method"]')) {
        console.log('Shipping method changed to:', e.target.value);
        // update shipping cost display...
    }
});

// Stop event bubbling when needed
document.querySelector('.modal').addEventListener('click', function(e) {
    e.stopPropagation(); // prevent click from reaching the backdrop close handler
});

NOTE: Avoid delegating to document for every event — use the closest stable ancestor that is guaranteed to exist. Delegating everything to document means every click anywhere on the page runs your selector check, which is wasteful on complex DOMs. Also, not all events bubble — focus, blur, and scroll do not, so you cannot delegate them this way. Use the capture phase (addEventListener('focus', handler, true)) or the focusin / focusout equivalents (which do bubble) instead.