How to Add Event Listeners to Dynamic Content with Event Delegation
Have you ever added a button to a page with JavaScript expecting it to function fully, but when you clicked it nothing happened? You've double checked your code - you're creating the event listeners, the selectors are correct, but it's still not working!
Chances are, if you've hit this situation, you're using a piece of code like this:
// Find all the .remove-button, then put a click event listener on each of them
document.querySelectorAll('.remove-button').forEach(elem => {
elem.addEventListener('click', function() {
this.closest('.todo-item').remove();
});
});
This looks correct. You've added an event listener to all .remove-button
elements.
The problems come when you're adding more .remove-button
elements after the page has loaded.
The challenge is that event listeners are added only once - if the elements don't exist when document.querySelectorAll
is called, they miss out.
One technique for handling this situation is to add and remove event listeners every time you add or remove content - but if your content is constantly changing, this can be a nightmare to manage.
As it turns out, there is a much easier way of adding event listeners to content that doesn't exist yet through event delegation.
Here is what happens if we change the code above to use event delegation:
// Instead of targeting an element, we target a static parent -- I've chosen
// body since it's always there, but in bigger applications it's better to
// target a more specific parent
document.querySelector('body').addEventListener('click', event => {
// Check if the clicked element was actually a .remove-button
if (event.target.matches('.remove-button')) {
event.target.closest('.todo-item').remove();
}
});
The concept here is simple: If the content we're trying to add listeners to doesn't exist yet, add a listener to something that does exist - specifically, a parent element that will capture events that have bubbled up.
Since this event listener is on a parent element, there's a chance that some of the clicks we receive aren't from the elements that we want to respond to. To defend against this, we can use event.target.matches
to check which element was clicked.
The only gotcha in this approach is that event.target
is the exact clicked element. This means that it's possible for children of our clickable elements to trigger the event instead, meaning we want to respond to it but it fails our .matches
check.
If you're using a simple clickable element like a button
, don't worry about it, but for more complicated elements you can use use event.target.closest
to check if a child element has been clicked.
document.querySelector('body').addEventListener('click', event => {
// This version checks the current element for a match, as well as it's parents.
// If none is found, it returns null
if (event.target.matches('.remove-button') || event.target.closest('.remove-button')) {
event.target.closest('.todo-item').remove();
}
}
If you find yourself creating these kinds of functions often, you might also consider using a utility function to create them.
function createDelegatedEventListener(selector, handler => {
return (event) => {
if (event.target.matches(selector) || event.target.closest(selector)) {
handler(event);
}
}
})
document.querySelector('body').addEventListener('click', createDelegatedEventListener('.remove-button', event => {
event.target.closest('.todo-item').remove();
}));