30 December 2014

Detecting Dynamically Added Elements With jQuery

The other day I was creating a task manager UI for my 30 websites 30 days challenge. The website allows a user to create and delete tasks. This requires a create and delete button. There are two click listeners: one for creating a new task, another one for completing a task.


var createElement = function() {
  var text = $("#todo-create-input").val();
  var newElement = "<form class='todo-item'><p class='todo-text'>" + text +
    "</p><button type='submit' class='delete-button'>Completed</button></form>"
    $("main").append(newElement);
};

var displayCongratulations = function(e) {
  e.preventDefault();
  $('img').fadeTo( 'slow', '1');
  $('header').children().remove();
  $('header').append("<h1>Good job! You did something!</h1>");
  this.remove();
};

$(document).ready(function(){
  $("#todo-create-button").on("click", createElement);
  $(".delete-button").on("click", displayCongratulations);
});

The limitation of $(document).ready()

The above code does not work because we are attempting to bind an event listener to .delete-button before it has been created. The element which is being created is the same element we are trying to bind our click listener to in our ready() method. When developing dynamic web applications it is common that to add new elements to the DOM that need to be mapped to events. How do we reconcile creating new elements which will require event listeners, with the notion that all elements are binded to event only when the DOM loads? Usually our content is binded to an event listener right after the DOM loads, but if we are adding new elements after the binding method has run, then our new elements will not map to events correctly.

The solution: DOMNodeInserted event

We can bind an event to the body element of our HTML with the DOMNodeInserted event. With DOMNodeInserted, when a new element is inserted onto html element during runtime the provided callback is executed. That callback can be used for binding click listeners to events. The syntax for doing that would be as follows: $('body').on('DOMNodeInserted', callback).

The above code can be refactored to include DOMNodeInserted event:


var createElement = function() {
  var text = $("#todo-create-input").val();
  var newElement = "<form class='todo-item'><p class='todo-text'>" + text +
    "</p><button type='submit' class='delete-button'>Completed</button></form>"
    $("main").append(newElement);
};

var displayCongratulations = function(e) {
  e.preventDefault();
  $('img').fadeTo( 'slow', '1');
  $('header').children().remove();
  $('header').append("<h1>Good job! You did something!</h1>");
  this.remove();
};

var bindEventToNewElement = function(e) {
  var target = $(e.target).find('button');
  $(target).on("click", displayCongratulations);
}

$(document).ready(function(){
  $("#todo-create-button").on("click", createElement);
  $("body").on('DOMNodeInserted', bindEventToNewElement);
});

The newly created elements map to the correct events and everything will work. Merry Christmas!

Limitations of DOMNodeInserted event

New solutions generally create new problems. Problems are never really solved, because every different solution requires a different implementation, which leads to different limitations. We will always have problems. We can't solve problems permanantly, but we can have solutions which lead to higher quality problems.

Consider this: Right now only one kind of element is being added to the DOM: the delete button. What if we want to add multiple elements to the DOM, each with its own unique event listener? The above solution would not work because the same callback is being executed regardless of what we add to the DOM, or where on the DOM we add it.

In the above example, what if we attatched something new to the DOM which had nothing to do with our delete functionality? It would attempt to rebind all delete buttons to their associated events.

One solution would be to bind DOMNodeInserted to the container of wherever we are appending our dynamically generated content. Instead of listening on our body tag we could listen only on the container of wherever we are adding our new content. For example, with $("body").on('DOMNodeInserted', bindEventToNewElement); we are initiating bindEventToNewElement callback whenever something is added to the body element. We could make this more precise by instead binding the DOMNodeInserted event to the container that we are attatching the new element to. Doing so would look like:


var createElement = function() {
  var text = $("#todo-create-input").val();
  var newElement = "<form class='todo-item'><p class='todo-text'>" + text +
    "</p><button type='submit' class='delete-button'>Completed</button></form>"
    $("main").append(newElement);
};

var displayCongratulations = function(e) {
  e.preventDefault();
  $('img').fadeTo( 'slow', '1');
  $('header').children().remove();
  $('header').append("<h1>Good job! You did something!</h1>");
  this.remove();
};

var bindEventToNewElement = function(e) {
  var target = $(e.target).find('button');
  $(target).on("click", displayCongratulations);
}

$(document).ready(function(){
  $("#todo-create-button").on("click", createElement);
  $(".list-of-ToDos").on('DOMNodeInserted', bindEventToNewElement);
});

Now the delete buttons will only map to events when an element is added to .list-of-toDos, rather than mapping when any element is added to the body.