Paging Lists with Knockout

As covered by this StackOverflow question, Knockout can get a little bit slow when trying to render large amounts of data.

This has been particularly noticeable in a recent project that has to run on older iPads, so I took the time to put together a simple paging solution. You can see the source code (and tests) here or you can see a running example here.

To make use of this object, create an instance of Utils.PagedObservableArray as below:

var ViewModel = function(data) {
  this.pagedList = new Utils.PagedObservableArray({
    data: data,
    pageSize: 3
  });
};

You can then bind to the exposed properties using some simple HTML:

<div data-bind="with: pagedList">
  <div>
    <a href="#" data-bind="click: previousPage">Previous Page</a>
    <span
      data-bind="text: 'Page ' + (pageIndex() + 1) + ' of ' + pageCount()"
    ></span>
    <a href="#" data-bind="click: nextPage">Next Page</a>
  </div>
  <ul data-bind="foreach: page">
    <li data-bind="text: $data"></li>
  </ul>
</div>

The new instance wraps the existing observableArray object from the Knockout library and exposes a number of properties to support paging:

  • allData - An observableArray instance exposing the entire data set. This should be used to populate and to access the source data
  • pageSize - An observable instance containing the number of items per page
  • pageIndex - An observable instance containing the zero-based current page index
  • pageCount - A computed observable that returns the number of pages of data available
  • page - An observableArray instance that contains the current page of data
  • previousPage - A function that moves to the previous page, if possible
  • nextPage - A function that moves to the next page, if possible

The final implementation is:

(function(Utils, ko) {
  Utils.PagedObservableArray = function(options) {
    options = options || {};
    if ($.isArray(options)) options = { data: options };
    var //the complete data collection
      _allData = ko.observableArray(options.data || []),
      //the size of the pages to display
      _pageSize = ko.observable(options.pageSize || 10),
      //the index of the current page
      _pageIndex = ko.observable(0),
      //the current page data
      _page = ko.computed(function() {
        var pageSize = _pageSize(),
          pageIndex = _pageIndex(),
          startIndex = pageSize * pageIndex,
          endIndex = pageSize * (pageIndex + 1);

        return _allData().slice(startIndex, endIndex);
      }, this),
      //the number of pages
      _pageCount = ko.computed(function() {
        return Math.ceil(_allData().length / _pageSize()) || 1;
      }),
      //move to the next page
      _nextPage = function() {
        if (_pageIndex() < _pageCount() - 1) _pageIndex(_pageIndex() + 1);
      },
      //move to the previous page
      _previousPage = function() {
        if (_pageIndex() > 0) _pageIndex(_pageIndex() - 1);
      };

    //reset page index when page size changes
    _pageSize.subscribe(function() {
      _pageIndex(0);
    });
    _allData.subscribe(function() {
      _pageIndex(0);
    });

    //public members
    this.allData = _allData;
    this.pageSize = _pageSize;
    this.pageIndex = _pageIndex;
    this.page = _page;
    this.pageCount = _pageCount;
    this.nextPage = _nextPage;
    this.previousPage = _previousPage;
  };
})(Utils, ko);

The implementation itself is not doing anything particularly clever but there are a couple of areas to note:

  • By using jQuery.isArray we can determine whether the parameter passed in is an array or just another object. If it is an array we just want to use it as the source data to save callers from wrapping their data in an options object.
  • Using Array.slice on the return value of allData in the page computed observable means that we can use the exposed allData collection as you would any other observableArray and the computed observable gets update notifications for free.
  • We are using the subscribe method directly on both pageSize and allData to reset the current page index to zero whenever either value changes
  • We use Math.ceil to get a whole number of pages for pageCount, with a special case to return a page count of 1 where there are no items in the source data