Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP

Asynchronous Dependent Observables

Steven Sanderson edited this page · 18 revisions

Computed (or dependent) observables have to return a value synchronously. But what if you want to use a computed observable to represent some data that you fetch using an asynchronous Ajax request?

For example, you might have a set of observables to represent query parameters, e.g.,

this.pageIndex = ko.observable(0);
this.sortColumn = ko.observable("lastName");
this.sortOrder = ko.observable("asc");

Conceptually, it makes sense to have a computed observable to represent the result of performing a query using these parameters. After all, the result of the query is a function of the query parameters.

Manually capturing the asynchronous result

The traditional way to capture an asynchronous result is to set up a further observable - let's call it queryResults - and then use a computed observable to detect changes on any of the query parameters and populate queryResults asynchronously as a side-effect. For example,

this.queryResults = ko.observable();
ko.computed(function() {
    // Whenever "pageIndex", "sortColumn", or "sortDirection" change, this function will re-run and issue
    // an Ajax request. When the Ajax request completes, assign the resulting value to "queryResults"
    $.ajax("someUrl", {
        data: { pageNum: this.pageIndex, sortBy: this.sortColumn, sortDirection: this.sortOrder },
        success: this.queryResults
    });
}, this);

The reason why the $.ajax call is wrapped inside a ko.computed is to ensure the request will be issued not just once when this code first runs, but also re-issued each time any query parameter changes. Automatic dependency tracking will detect the dependencies on pageIndex, sortColumn, and sortDirection and force re-evaluation when they change.

This technique works fine, but if you're doing it a lot, wouldn't it be nicer to eliminate the separate queryResults observable and just emit the output directly from your computed observable somehow? Yes, and here's how...

Dependent Observables that return deferred results

A common mechanism for handling asynchronous operations is to have some object that represents the operation in progress:

What Task<T>, Deferrable, and $.Deferred all have in common is that they represent the present-or-future availability of some result value. They all give you a way to add a callback so you'll be notified when the result becomes available (or you'll be called back immediately if the result is already available).

So, what if your computed observable was to return a $.Deferred object to represent an Ajax request that it issued? That technique would almost completely handle our requirements here.

The one thing that technique wouldn't handle, though, is letting you easily use regular bindings to display the result, because regular bindings don't understand $.Deferred values. To solve this, you can create a standard wrapper around a computed observable that captures the output for any $.Deferred value and transfers it onto some other, normal observable, so you can apply bindings to that other observable in the normal way.

Here's a simple implementation:

function asyncComputed(evaluator, owner) {
    var result = ko.observable();

    ko.computed(function() {
        // Get the $.Deferred value, and then set up a callback so that when it's done,
        // the output is transferred onto our "result" observable
        evaluator.call(owner).done(result);
    });

    return result;
}

You can then use this, asyncComputed, in place of a regular computed observable, and its result will appear asynchronously after any of its dependencies change. For example,

this.queryResults = asyncComputed(function() {
    // Whenever "pageIndex", "sortColumn", or "sortDirection" change, this function will re-run
    return $.ajax("someUrl", {
        data: { pageNum: this.pageIndex, sortBy: this.sortColumn, sortDirection: this.sortOrder }
    });
}, this);

You can then bind queryResults to your DOM elements in the usual way.

A more sophisticated implementation

What you've just seen may be perfectly sufficient in many cases, but you might want to add more functionality. For example,

  • Gracefully handling result values that are either asynchronous (e.g., $.Deferred), synchronous (regular JavaScript objects), or just null
  • Coping with out-of-order responses - ensuring that your computed observable's value only ever represents the most-recently-requested data, even if the Ajax requests complete in a different order
  • Exposing an inProgress sub-property so you can display a "loading" indicator in your UI

Here's a more sophisticated implementation that handles all of these things:

function asyncComputed(evaluator, owner) {
    var result = ko.observable(), currentDeferred;
    result.inProgress = ko.observable(false); // Track whether we're waiting for a result

    ko.computed(function() {
        // Abort any in-flight evaluation to ensure we only notify with the latest value
        if (currentDeferred) { currentDeferred.reject(); }

        var evaluatorResult = evaluator.call(owner);
        // Cope with both asynchronous and synchronous values
        if (evaluatorResult && (typeof evaluatorResult.done == "function")) { // Async
            result.inProgress(true);
            currentDeferred = $.Deferred().done(function(data) {
                result.inProgress(false);
                result(data);
            });
            evaluatorResult.done(currentDeferred.resolve);
        } else // Sync
            result(evaluatorResult);
    });

    return result;
}

Using an extender

With Knockout 1.3, it became possible to express this kind of facility as an "extender", so you could turn any computed observable into a $.Deferred-aware one like this:

this.someData = ko.computed(function() { /* ... */ }, this).extend({ async: true });

A version using an extender is published here: http://smellegantcode.wordpress.com/2012/12/10/asynchronous-computed-observables-in-knockout-js/

Something went wrong with that request. Please try again.