Infinite Scrolling with Rails, In Practice

Share this article

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.

Frequently Asked Questions about Infinite Scrolling in Rails

What are the SEO implications of using infinite scrolling in Rails?

Infinite scrolling in Rails can have both positive and negative SEO implications. On the positive side, it can improve user engagement and time spent on the site, which are factors that search engines consider when ranking pages. However, on the downside, if not implemented correctly, infinite scrolling can make it difficult for search engines to crawl and index all the content on your page. This is because search engines typically crawl a site by following links, and with infinite scrolling, not all content is available at once. To mitigate this, you can implement a paginated version of your content alongside infinite scrolling.

How can I make my infinite scrolling page SEO-friendly?

To make your infinite scrolling page SEO-friendly, you should implement a paginated version of your content. This means that while users see a single, continuously loading page, search engines see multiple pages with distinct URLs. This allows search engines to crawl and index all your content. You can also use the history API to change the URL as the user scrolls, which can help with indexing.

Can infinite scrolling affect the performance of my website?

Yes, infinite scrolling can affect the performance of your website if not implemented correctly. Loading too much content at once can slow down your site, especially for users with slower internet connections. To avoid this, you can implement lazy loading, which only loads content as the user scrolls down the page.

How can I implement lazy loading with infinite scrolling in Rails?

Implementing lazy loading with infinite scrolling in Rails involves loading content only when it’s needed. This can be achieved by using JavaScript to detect when the user has scrolled to a certain point on the page, and then using AJAX to fetch and display the next set of content. This can help improve the performance of your site and make it more user-friendly.

What are some common issues with infinite scrolling and how can I avoid them?

Some common issues with infinite scrolling include difficulty in navigating back to previous content, problems with scrolling on mobile devices, and issues with search engine indexing. To avoid these issues, you can implement a ‘Load More’ button instead of automatic loading, ensure your site is mobile-friendly, and use a paginated version of your content for search engines.

How can I test the performance of my infinite scrolling page?

You can test the performance of your infinite scrolling page using various tools such as Google’s PageSpeed Insights, which can give you insights into how your page performs and provide suggestions for improvement. You can also use your browser’s developer tools to monitor network activity and see how much data is being loaded at a time.

How can I improve the user experience with infinite scrolling?

To improve the user experience with infinite scrolling, you can provide visual feedback to indicate that more content is loading, implement a ‘Load More’ button to give users control over loading content, and ensure that users can easily navigate back to previous content. You should also make sure your site is mobile-friendly, as scrolling can be more difficult on smaller screens.

Can I use infinite scrolling with other types of content besides text?

Yes, you can use infinite scrolling with various types of content, including images, videos, and even interactive content. However, keep in mind that different types of content may have different performance implications, and you should test your page to ensure it performs well.

How can I implement infinite scrolling in Rails?

Implementing infinite scrolling in Rails typically involves using JavaScript and AJAX to load content as the user scrolls. You can use the ‘will_paginate’ or ‘kaminari’ gems to help with pagination, and the ‘jquery-infinitescroll’ plugin to handle the scrolling behavior.

What are some alternatives to infinite scrolling?

Some alternatives to infinite scrolling include pagination, where content is divided into separate pages, and ‘Load More’ buttons, which allow users to load more content on demand. These alternatives can be more user-friendly and SEO-friendly, but they may not provide the same seamless browsing experience as infinite scrolling.

Ilya Bodrov-KrukowskiIlya Bodrov-Krukowski
View Author

Ilya Bodrov is personal IT teacher, a senior engineer working at Campaigner LLC, author and teaching assistant at Sitepoint and lecturer at Moscow Aviations Institute. His primary programming languages are Ruby (with Rails) and JavaScript. He enjoys coding, teaching people and learning new things. Ilya also has some Cisco and Microsoft certificates and was working as a tutor in an educational center for a couple of years. In his free time he tweets, writes posts for his website, participates in OpenSource projects, goes in for sports and plays music.

Share this article
Read Next
Get the freshest news and resources for developers, designers and digital creators in your inbox each week