Server-Side Paged Lists in Knockout

After my last post on client-side paging using Knockout I had a few people comment that it would be useful if something reusable could handle server-side paging. It’s not quite what I needed but it sounded useful so I thought I’d see what I could come up with…

Strategising the Server

Server-side paging poses a slightly different problem to paging on the client-side as it requires (obviously) some cooperation from the server. Given that the server might be implemented in any of a hundred different ways I decided to use a strategy pattern to actually load the data:

var ViewModel = function() {
  this.pagedList = ko.pagedList({
    loadPage: function(pageIndex, pageSize) {
      //load data here
    }
  });
};

The requirements of the strategy are:

  1. Accept a pageIndex and pageSize to be loaded
  2. Return a jQuery.Deferred instance (such as that returned by $.ajax)
  3. Resolve the deferred object with a value that contains an array rows property Assuming that the server response needs no modification (i.e. already has an array rows property) then a paged list can be created as below:
var ViewModel = function() {
  this.pagedList = ko.pagedList({
    loadPage: function(pageIndex, pageSize) {
      return $.getJson('/getPage?index=' + pageIndex + '&size=' + pageSize);
    }
  });
};

Getting the Total Number of Rows

When writing this I was keen to avoid enforcing anything on the server implementation that wasn’t absolutely necessary, and one of the things I decided wasn’t needed was the total number of available rows. As the client side component cannot always know how many total rows are available, I decided that by default I would simply set the totalRows property to -1 and the pagedList will function perfectly without ever knowing how many rows are actually available - with the following limitations:

  • pageCount and totalRows will always return -1
  • nextPage will never prevent the user from moving to the next page, even if that page will be empty That being said, I also wanted to support those scenarios where the total number of rows is available, so totalRows can be set in one of two ways:

Manually Set totalRows

As with any Knockout observable property the totalRows value can be set externally, and the pagedList will use whatever value is set to calculate pageCount and prevent moving beyond the final page using nextPage.

var vm = new ViewModel();
vm.pagedList.totalRows(100); //specify 100 rows total

Include totalRows in Server Response

The minimum requirement of the data returned from the page loading strategy is that it requires an array rows property, but if it also includes an integer totalRows value then this will automatically set the observable value on the pagedList.

this.pagedList = ko.pagedList({
  loadPage: function(pageIndex, pageSize) {
    var defer = $.Deferred();

    //this is an example of the expected response object from the server
    defer.resolve({
      rows: [1, 2, 3],
      totalRows: 100 //specify the total number of rows in the response
    });

    return defer;
  }
});

In this way we can either specify the total number of rows on the client side, on the server side, or not at all - and still use the pagedList in the same way.

Pre-Loading the First page

When the page containing the pagedList is being generated on the server it is preferable that the first page of data already be loaded, rather than waiting on an unnecessary post back to the server to populate the list.

With that scenario in mind, I added the ability to specify the first page of data as another option on the constructor.

this.pagedList = ko.pagedList({
  loadPage: function(pageIndex, pageSize) {
    //...
  },
  firstPage: {
    rows: [1, 2, 3],
    totalRows: 100
  }
});

If this option is specified then the pagedList will not attempt to load the first page of data and will instead render the data specified in the option.

Note: this is also another mechanism by which the totalRows property can be pre-populated on the client side.

Mapping Data to ViewModels

In most cases the server will be returning something more complicated than the numeric values in the examples above, and in those cases it is common to want to wrap the returned object in a view model of it’s own.

To support this functionality I included a map constructor parameter that will be invoked on each of the rows returned from the page load strategy.

this.pagedList = ko.pagedList({
  //...
  map: function(item) {
    return ko.mapping.fromJS(item);
  }
});

Note: this example makes use of the Knockout mapping plugin to generate a view model.

Bindable Properties

Much of the behaviour is similar to the pagedObservableArray as it was described in my original post, with the most significant difference being that the returned object can be used directly - there is no need to bind to a page property as there was before. It also exposes the following properties:

  • pageSize - An observable instance containing the number of items per page
  • pageIndex - An observable instance containing the zero-based current page index
  • totalRows - An observable instance containing the total number of rows that are assumed to be available (or -1 if this has not been determined)
  • pageCount - A computed observable that returns the number of pages of data available (or -1 if totalRows is unknown)
  • previousPage - A command that moves to the previous page, if possible
  • nextPage - A command that moves to the next page, if possible
  • loadPage - A command that loads the page data at the index specified as the first parameter. The loadPage.isRunning observable should be used for all loading notifications One point to note is that this implementation does not expose an observableArray containing the data; instead it exposes a regular observable with a value that is an array. This may seem a little strange but I felt that the additional functions and properties on the observable array do not really make sense when talking about a collection of server-specified data.

Source Code & Samples

The source code and unit tests are available on GitHub, and a working sample is available on jsFiddle.

If anyone else has any other suggestions or requests just let me know in the comments!