Skip to content Skip to sidebar Skip to footer

Infinite Scroll Pagination in Ruby on Rails apps

The Problem
In my PM roadmap, I had some minor improvements for the scrolling of users in Kubukisa, one of my passion projects. Below is how I had it initially using the pagy gem.
Screenshot from Kubukisa app

Not really the best-looking pagination if you are a perfectionist like me. I mean, it can do but why not improve it, right?! For the purposes of this article, I will use my personal site for reference, particularly, the POST resource on how to go about implementing infinite scrolling.

Get started
To get started, you need to install Hotwire Turbo in your app. For Rails apps, you need the gem, this will be important as we’ll be using turbo_streams. Next, you will need to include the Pagy Countless subclass which is useful for saving one count query per request, this will be important later in the implementation.
				
					require 'pagy/extras/countless'
				
			

PS: Remember to restart your server, I had errors until I restarted the server.

In your posts_controller.rb, include Pagy.

				
					class PostsController < ApplicationController
    include Pagy:: Backend
end
				
			

In your Index method, set up Pagy’s Countless subclass.

				
					class PostsController < ApplicationController
    def index
    @pagy, @posts = pagy_countless(Post.all.order('created_at DESC').where(is_published: true), items: 5)
    
        respond_to do | format|
        format.html
        format.turbo_stream
        end
    end
end
				
			
You don’t need the WHERE clause if you don’t have conditions you would like to set for your resources. You will also need to explicitly indicate the formats the index view will respond to. You will later see how to set these up. The respond_to block is a helper method on the superclass PostsController that takes |format| as the argument to the block. This Rails helper method references the response that will be sent to the View (i.e. on the browser). From here, in your views, particularly, the index view, declutter your view and use partials as below in the _posts.html.erb:
				
					<% if @posts.empty? %>
  <h3 class="text-xl font-bold">No results found for your search.</h3>
<% else %>
  <% @posts.each do |post| %>
    <div class="container" id="<%= dom_id post %>">
      <div class="flex flex-col md:grid grid-cols-12 text-gray-50">
        <div class="flex md:contents">
          <div class="col-start-2 col-end-4 xl:mr-10 lg:mr-10 md:mr-4 md:mx-auto relative">
            <div class="h-full w-24 flex items-center justify-center">
              <div class="h-full w-1 bg-thubz-accent pointer-events-none"></div>
            </div>
            <div class="w-24 h-24 absolute top-1/2 -mt-28 rounded-full object-center w-full bg-thubz-primary shadow text-center animate__animated animate__fadeInDown">
              <% if post.image.attached? %>
                <%= link_to post do %>
                  <%= image_tag post.image, :alt => "#{post.title} Image",
                                :class => "h-24 w-24 rounded-full object-center w-full object-cover object-center hvr-float" %>
                <% end %>
              <% else %>
                <%= link_to post do %>
                  <%= image_tag "post_placeholder.jpg", :alt => "#{post.title} Placeholder",
                                :class => "h-24 w-24 rounded-full object-center w-full object-cover object-center hvr-float" %>
                <% end %>
              <% end %>
            </div>
          </div>
          <div class="col-start-4 col-end-12 p-4 my-4 mr-auto w-full">
            <h3 class="sm:text-2xl text-4xl font-medium mb-3">
              <%= link_to post.title, post, class: "text-thubz-primary hover:text-white hvr-float animate__animated animate__fadeInDown break-all" %>
            </h3>
            <p class="leading-tight text-justify w-full text-thubz-hover font-semibold uppercase mb-4 animate__animated animate__fadeInDown">
              <%= post.created_at.strftime("%d %B %Y") %>
            </p>
            <p class="text-white animate__animated animate__fadeInDown">
              <%= truncate(strip_tags(post.content.to_s), length: 300) %>
            </p>
            <%= link_to post, class: "inline-flex items-center text-thubz-primary hover:text-white hvr-float mt-4 animate__animated animate__fadeInDown" do  %>
              Read More
              <svg class="w-4 h-4 ml-2" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round">
                <path d="M5 12h14"></path>
                <path d="M12 5l7 7-7 7"></path>
              </svg>
            <% end %>
          </div>
        </div>
      </div>
    </div>
  <% end %>
<% end %>
				
			

I use Tailwind CSS for my projects, in case you use Bootstrap, etc. you can discard my classes. What is most important here is including the id of posts (highlighted above) which is what turbo will be used to reference the posts on each query.

Back in the index view, you need to set up the rendering of the above partial view (you can read more on partials here) as well as the turbo_stream_tag helper to create a turbo stream.

				
					<section class="overflow-hidden bg-thubz-tertiary">
  <div id="posts" class="p-4 mt-4">
  <%= render 'posts' %>
  </div>
  <%= turbo_frame_tag :pagination, loading: :lazy, src: posts_path(format: :turbo_stream) %>
</section>
				
			

Here, you will notice that the div wrapping the posts partial has an id which is important. This id is named according to your resource, e.g. articles or towns. The turbo_frame_tag is lady loaded and most importantly, pulls the queries from the posts_path and displays the queries in the turbo_stream format we defined on the posts_controller respond_to helper method.

I hope you are still following and everything is starting to come together.

We further will need to create the index.turbo_stream.erb view for turbo.

				
					<%= turbo_stream.append :posts do %>
  <%= render 'posts' %>
<% end %>
<%= turbo_stream.append :posts do %>
  <% if @pagy.next.present? %>
    <%= turbo_frame_tag :pagination, loading: :lazy, src: posts_path(format: :turbo_stream, page: @pagy.next) %>
  <% end %>
<% end %>
				
			

Here, we have to wrap the posts partial in a turbo_stream and append it with the posts resource. The turbo_frame_tag is similar to that in the index.html.erb and does the same thing. To prevent the loading to be infinitely looped, you will have to include page: @pagy.next which checks the size of the paginated collection (@records) in order to know if it is the last page or not.

There you have it, you now have infinite scrolling.

Leave a comment