Friday, February 18, 2011

Using jQuery deferreds to cache ajax data

In this example I will demonstrate how to use jQuery deferreds to cache data from the server. This will provide the means to prevent ajax calls for data that has already been acquired. We will be making use of the jQuery.when() method. When we query the cache, our results may come from the server (first request) or from the cache (subsequent requests) and our query code will be the same for each.

Lets start with a simple example in which we have a page containing a drop down list with various categories:

<select id="categories">
<option value="1">Pikmin</option>
<option value="2">Reptiles</option>
<option value="3">Urchins</option>
</select>

When the user selects a category, a second drop down list will be populated with the values associated with the selected category. So, for instance, if the user selects "Pikmin" from the categories drop down list, we want to populate the "values" drop down list like this:

<select id="values">
<option value="1">Red</option>
<option value="2">Yellow</option>
<option value="3">Blue</option>
<option value="4">Purple</option>
<option value="5">White</option>
</select>

The set of Pikmin values may be considerably larger, so we decide to load the Pikmin values only when the user actually selects Pikmin from the category list. So for now in our html we'll start with an empty drop down list:

<select id="values"></select>

Now lets put together the jQuery code to initiate an ajax request for the values when the user selects a category:
$(function () {
    $("#categories").change(function () { LoadValues($("#categories").val()); })[0].selectedIndex = -1;
});

Here we tell jQuery to call the LoadValues() function whenever the categories drop down list is changed. We also set the selectedIndex property of the categories drop down list to -1 to indicate that nothing has been selected yet.

Lets take a look at the LoadValues() function now:
//Loads values for the selected category and populates the "values" select box with the results
function LoadValues(categoryId) {
    $.ajax({
        type: "POST",
        url: "Data.asmx/GetValues",
        data: JSON.stringify({ categoryId: categoryId }),
        contentType: "application/json",
        dataType: "json",
        success: function (msg) {
            MakeOptions(msg.d);
        }
    });
}

This ajax call sends the selected category id to the server to get the values back for a given category. Upon success, we pass the data to the MakeOptions() function which creates the option tags and places them in the values drop down list:
function MakeOptions(data) {
    //data looks like : [{ id: 1, value: 'Red' }, { id: 2, value: 'Yellow' }, { id: 3, value: 'Blue'} ...]
    var i, options = "";
    for (i = 0; i < data.length; i++) {
        options += "<option value='" + data[i].id + "'>" + data[i].value + "</option>";
    }
    $("#values").html(options);
}

Now, whenever the user selects a category, the values drop down list will be populated with the correct values for the selected category. The only problem here is that we may ask the server for the same data multiple times. For instance, if the user selects Pikmin, then selects Reptiles and then selects Pikmin again, this solution will ask the server for the Pikmin values again because we're not saving them anywhere. Let's change the LoadValues() function so that it stores data on the client once it's been fetched:
var CACHE = {};
function LoadValues(categoryId) {
    $.ajax({
        type: "POST",
        url: "Data.asmx/GetValues",
        data: JSON.stringify({ categoryId: categoryId }),
        contentType: "application/json",
        dataType: "json",
        success: function (msg) {
            CACHE[categoryId] = msg.d;
            MakeOptions(msg.d);
        }
    });
}

Now we've saved the data on the client. There are various ways we can determine whether the data we need is in the cache or not, but this is where we'll bring deferreds into play to see how useful they can be. We'll need to modify LoadValues() and our categories change handler. Lets start with LoadValues():
function LoadValues(categoryId) {
    var result = CACHE[categoryId];
    if (!result) {
        CACHE[categoryId] = $.ajax({
            type: "POST",
            url: "Data.asmx/GetValues",
            data: JSON.stringify({ categoryId: categoryId }),
            contentType: "application/json",
            dataType: "jsond",
            converters: {
                "json jsond"function (msg) {
                    return msg.hasOwnProperty('d') ? msg.d : msg;
                }
            },
            success: function (msg) {
                CACHE[categoryId] = msg;
            }
        });
        console.log(CACHE[categoryId]);
        return CACHE[categoryId];
    }
    return result;
}

Now we're looking in our cache for the data we want before we ask the server for it. If it's found in the cache, it gets returned. If it isn't found, we ask the server for the required data. Notice that I removed MakeOptions() from the success handler. That's because we now expect LoadData() to return the data (whether from cache or from the server), so we'll call MakeOptions() on the result of LoadData(). But wait! We're putting the jqXHR result of the ajax call into the cache where the data belongs. Furthermore, if we end up asking for the data from the server in the ajax call, the return value is the jqXHR. We can't pass that to MakeOptions(), can we? No, we can't but let's take a look at the modified change handler to get the full picture of what's going on:
$(function () {
    $("#categories").change(function () {
        $.when(
            LoadValues($("#categories").val())
        ).then(function (values) {
            MakeOptions(values)
        });
    })[0].selectedIndex = -1;
});

Here we can see deferreds in action. In $.when we make a call to LoadValues(). $.when waits for all its ajax methods to complete and then calls .then(). So let's walk through this. On our first call to LoadData() for Pikmin, LoadData sees that there is nothing in the cache. It then sends the ajax request and puts the resulting jqXHR into the cache. Now $.when sits around waiting for the ajax call to complete. Once it does, .then() is called with the data returned from the server as a parameter. In the ajax success handler, we store the data in the cache so that on any subsequent call to LoadData(1) the data is found and returned immediately without going to the server for it.
So why do we store the jqXHR object returned by our call to $.ajax()? The way deferreds work is that we can queue up additional callbacks on the deferred and they will all execute in order once the deferred finishes. So let's say that there's another place in our page which requires the Pikmin values. When we click a button, we want to display an unordered list of Pikmin values. If the server method to fetch the Pikmin values is long running, our user may click on this button while waiting for the values drop down list to be populated. If it's taking a while to load our Pikmin values, we certainly don't want to do it twice! So we use a deferred again for the button click:
function MakeUl(data) {
    var i, options = "";
    for (i = 0; i < data.length; i++) {
        options += "<li>" + data[i].value + "</li>";
    }
    $("#Pikmin").html(options);
}

$(function () {
    $("#categories").change(function () {
        $.when(
            LoadValues($("#categories").val())
        ).then(function (values) {
            MakeOptions(values);
        });
    })[0].selectedIndex = -1;

    //use another deferred for the button click:
    $("#loadPikmin").click(function () {
        $.when(
            LoadValues("1")
        ).then(function (values) {
            MakeUl(values);
        });
    });
});

So what does LoadData() do when the user clicks the button? It checks the cache and since we've already started an ajax call to the server, it finds and returns the jqXHR object from our previous ajax call. This causes the $.when from the button click to be enqueued behind the ajax call so that when the ajax call returns, the data can be handled by both MakeOptions() and MakeUl() without the need to talk to the server a second time.

Live demo