MTJ Hax

Action and reaction: event-driven AJAX updates with jQuery

Posted in code, jquery by mtjhax on September 7, 2010

Newton's CradleAs I increasingly use AJAX to let users perform actions in a page without navigating away, I find myself constantly having to solve the problem of updating page content that depends on these actions. For example, a user logs in with a popup bubble, then parts of the page that depend on login state must be updated. This is a relatively trivial example and there are many approaches to handling this sort of update.

A straightforward method that is commonly used is to avoid AJAX in these cases–submit the login form and redirect back to refresh the entire page. While using AJAX sparingly is a worthwhile goal, avoiding it entirely doesn’t always result in the most engaging user experience. Another approach is to hard-code everything that needs to be updated after an action is complete:

// submit my login form with jQuery .ajax()
$.ajax({ url: login_url, data: login_data, complete: function() {
  // update page contents that depend on login state
  $('#mainmenu').load('mainmenu');
  $('#login_form').fadeOut('slow', function() {$(this).remove()});
  $('.welcome_text').show();
});

Fine for one or two actions, but this turns into a spaghetti mess pretty quickly in more complicated situations. A better solution is to have some way of registering update callbacks, e.g.:

$('#mainmenu').updateOnEvents('login', function() {
  $(this).load('mainmenu');
});

$('#login_form').updateOnEvents('login', function() {
  $(this).fadeOut('slow', function() {$(this).remove()});
});

$('.welcome_text').updateOnEvents('login', function(){
  $(this).show();
});

$.ajax({ url: login_url, data: login_data, complete: function() {
  // globally trigger the event 'login' somehow
});

So the question becomes, how do you globally trigger the event so any element at any level can register a handler? After considering a number of designs involving .live() or .delegate() (so the handler setups would affect both existing elements and new elements added later) I realized that good old .bind() would do the trick.

Solutions involving .live() and .delegate() are tricky because when you use something like $('.foo').live() or $(document).delegate('.foo'), you need to use $('.foo').trigger() for the event to be handled. Calling something higher-level like $(document).trigger() does not invoke the handlers. The trick is to roll your simple .live()-style function similar to the following:

$.fn.updateOnEvents = function(events, callback) {
  $(document).bind(
    events,
    { selector: $(this).selector, context: $(this).context },
    function(event, trigger_data) {
      $(event.data.selector, event.data.context).each(function(){
        if (typeof callback == 'function') {
          extra_data = callback(event, trigger_data);
        }
      });
    }
  );
};

// example usage
$(document).ready(function() {
  $('.welcome_text').updateOnEvents('login logout', function(event, data){
    $(this).text(data.message).show();
  });

  $(document).trigger('login', { message: "Welcome back!" });
  $(document).trigger('logout', { message: "See you next time." });
});

The trick is that when we call $('.foo').updateOnEvent(), the selector string ‘.foo’ is saved (and the context, if specified) and are passed to the handler function in event.data. The handler uses the selector and invokes the callback function for each matching element with .each(), so the value of $(this) in your callback is the element itself instead of $(document). Since the selector is evaluated at the time of the callback, any recently-added elements that match are included in the update.

The parameter trigger_data is whatever extra data you pass with the .trigger() call and your callback can take advantage of that data.

Commonly-used patterns can be expressed as a shortcut in the code. For example, in this variant if the callback parameter is a string instead of a function, the code treats the string as an AJAX URL and assumes you want to update the selected elements with .load():

$.fn.updateOnEvents = function(events, callback) {
  $(document).bind(
    events,
    { selector: $(this).selector, context: $(this).context },
    function(event, trigger_data) {
      if (typeof callback == 'function') {
        $(event.data.selector, event.data.context).each(function(){
          extra_data = callback(event, trigger_data);
        });
      } else if (typeof callback == 'string') {
        $(event.data.selector, event.data.context).load(callback, trigger_data);
        });
      }
    }
  );
};

// slightly absurd example of using AJAX to retrieve a welcome message
$(document).ready(function() {
  $('.welcome_text').updateOnEvents('login logout', '/ajax/welcome_msg');
  $(document).trigger('login', { type: "login" });
  $(document).trigger('logout', { type: "logout" });
});

In yet another variant where I always want to update the element with AJAX via .load(), instead of passing a callback function to perform the updates, I passed a callback function that returns the parameters for the .load() call — in this way, the .trigger() function doesn’t need to know anything about the parameters needed by the update callbacks:

$.fn.updateOnEvents = function(events, ajax_url, params_callback) {
  $(document).bind(
    events,
    { selector: $(this).selector, context: $(this).context, url: ajax_url },
    function(event) {
      var data = extra_data;
      $(event.data.selector, event.data.context).each(function(){
        // get AJAX request parameters from callback function
        if (typeof params_callback == 'function') {
          data = params_callback(event);
        }
        // convert params to string so .load() always uses GET method
        if (typeof data == 'object') {
          var new_params = "";
          for (var key in data) {
            if (new_params.length > 0) new_params += "&";
            new_params += key + "=" + data[key];
          }
          data = new_params;
        }
        // update the element with AJAX
        $(this).load(e.data.ajax_url, data);
      });
    }
  );
};

// another slightly ludicrous usage example
$(document).ready(function() {
  $('.welcome_text').updateOnEvents('login logout', '/ajax/welcome_msg', function(event){
    if (event.type == 'login')
      return { type: "login" };
    else
      return { type: "logout" };
  });
});

I have to profess that I am not a jQuery god (yet). I am certain that there are improvements that could be made to this function in terms of performance and simplicity, maybe a potential error or two, or a completely better way to approach the problem. I welcome your suggestions, comments, and criticism!

Tagged with: , , , , ,