Infinite Scrolling with Rails, In Practice

Ilya Bodrov
Tweet

no_load_more

In the predecessor to this article, we set up a very simple blog with demo posts and implemented infinite scrolling instead of simple pagination. We used will_paginate and some javascript to achieve this task.

The working demo can be found on Heroku.

The source code can be found on GitHub.

Today, let’s implement a “Load more” button instead of an infinite scrolling. This solution may come in handy when, for example, you have some links inside the footer and infinite scrolling causes it to “run away” until all the records are loaded.

To demonstrate how this can be done, make the following changes to PostsController:

posts_controller.rb

def index
    get_and_show_posts
end

def index_with_button
    get_and_show_posts
end

private

def get_and_show_posts
    @posts = Post.paginate(page: params[:page], per_page: 15).order('created_at DESC')
    respond_to do |format|
        format.html
        format.js
    end
end

And add a route:

config/routes.rb

get '/posts_with_button', to: 'posts#index_with_button', as: 'posts_with_button'

Now there are two independent pages that demonstrate two concepts.

index_with_button.html.erb

<div class="page-header">
    <h1>My posts</h1>
</div>

<div id="my-posts">
    <%= render @posts %>
</div>

<div id="with-button">
    <%= will_paginate %>
</div>

<% if @posts.next_page %>
    <div id="load_more_posts" class="btn btn-primary btn-lg">More posts</div>
<% end %>

For the most part, the view is the same. I’ve only changed the identifier of the pagination wrapper (we will use it later to write a proper condition) and added a #load_more_posts block that will be displayed as a button with the help of Bootstrap classes. We want this button to be shown only if there are more pages available. Imagine a situation when there is only one post in the blog – why would we need to render “Load more” button?

This button should not be visible at first – we will show it with javascript. This way, there is a fallback to the default behaviour if JS is disabled:

application.css.scss

#load_more_posts {
    display: none;
    margin-bottom: 10px; /* Some margin to separate it from the footer */
}

It’s time to modify the client-side code:

pagination.js.coffee

if $('#with-button').size() > 0
    $('.pagination').hide()
    loading_posts = false

    $('#load_more_posts').show().click ->
      unless loading_posts
        loading_posts = true
        more_posts_url = $('.pagination .next_page a').attr('href')
        $this = $(this)
        $this.html('<img src="/assets/ajax-loader.gif" alt="Loading..." title="Loading..." />').addClass('disabled')
        $.getScript more_posts_url, ->
          $this.text('More posts').removeClass('disabled') if $this
          loading_posts = false
      return

Here we are hiding the pagination block, showing the “Load more” button instead, and binding a click event handler to it. Also, the loading_posts flag is used to prevent sending multiple requests if a user clicks the button more than once.

Inside the event handler, we are using the same concept as before: fetch the next page URL, add a “loading” image, disable the button, and submit the AJAX request to the server. We’ve also added a callback that fires when the response is received. This callback restores the button to its original state and sets the flag to false.

And now the view:

index_with_button.js.erb

$('#my-posts').append('<%= j render @posts %>');
<% if @posts.next_page %>
    $('.pagination').replaceWith('<%= j will_paginate @posts %>');
    $('.pagination').hide();
<% else %>
    $('.pagination, #load_more_posts').remove();
<% end %>

Again, we are appending new posts to the page. If there are more posts, a new pagination is rendered and then hidden. Otherwise, the pagination button is removed.

Link to a Particular Page

Now you know how to create infinite scrolling or a “Load more” button, instead of a classic pagination. One thing that you should probably consider is, how can a user share a link to a particular page? Right now, there is no way to do this, because we do not change the URL when we load new pages.

Let’s try to achieve this by changing the search part inside the URL (the one that starts with the ? symbol) using javascript:

window.location.search = 'page' + page_number

Unfortunately, this instantly reloads the page, which is not what we want. On our second try, change the hash portion instead (the one that starts with the # symbol). Indeed, this works well. The page
is not reloaded. However, there is a third, and more elegant, solution – the History API. With this API, we can directly manipulate the browser’s history.

In this particular case, we want to add some entries to the history using the pushState method.

First of all, let’s download the History.js library by Benjamin Arthur Lupton that provides cross-browser support for the HTML 5 History/State API. For jQuery you will probably want to use the script located under scripts/bundled/html4+html5/jquery.history.js.

Now, let’s write a simple function that will fire after $.getScript finishes loading the resource:

pagination.js.coffee

page_regexp = /\d+$/

pushPage = (page) ->
    History.pushState null, "InfiniteScrolling | Page " + page, "?page=" + page
    return

$.getScript more_posts_url, ->
    # ...
    pushPage(more_posts_url.match(page_regexp)[0])

Do not forget that more_posts_url contains a link to the next page, where the page number is fetched. Inside the pushPage function we use History.js to manipulate the browser’s history and, basically, change the URL (with the last parameter). The second parameter changes the window’s title. The first parameter (null) can be used to store some additional data, if needed. Please note that, after the URL was modified, a user can click the “Back” button in his browser to navigate to the previous page. Pretty cool.

The last thing to worry about is the legacy browsers: IE 9 and less to be specific, which do not support the History API. In these archaic beasts, the resulting URL will look like this: http://example.com#http://example.com?page=2 instead of http://example.com?page=2. So, we have to add support for this case.

pagination.js.coffee

[...]

hash = window.location.hash
  if hash.match(/page=\d+/i)
    window.location.hash = '' # Otherwise the hash will remain after the page reload
    window.location.search = '?page=' + hash.match(/page=(\d+)/i)[1]

[...]

This block of code runs on page load. Here, we scan the url hash for page=. If found, the search portion of the URL is updated with a corresponding page number and after that the page is reloaded.

It’s a good idea to slightly modify the view so that the pagination is displayed only when a next page is available (like we did with the “Load more” button). Otherwise, when the user enters a URL to go straight to the last page, the pagination will still be displayed and the javascript event handler will still be bound.

index.html.erb

<% if @posts.next_page %>
    <div id="infinite-scrolling">
        <%= will_paginate %>
    </div>
<% end %>

This solution, however, leads to a problem where the user cannot load previous posts. You could implement a more complex solution with a “Load previous” button or just display a “Go to the first page” link.

Another way is to combine basic pagination, displayed at the top of the page, along with infinite scrolling. This solves another problem: What if our visitor wants to go to the last or, say, the 31st page? Scrolling down and down (or clicking a “Load more” button 30 times) will be very annoying. We shoule either present a way to jump to a desired page or implement some filters (by date, category, view count etc).

Pagination and Infinite Scrolling

Let’s implement the “combined” solution, combinin infinite scrolling and basic pagination. This will also work with javascript disabled, our user will just see the pagination in two places, which isn’t bad.

First, add another pagination block to the views (in the next section, we will work with the static-pagination wrapper) block:

index.html.erb and index_with_button.html.erb

<div class="page-header">
    <h1>My posts</h1>
</div>

<div id="static-pagination">
    <%= will_paginate %>
</div>

[...]

After that, we have to slightly modify the scripts so that only one pagination block is being referenced (I’ve placed comments near the modified lines):

pagination.js.coffee

[...]

if $('#infinite-scrolling').size() > 0
    $(window).bindWithDelay 'scroll', ->
      more_posts_url = $('#infinite-scrolling .next_page a').attr('href') # <--------
      if more_posts_url && $(window).scrollTop() > $(document).height() - $(window).height() - 60
        $('#infinite-scrolling .pagination').html( # <--------
          '<img src="/assets/ajax-loader.gif" alt="Loading..." title="Loading..." />') # <--------
        $.getScript more_posts_url, ->
          window.location.hash = more_posts_url.match(page_regexp)[0]
      return
    , 100

  if $('#with-button').size() > 0
    # Replace pagination
    $('#with-button .pagination').hide() # <--------
    loading_posts = false

    $('#load_more_posts').show().click ->
      unless loading_posts
        loading_posts = true
        more_posts_url = $('#with-button .next_page a').attr('href') # <--------
        if more_posts_url
          $this = $(this)
          $this.html('<img src="/assets/ajax-loader.gif" alt="Loading..." title="Loading..." />').addClass('disabled')
          $.getScript more_posts_url, ->
            $this.text('More posts').removeClass('disabled') if $this
            window.location.hash = more_posts_url.match(page_regexp)[0]
            loading_posts = false
      return

[...]

index.js.erb

$('#my-posts').append('<%= j render @posts %>');
$('.pagination').replaceWith('<%= j will_paginate @posts %>');
<% unless @posts.next_page %>
    $(window).unbind('scroll');
    $('#infinite-scrolling .pagination').remove(); // <--------
<% end %>

Inside index.js.erb we do not modify the 2nd line because we want pagination to update in both places.

index_with_button.js.erb

$('#my-posts').append('<%= j render @posts %>');
$('.pagination').replaceWith('<%= j will_paginate @posts %>');
<% if @posts.next_page %>
    $('#with-button .pagination').hide(); // <--------
<% else %>
    $('#with-button .pagination, #load_more_posts').remove(); // <--------
<% end %>

The same concept applies here. Also, note that in both cases I have moved the replaceWith out of the conditional statement. At this point we want our pagination to be rewritten every time the next page is open. If we do not make this change when the user opens the last page the top pagination will not be replaced – only the bottom one will be removed.

Spy the Scrolling!

We’ve reached the last, and probably the trickiest, part. At this point, we update the URL and highlight the current page when the user scrolls down and more posts are being loaded. However, what if our user decides to scroll back (to the top)? Of course, neither the URL nor the pagination will be updated and it can be rather confusing!

This can be solved by implementing scroll spying. Our plan is as follows: add delimiters between posts from the different pages (those delimiters will contain page numbers) and raise an event whenever the user scrolls by these delimiters. Inside the event, check which page he is currently viewing and update the URL and pagination accordingly.

Lets start with the delimiters.

index.html.erb and index_with_button.html.erb

[...]

<div id="my-posts">
  <div class="page-delimiter first-page" data-page="<%= params[:page] || 1 %>"></div>
  <%= render @posts %>
</div>

[...]

Here the data-page contains the actual page number. We either fetch it from the GET parameter or set to 1 if no page number was provided. Notice the first-page class that we will use shortly.

We also have to update the scripts.

index.js.erb and index_with_button.js.erb

var delimiter = $('<div class="page-delimiter" data-page="<%= params[:page] %>"></div>');
$('#my-posts').append(delimiter);
$('#my-posts').append('<%= j render @posts %>');

[...]

Right now these delimiters will be invisible to the user.

Lastly, implement the actual scroll spying. For that we can use the Waypoints library for jQuery, created by Caleb Troughton. There are some other libraries that provide the similar functionality, but this one allows tracking whether the user scrolled up or down, which will come in handy in our case.

The following function will attach an event handler to the delimiter which fires whenever a user scrolls to it. Unfortunately, as our delimiters are being added dynamically we will have to attach this event to each one separately, otherwise Waypoints won’t work.

pagination.js.coffee

jQuery ->
  page_regexp = /\d+$/

  window.preparePagination = (el) ->
    el.waypoint (direction) ->
      $this = $(this)
      unless $this.hasClass('first-page') && direction is 'up'
        page = parseInt($this.data('page'), 10)
        page -= 1 if direction is 'up'
        page_el = $($('#static-pagination li').get(page))
        unless page_el.hasClass('active')
          $('#static-pagination .active').removeClass('active')
          pushPage(page)
          page_el.addClass('active')

    return

  [...]

Here, the code checks that the user is not scrolling up and has not reached the first page. Then, it fetches the page number from the data-page attribute and decrements it by 1 if the direction is up. This is because our delimiters are placed before the posts from the corresponding page, so when the user scrolls up and passes by this delimiter he actually leaves this page and goes to the previous one.

The #static-pagination selector points to the block with basic pagination. It returns the li element with the current page number and assigns an active class to it (removing this class from another li). Note that page numeration starts from 1, whereas indexing of the elements that are being returned by $('#static-pagination li') starts from 0, yet we do not decrement the page by 1. This is because the first li in the pagination block always contains the “Previous page” link, so we just skip it. Lastly, we also change the hash in the URL.

Also note that the preparePagination function is attached to the window. This is so we invoke it not only from within this file, but from our *.js.erb views as well. CoffeeScript wraps the code inside each file with self-invoking anonymous function to prevent polluting the global scope (which is actually a good thing). In this case, though, if we don’t attach the function to window, it will be invisible from the outside.

Now, we can apply it.

pagination.js.coffee

[...]

if $('#infinite-scrolling').size() > 0
    preparePagination($('.page-delimiter'))

[...]

if $('#with-button').size() > 0
    preparePagination($('.page-delimiter'))

[...]

index.js.erb and index_with_button.js.erb

var delimiter = $('<div class="page-delimiter" data-page="<%= params[:page] %>"></div>');
$('#my-posts').append(delimiter);
$('#my-posts').append('<%= j render @posts %>');
$('.pagination').replaceWith('<%= j will_paginate @posts %>');
preparePagination(delimiter);

[...]

The last important thing to do is to remove the $(window).unbind('scroll'); from index.js.erb, because Waypoints relies on this event and we should listen to it all the time.

You may also want to assign a fixed position to the basic pagination so that the user can check the current page. Let’s apply some really simple styling:

#static-pagination {
  position: fixed;
  top: 30px;
  opacity: 0.7;
  &:hover {
    opacity: 1;
  }
}

Now the pagination block will always be displayed on the top and will be semi-opaque. When the user hovers this element,
its opacity will be set to 1.

Conclusion

This brings us to the end of this article. I hope you’ve found some useful tips while reading it. Presented solutions are not ideal, but should give you an understanding of how this task can be accomplished. Please share your thoughts about this article, along with any ways you have solved the problem with loading previous posts on your website.

Free JavaScript: Novice to Ninja Sample

Get a free 32-page chapter of JavaScript: Novice to Ninja

No Reader comments