Of course I have a backup!

Random blobs of wisdom about software development

Common JavaScript patterns: Event delegation

Monday, October 31, 2011

I see these types of questions a lot on forums:

I have made an ajax based favorites system on my webshop. Users can click on the "add" button next to each product, and it will be added to their favorites list. Everything works fine, except that on freshly favorited elements, the "remove" button does not work, until I refresh the page. What could cause this? Here is the code. I'm using jQuery.

Without even taking a look at the code, I already know what the problem is. The problem usually has nothing to do with jQuery, but given the popularity of it, but it usually gets associated to it. The question can come in various flavors, but the pattern is always the same:

There is a container element, that has elements added to it via DOM manipulation, and the newly added elements behave in unexpected ways, usually some functionality is not working, until a page refresh is made.

What is causing the problem?

Let's imagine for a moment that we are building a webshop, and looking at the products page. Each product has an "add" button next to it's name, that you can use to add the product to your list of favorites, and each item in your favorites has a "remove" button.

<ul id="favorites">
    <li>Alan Wake <a class="remove" href="#">(remove)</a></li>
    <li>Calvin and Hobbes <a class="remove" href="#">(remove)</a></li>
    <li>Dilbert 2.0 <a class="remove" href="#">(remove)</a></li>
</ul>

<ul id="products">
    <li>Code Complete <a class="add" href="#">(add)</a></li>
    <li>Game of Thrones <a class="add" href="#">(add)</a></li>
    <li>The gun seller <a class="add" href="#">(add)</a></li>
</ul>
$(function() {
    $('#favorites .remove').click(function(e) {
        $(this).parent('li').remove();
    });
    $('#products .add').click(function(e) {
        var favorite = $(this).parent('li').clone();
        favorite.children('a').removeClass('add').addClass('remove').html('(remove)');
        favorite.appendTo('#favorites');
    });
});

Now this all seems good, until you add something, then try to remove it, without reloading your browser. The reason this fails, is because you forgot how scripts on a webpage work. Each <script> tag gets parsed, and executed, as soon as it is encountered by the browser. But after you add new elements, they will not receive the click listener, even though they match the selector (#favorites .remove ), because they were not present when the script tag was executed.

The wrong solutions

Sometimes helpful people suggest that you should re-run the event listener attaching code, each time a new comment is added, leading to something like this:

function initFavoriteRemover() {
    $('#favorites .remove').click(function(e) {
        console.log('removing');
        $(this).parent('li').remove();
    });
}
$(function() {
    $('#products .add').click(function(e) {
        var favorite = $(this).parent('li').clone();
        favorite.children('a').removeClass('add').addClass('remove').html('(remove)');
        favorite.appendTo('#favorites');
        initFavoriteRemover();
    });
    initFavoriteRemover();
});

This seems to work at first, but has another flaw. Add a favorite, and then delete an old one (so not the one that you just added). Check the console tab in Firebug, you will see that there are 2 "removing" lines in the console. This is because each time you add a new favorite item, all the old ones will have the event listener attached to them again. So, when you click remove, the listeners gets called twice (or in general N times, where N is the number of the removed items). The browser does not magically detect that the listener is already on the element, and skip applying it.

Another recommendation to aid this, is usually to remove all the listeners, before applying it:

function initFavoriteRemover() {
    $('#favorites .remove').unbind();
    $('#favorites .remove').click(function(e) {
        console.log('removing');
        $(this).parent('li').remove();
    });
}
$(function() {
    $('#products .add').click(function(e) {
        var favorite = $(this).parent('li').clone();
        favorite.children('a').removeClass('add').addClass('remove').html('(remove)');
        favorite.appendTo('#favorites');
        initFavoriteRemover();
    });
    initFavoriteRemover();
});

This seems to be working, but guess what, this is also wrong. You remove all the click event listeners, which is definitely not okay. This will cause hard to track down bugs later, when you want to add more listeners.

The correct solution

You have to apply the listener to an element that is constantly present in the DOM (it is present on page load, and is never removed), and is an ancestor of all the elements that you wish to listen on, in other words, apply the listener to the container element, that the elements are added into. In the current example, this is element is <ul id="favorites"> .

$(function() {
    $('#favorites').click(function(e) {
        var target = $(e.target);
        if (target.hasClass('remove')) {
            target.parent('li').remove();
        }
    });
    $('#products .add').click(function(e) {
        var favorite = $(this).parent('li').clone();
        favorite.children('a').removeClass('add').addClass('remove').html('(remove)');
        favorite.appendTo('#favorites');
    });
});

Very important points:

  • We are listening on an element that is never clicked, yet this still works. This is due to event bubbling.
  • We are we using e.target, instead if $(this) . $(this) returns the element that the listener is attached to. In this case, it would be <ul id="favorites"> , e.target returns the element that triggered the event. This is <a href="#" class="remove">
  • Why are we checking the class of the element @ line 4? Because we are listening on the whole <ul id="favorites"> element, every child of it can trigger the listener, and we want to avoid this. We are making sure that only clicking on the <a class="remove"></a> tag removes the element, while the <li> tag does not.

Event bubbling, a very well documented feature of JavaScript, that even IE6 managed to get right. In a nutshell:

Each time an element is clicked, all of it's parent elements, will also receive the click event, because the event "bubbles" upwards from the clicked element, to all of it's parents.

What we are doing is, we are listening on a parent element, that will handle some (or all) of it's children's events. This technique is called event delegation. For problems like this, this is the most straight forward, and side effect less way of solving it. There is a lot more to it, do a search for event delegation, because there are so many tutorials already about it, and it's a key concept in JavaScript, that is crucial to understand.

Most frameworks (YUI3, jQuery, MooTools (through an extension) does, Prototype does not) already have a wrapper function, that you can use for event delegation, so the code can be simplified with another step:

$(function() {
    $('#favorites').delegate('.remove', 'click', function(e) {
        $(e.target).parent('li').remove();
    });
    $('#products .add').click(function(e) {
        var favorite = $(this).parent('li').clone();
        favorite.children('a').removeClass('add').addClass('remove').html('(remove)');
        favorite.appendTo('#favorites');
    });
});

The advantages are, that the code is much more expressive, it is very easy to understand what is going on here, especially in contrast of the solutions in the wrong solutions section. It is also lighter on resources, since you only attach one listeners, instead of N (one for every element).

So, the next time you encounter a problem that involves DOM manipulation, and non functional elements without a page reload, remember that event delegation is a very good solution.

Post a comment

Providing your email is optional, it is never published or shared, it is only used for auto approval purposes. If you already have at least 1 approved comment(s) tied to your email, you don't have to wait for moderation, otherwise the author must approve your comment.

Please solve this totally random captcha