Handling JavaScript events on multiple elements

Are you looping over a set of elements to apply the same event handler to each one? In this article I am are going to discuss event delegation, i.e. how a single event handler in the right place can be more effective than many.

A common need

Here is a simple table with nonsense data. Try to select some rows for processing. Thing to note here: you can click on whole rows, not just the checkboxes.

DateNameSurnamePriceIP Address
21/01/2006NeilCrosby$1.96192.168.1.1
01/02/2006BeccaCourtley$23.95192.167.2.1
17/11/2004DavidFreidman-Jones$14.00192.168.2.1
17/10/2004AnnabelTyler$104.00192.168.2.17
17/11/2005CarlConway$17.00192.168.02.13

So you’ve played with it and saw it’s pretty much basic. But how to implement this? Many people will think oh, I’ll just go ahead and attach a click handler to each row. That is a complex solution and generally should be avoided. The main flaw of this solution is that new, dynamically added rows (such as after Ajax requests) won’t have the same handlers.

Here is the complete jQuery code for the above example:

// observe all clicks to table rows
$(document).delegate('#mytable tbody tr', 'click', function(e) {
  var row = $(this)
  // find the first input element in the row; that's our checkbox
  var checkbox = row.find('input:first')
  // toggle the checkbox unless the click event originated on it
  if (!$(e.target).is(':input')) checkbox.prop('checked', !checkbox.is(':checked'))
  // toggle the classname of the row
  row.toggleClass('selected')
})

// catch the submit on the form
$(document).delegate('form:has(table)', 'submit', function(e) {
  var values = [], data = $(this).serializeArray()
  $.each(data, function(){ values.push(this.value) })

  if (values.length) alert('Rows to submit: ' + values.join(', '))
  else alert('Nothing selected. Please select some rows')

  // prevent the real submit action taking place in the browser
  e.preventDefault()
})

$(function() {
  // add the "selected" class if some inputs are already selected
  $('#mytable tbody tr input').each(function() {
    var input = $(this)
    if (input.val()) input.parent('tr').addClass('selected')
  })
})

In the above script, the delegate method was used to capture ‘click’ and ‘submit’ events originating from specific groups of elements. This is possible because most events bubble up the DOM tree, eventually reaching the document object. Attaching handlers to the document object also has the benefit that it’s available at any time, even before the page DOM is ready.

Let’s observe another real-world usage and reuse the same principle of unobtrusive click handling.

Analytics example

If you are using Google Analytics on your site, you might have wondered how to track PDF or other file downloads, or even outbound (off-site) clicks. Analytics help suggests that you use the onclick attribute to invoke custom tracking functions:

<!-- file downloads: -->
<a href="report.pdf" onclick="trackFile(...); return false">awesome report, has pie charts</a>
<!-- outgoing clicks: -->
<a href="http://another-site.com" onclick="trackOutboundLink(...); return false">visit my sponsor!</a>

This works but is pretty tedious and brittle (what happens if the custom functions are not defined?).

We’re smarter than that. With event delegation we can make an unobtrusive, one-time solution that doesn’t even require a JavaScript library like Prototype.js or jQuery:

// outbound links and file downloads Analytics tracking
if ('addEventListener' in document) (function(){
  var root = 'http://' + location.host + '/'

  function isLink(elem) {
    return elem.nodeType == Node.ELEMENT_NODE &&
      elem.nodeName.toUpperCase() == 'A' &&
      typeof elem.getAttribute('href') == 'string'
  }

  function findLink(elem, limit) {
    if (limit > 0 && elem) {
      return isLink(elem) ? elem : findLink(elem.parentNode, limit - 1)
    }
  }

  document.addEventListener('click', function(e) {
    if (!window._gaq || e.which == 3) return // ignore right click
      var trackData, link = findLink(e.target, 3)
    if (link) {
      if (link.href.replace(/^https:/i, 'http:').indexOf(root) !== 0) {
        // track outbound links
        var domain = link.href.split('/', 3)[2]
        trackData = ['_trackEvent', 'Outbound links', domain]
      }
      else if (/.(\w{2,5})$/.test(link.href) && RegExp.$1.toLowerCase() != 'html') {
        // track file downloads
        var path = '/' + link.href.replace(root, '')
        trackData = ['_trackPageview', path]
      }

      if (trackData) {
        _gaq.push(trackData)
        if (e.which == 1 && !(e.ctrlKey || e.metaKey || e.shiftKey)) {
          // on regular left click, force a small delay before following the link
          setTimeout(function(){ document.location = link.href }, 100)
          e.preventDefault()
        }
      }
    }
  })
})()

We observe mouse clicks on document level and then test if they originated from link elements; then we apply some simple rules to determine whether we are going to track the click or not. Outbound links are recognized by leading to another domain, while file downloads are detected by the file extension.

Related reading