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.