It surprises me how many developers are unaware of – or ignorant of – event delegation within JavaScript and jQuery events.
Sometimes when developing a site or application we need to attach an event handler to multiple elements, and those elements may or may not be in the DOM when it’s initially loaded (AJAX loaded content for example). Imagine we have a shop type page, showing a list of products which can be filtered down using AJAX calls. When the user selects a filter, we generate the new product list to be shown in JavaScript and then append it to the relevant parent DOM element. Now imagine we have a mouseenter
event attached to these product elements, which could do anything from cycling through product images, to displaying an overlay with pricing options:
jQuery(function ($) {
$('.product-list .product').on('mouseenter', function () {
cycleImages($(this));
});
function cycleImages($product) {
// code to cycle images here
}
});
All looks good on the initial page load, we hover the cursor over a product element and the mouseenter
handler we attached gets called. The problem comes about after the user has selected a filter and our new product elements are inserted into the page. Because we attached the mouseenter
handler when the DOM was ready, it didn’t get attached to the new product elements that were just created – hovering over the newly inserted products does nothing.
The Wrong Way
Some developers try to work around this by re-attaching the listeners after the new product elements are inserted:
jQuery(function ($) {
function attachHandlers() {
$('.product-list .product')
.off('mouseenter')
.on('mouseenter', function () {
cycleImages($(this));
});
}
// Attach handlers when the DOM is ready
attachHandlers();
function filterProducts() {
$.getJSON('/', $('.filter-form').serialize(), function (json) {
// Code to insert new DOM elements goes here then we call the attachHandlers function again
attachHandlers();
});
}
});
This is bad. We’re repeatedly detaching/attaching the same event listener every time the user selects a filter. Also by using .off()
we’re removing every single mouseenter
listener from the matched elements, whether it was added by us or a third party library (this can be solved by using event namespaces, but it’s still the wrong way to do it)
Better, But Still Wrong
How about if we add a class to elements which already have the event listener attached, then we can use jQuery’s .not()
function or :not
selector to filter down elements to those which do not have the listener attached:
function attachHandlers() {
$('.product-list .product:not(.attached)')
.addClass('attached')
.on('mouseenter', function () {
cycleImages($(this));
});
}
This is a bit better than what we had above, we’re only adding the handler once per element but we’ve added extra complexity by using the :not
selector and the use of an arbitrary class. We also need to consider the amount of product elements there are likely to be on the page. What if the user wants to view all products on a single page rather than use pagination? You now have potentially hundreds of unique copies of the same function body, all consuming memory and being monitored by the browser individually.
Event Delegation – The Right Way
When an event is fired on an element in JavaScript it ‘bubbles’ all the way up the DOM tree. This means when we hover over one of our product elements, the mouseenter
event is fired for the product element itself, the .product-list
parent element, its parent and so on, all the way up to the body
element and finally the document
element. The target
property is passed in to each descendent element’s event and references the original element from which the event started.
We can attach the handler to a single element that’s always in the DOM by passing in a selector string as the second argument to jQuery’s .on()
method:
jQuery(function ($) {
$('.product-list').on('mouseenter', '.product', function () {
// 'this' references the hovered .product element
cycleImages($(this));
});
});
Now whenever a product element is hovered our handler will be called because we’ve attached it to the .product-list
parent element. This has allowed us to get rid of the function to re-assign the handler to new elements, get rid of the arbitrary .attached
class and .not
filter, and best of all we’ve attached the handler to one element rather than potentially hundreds so our overhead and memory usage is lower.
Our code is now cleaner, leaner and faster than before.