Ajax pagination with jQuery and the History API

Posted on

On many websites that have paged content such as search results or product listings, when you click a pagination link, the new content will be loaded in via Ajax and replace the current content, sparing the user another full page load. Nice as it is, I’ve never been that much of a fan, because having gone to the effort of making such a feature work, you typically don’t end up with any net benefit to the user.

I’ll explain that: Because paged content like search results often make up the majority of the page anyway, you don’t save that much time (although any time saved is always good) by only reloading that content. The real justification, then, is that in avoiding a full page load you are making the user experience a bit less jarring. Except you’re not, because it breaks the back button. If a user clicks through from page 3 of the results, then hits the back button, they are returned to page 1. You could argue that this is worse.

Happily, it can be made better. Thanks to libraries like jQuery, implementing Ajax pagination is almost laughably easy, and thanks to the HTML5 History API, you can preserve the functionality of the back button, thus making the whole practice actually worthwhile.

A solid foundation

Before you touch any JavaScript, stop for a moment and make sure your pagination works anyway, without JavaScript enabled. What you need to have is normal hyperlinks, probably pointing to the current page but with a query string like ?p=2. You may well have a “View All” link as well with a href something like ?p=all. Your publishing system will be generating these links dynamically, but make sure they are done right. In terms of markup, go for an ordered list and give it a sensible class name that can be used as a hook in your stylesheet as well as in our script.

<ol class="pager">
 <li class="here">1</li>
 <li><a href="page.html?p=2">2</a></li>
 <li><a href="page.html?p=3">3</a></li>
 <li><a href="page.html?p=all">View All</a></li>
</ol>

Zooming out a little, let’s see all the markup we’re going to be dealing with:

<div id="outer">
 <div id="inner">
  <ol class="pager">…</ol>
  <ul class="product-list">…</ul>
  <ol class="pager">…</ol>
 </div>
</div>

This is quite a common pattern — the paged content (in this case an unordered list) with the set of pagination links above and below it. As you might expect, that group of elements is wrapped in a block element (in this case a div with an id of inner). As you might not expect, that is in turn wrapped in another div, this time with an id of outer. The reason why will become clear when we get to writing the jQuery.

If we wanted to, we could stop at this point and we would have a working solution (see Example 1), using just plain old links and no JavaScript. Now that foundation is in place, we’ll sprinkle on some Hijax.

Making it work with jQuery

Let’s assume you’re using the jQuery library and that you’re already doing some other stuff with it on your page. Add a call to your $(document).ready function, so the function we’re about to write will be executed once the page has loaded successfully.

$(document).ready(function() {
 if ($('.pager').length > 0) {
  doPager();
 }
});

At this point I’m going to introduce you to the load() function, which has been in jQuery right from version 1.0. It’s a very simple way to get some HTML from the server and insert it somewhere in the document. What I particularly like about it is that as well as a URL to get, you can provide it with a selector to extract a certain part of that document. Here it is in use in our script:

function doPager() {
 $('.pager a').click(function(e) {
  e.preventDefault();
  loadContent($(this).attr('href'));
 });
}

function loadContent(url) {
 $('#outer').empty().addClass('loading').load(url + ' #inner', function() {
  $('#outer').removeClass();
  doPager();
 });
}

Let’s break down what this function is doing. For each of our paging links, it’s adding a click handler which will prevent the default behaviour, then pass the link’s href to another function called loadProducts, which will:

  1. Empty the #outer element
  2. Assign the #outer element a class of loading so we can show a spinner or something while we wait for the new content.
  3. Use the jQuery load() function to fetch the document at the given URL, grab the #inner element and its contents from that document, and drop them into our empty #outer element.

What’s great about this is that we’ve not duplicated any work on the server side — we’re getting everything we need from the page that already exists (or is generated by the server). There’s also a callback function for when the load() completes successfully, which will:

  1. Remove the loading class from the #outer element.
  2. Re-run the doPager function. Remember, we emptied the #outer element before we loaded the new content, so the paging links, along with their click handlers, were removed from the DOM. We now have a new set of paging links, so we need to re-apply the click handlers.

After writing not very much code at all, we now have some working Ajax paging (see Example 2), but when we use it we get the broken back button problem. Enter the History API.

Making it work properly with the History API

Although there are many new semantic and media elements in HTML5 (or, I should say, HTML) there is also a lot of stuff aimed at web applications. The History API is one such thing. Clearly, it was designed to be able to help large, Ajax-heavy web apps, but it does a great job with our simple paging scenario as well.

The two important parts of the History API are the history.pushState function and the popstate event. You can use pushState to update the history and address bar in order to simulate moving to a new page, and if the user subsequently clicks the back button it will fire a popstate so you can simulate moving back again. If that sounded a bit woolly, try reading Mark’s Pilgrim’s very well-written explanation instead.

Here’s our code once the History API has been worked in.

$(document).ready(function() {
 if (window.history && history.pushState) {
  historyedited = false;
  $(window).bind('popstate', function(e) {
   if (historyedited) {
    loadContent(location.pathname + location.search);
   }
  });
  doPager();
 }
});

function doPager() {
 $('.pager a').click(function(e) {
  e.preventDefault();
  loadContent($(this).attr('href'));
  history.pushState(null, null, $(this).attr('href'));
  historyedited = true;
 });
}

function loadContent(url) {
 $('#outer').empty().addClass('loading').load(url + ' #inner', function() {
  $('#outer').removeClass();
  doPager();
 });
}

Well, you can see that the code has grown a little. Notice that our click handler now includes the history.pushState function. The first two parameters aren’t needed so we’ve left them as nulls. The third is where we slot in the URL from the link’s href. So now, when the link is clicked, the Ajax magic happens as before but also the history and address bar are updated, so it appears to the user as though they have moved to a new page. The browser, however, knows that we have simulated the move. If the user clicks the back button, it will update the address bar and fire a popstate to let us know we need to swap the previous content back in.

We’ll go through it step by step, but first we have to be aware of a browser inconsistency. As well as firing a popstate event when the user clicks back from a simulated history entry, Webkit browsers also fire one on every page load. Firefox, on the other hand, doesn’t. Both vendors seem to believe they are following the specification. One of them has to be wrong, but right now that doesn’t matter. What does matter is that Webkit’s extra popstate is not helpful to our paging script — we only want to react when the back button is pressed, not when the page first loads.

Anyway, here’s what our $(document).ready function is now doing:

  1. First, we have an if statement that tests for support of the window.history object and history.pushState function. If they are not supported (e.g. the History API is not supported), then our script will go no further — non-supporting browsers will get the standard non-JavaScript behaviour we started with, and that’s just fine.
  2. Next, we declare a boolean variable called historyedited and set it to false. If and when one of our paging links gets clicked and we do a history.pushState, we’ll set it to true (as you can see in the click handler function), but otherwise it stays false.
  3. Next, we use jQuery’s $(window).bind function to add an event listener for popstate. The first thing it does when a popstate fires is check the historyedited variable. If it’s false, then we haven’t done a pushState yet, so it must be Webkit’s extra popstate on page load, so we won’t react to it. If it’s true, then we need to update the page.
  4. This brings us back to using our loadProducts function again. This time, instead of getting the URL from a link that was just clicked, we get it from the window.location object — by the time the browser fires a popstate at us, it has updated the address bar and window.location with the required URL. We need to use location.pathname + location.search, because the pathname property gets the main part of the URL, and the search property gets the query string, which is essential to our paging system.

Example 3 demonstrates the finished script. In relatively few lines of code, we’ve implemented Ajax pagination that works perfectly, requires no maintenance and preserves the back button, while providing a solid fallback for browsers without the History API, or with JavaScript disabled.

Comments off