Academic Block

Event Delegation Get Certified

Instead of attaching a separate listener to every child element, you can attach one listener to a common parent and let events bubble up to it. This technique is called event delegation.

Delegation is more efficient, cleaner, and automatically works for elements that are added to the page later.

Why Use Event Delegation?

Attaching individual listeners to dozens or hundreds of similar elements wastes memory and makes the code harder to maintain. A single delegated listener on the parent solves both problems.

Basic Event Delegation

What this code does: One listener on the parent handles clicks for every child. The actual clicked element is available as event.target.

function makeElement(tag) {
    const listeners = {};
    return {
        tag: tag,
        textContent: "",
        parentElement: null,
        addEventListener(type, handler) {
            if (!listeners[type]) listeners[type] = [];
            listeners[type].push(handler);
        },
        dispatch(type, event) {
            if (listeners[type]) {
                listeners[type].forEach(handler => handler(event));
            }
            if (!event.propagationStopped && this.parentElement) {
                this.parentElement.dispatch(type, event);
            }
        }
    };
}

const menuList = makeElement("ul");
const itemA = makeElement("li");
itemA.textContent = "Home";
itemA.parentElement = menuList;
const itemB = makeElement("li");
itemB.textContent = "About";
itemB.parentElement = menuList;

menuList.addEventListener("click", function (event) {
    console.log("Clicked:", event.target.textContent);
});

itemA.dispatch("click", { target: itemA, propagationStopped: false });
itemB.dispatch("click", { target: itemB, propagationStopped: false });

Matching Only Specific Children

A delegated listener often needs to check whether event.target is actually the kind of element it cares about, using something like closest(), since the click might have landed on a nested element inside it.

What this code does: Demonstrates matching a specific child even when the real click lands on a nested element (the icon). closest(".buy-button") walks up the tree to find the intended button.

function makeElement(tag, className) {
    const listeners = {};
    return {
        tag: tag,
        className: className || "",
        parentElement: null,
        addEventListener(type, handler) {
            if (!listeners[type]) listeners[type] = [];
            listeners[type].push(handler);
        },
        dispatch(type, event) {
            if (listeners[type]) {
                listeners[type].forEach(handler => handler(event));
            }
            if (!event.propagationStopped && this.parentElement) {
                this.parentElement.dispatch(type, event);
            }
        },
        closest(selector) {
            let current = this;
            while (current) {
                if ("." + current.className === selector) return current;
                current = current.parentElement;
            }
            return null;
        }
    };
}

const productList = makeElement("div");
const buyButton = makeElement("button", "buy-button");
buyButton.parentElement = productList;
const buttonIcon = makeElement("span", "icon");
buttonIcon.parentElement = buyButton;

productList.addEventListener("click", function (event) {
    const clickedButton = event.target.closest(".buy-button");
    if (clickedButton) {
        console.log("Buy button activated!");
    } else {
        console.log("Click ignored — not a buy button.");
    }
});

buttonIcon.dispatch("click", { target: buttonIcon, propagationStopped: false });

Even though the click technically landed on the small icon inside the button, closest(".buy-button") correctly walks up to find the actual button that should handle it.

Stopping Propagation

Calling event.stopPropagation() prevents the event from continuing to bubble up past the current element, which can be useful when a nested element needs to handle a click without triggering a listener on its container.

What this code does: Shows how stopPropagation() prevents the event from continuing to bubble. Clicking the close button logs its own message and never triggers the modal’s background listener.

function makeElement(tag) {
    const listeners = {};
    return {
        tag: tag,
        parentElement: null,
        addEventListener(type, handler) {
            if (!listeners[type]) listeners[type] = [];
            listeners[type].push(handler);
        },
        dispatch(type, event) {
            if (listeners[type]) {
                listeners[type].forEach(handler => handler(event));
            }
            if (!event.propagationStopped && this.parentElement) {
                this.parentElement.dispatch(type, event);
            }
        }
    };
}

function makeEvent(target) {
    return {
        target: target,
        propagationStopped: false,
        stopPropagation() { this.propagationStopped = true; }
    };
}

const modal = makeElement("div");
const closeButton = makeElement("button");
closeButton.parentElement = modal;

modal.addEventListener("click", () => console.log("Modal background clicked — closing modal."));
closeButton.addEventListener("click", function (event) {
    console.log("Close button clicked.");
    event.stopPropagation();
});

closeButton.dispatch("click", makeEvent(closeButton));

Because stopPropagation() was called, the modal’s own click listener never runs — the event stopped bubbling right after the close button handled it.

Handling Elements Added Later

One of the biggest advantages of delegation is that it automatically works for elements added to the page after the listener was set up — no extra code required.

What this code does: Proves that a delegated listener still works for elements created after the listener was attached. The new comment is handled correctly without any extra setup.

function makeElement(tag) {
    const listeners = {};
    return {
        tag: tag,
        textContent: "",
        parentElement: null,
        addEventListener(type, handler) {
            if (!listeners[type]) listeners[type] = [];
            listeners[type].push(handler);
        },
        dispatch(type, event) {
            if (listeners[type]) {
                listeners[type].forEach(handler => handler(event));
            }
            if (!event.propagationStopped && this.parentElement) {
                this.parentElement.dispatch(type, event);
            }
        }
    };
}

const commentSection = makeElement("div");
commentSection.addEventListener("click", function (event) {
    console.log("Clicked comment:", event.target.textContent);
});

// This comment element is created *after* the listener was set up.
const newComment = makeElement("p");
newComment.textContent = "Great article!";
newComment.parentElement = commentSection;

newComment.dispatch("click", { target: newComment, propagationStopped: false });

The listener still catches the click, even though newComment didn’t exist yet when addEventListener() was called on the container.

Practical Example

Let’s build a delegated “delete” handler for a to-do list, where each item has its own small delete button, but only one listener is attached to the whole list.

What this code does: A practical “delete” handler for a to-do list. One listener on the list uses closest() twice—once to detect the delete button, once to locate its parent item.

function makeElement(tag, className) {
    const listeners = {};
    return {
        tag: tag,
        className: className || "",
        textContent: "",
        parentElement: null,
        addEventListener(type, handler) {
            if (!listeners[type]) listeners[type] = [];
            listeners[type].push(handler);
        },
        dispatch(type, event) {
            if (listeners[type]) {
                listeners[type].forEach(handler => handler(event));
            }
            if (!event.propagationStopped && this.parentElement) {
                this.parentElement.dispatch(type, event);
            }
        },
        closest(selector) {
            let current = this;
            while (current) {
                if ("." + current.className === selector) return current;
                current = current.parentElement;
            }
            return null;
        }
    };
}

const todoList = makeElement("ul");
const taskItem = makeElement("li", "todo-item");
taskItem.textContent = "Water the garden";
taskItem.parentElement = todoList;
const deleteButton = makeElement("button", "delete-button");
deleteButton.parentElement = taskItem;

todoList.addEventListener("click", function (event) {
    const clickedDelete = event.target.closest(".delete-button");
    if (clickedDelete) {
        const item = clickedDelete.closest(".todo-item");
        console.log("Deleting task:", item.textContent);
    }
});

deleteButton.dispatch("click", { target: deleteButton, propagationStopped: false });

When Delegation Isn’t the Right Fit

Not every event bubbles — for example, focus and blur don’t bubble by default, so delegation doesn’t work for them the same way (though related bubbling versions, like focusin and focusout, do exist). Delegation is also less useful for a single, unique element that will never be duplicated or recreated.

Common Mistakes With Event Delegation

  • Assuming event.target is always the element you attached logic for, instead of checking with closest() or a similar match.
  • Forgetting that some events, like focus and blur, don’t bubble in the same way as click.
  • Overusing stopPropagation(), which can accidentally prevent other unrelated listeners further up the tree from ever running.
  • Attaching a delegated listener to an element far higher up the tree than necessary, making the matching logic more complex than it needs to be.
  • Re-attaching individual listeners to newly created elements out of habit, when a single delegated listener already handles them.

Best Practices for Event Delegation

  • Attach the delegated listener to the closest reasonable shared ancestor, not necessarily the very top of the page.
  • Use closest() to reliably match the intended element, even if the actual click landed on something nested inside it.
  • Reach for delegation especially for lists, tables, and other groups of similar, repeatable elements.
  • Use stopPropagation() sparingly and intentionally, only when you specifically need to prevent further bubbling.
  • Remember that delegation automatically covers elements added later — no need to reattach listeners after updating a list.

Quick Summary

  • Events bubble upward from the target element through its ancestors, unless stopped.
  • Event delegation attaches one listener to a shared parent instead of many listeners on individual children.
  • event.target identifies the actual element that triggered the event; closest() helps match the right ancestor.
  • event.stopPropagation() prevents an event from bubbling further.
  • Delegation automatically works for elements added to the page after the listener was attached.
  • Not all events bubble, so delegation doesn’t apply universally to every event type.

Try It Yourself

Modify the code below. Try adding a second item to the list, or changing which element is clicked, to see the delegated listener respond to each one.

What this code does: A gallery container listens for clicks on any of its photo elements. Change which photo is dispatched (or add another) and watch the same listener respond to each one.

function makeElement(tag) {
    const listeners = {};
    return {
        tag: tag,
        textContent: "",
        parentElement: null,
        addEventListener(type, handler) {
            if (!listeners[type]) listeners[type] = [];
            listeners[type].push(handler);
        },
        dispatch(type, event) {
            if (listeners[type]) {
                listeners[type].forEach(handler => handler(event));
            }
            if (!event.propagationStopped && this.parentElement) {
                this.parentElement.dispatch(type, event);
            }
        }
    };
}

const gallery = makeElement("div");
const photoOne = makeElement("img");
photoOne.textContent = "sunset.jpg";
photoOne.parentElement = gallery;
const photoTwo = makeElement("img");
photoTwo.textContent = "mountains.jpg";
photoTwo.parentElement = gallery;

gallery.addEventListener("click", function (event) {
    console.log("Opening photo:", event.target.textContent);
});

photoTwo.dispatch("click", { target: photoTwo, propagationStopped: false });
Tip: A good rule of thumb: if you’re about to write a loop that attaches the exact same listener to many similar elements, pause — a single delegated listener on their shared parent is very likely the cleaner, more maintainable option.

Understanding event delegation is essential before moving on to forms, custom events, and more advanced browser-based JavaScript concepts.

🧪 Test Your JavaScript Code

Edit the code on the left and click “Run Code” to see the console output on the right.

📝 JavaScript Code
👁 Console Output

Click “Run Code” to see the output here.

Ctrl + Enter to run code Esc to close