Pagination in Rails from scratch (no gems)

rails

activerecord

Nov, 2021

Pagination in Rails from scratch (no gems)

In this article, I will share how to implement pagination without gems. It covers the most common scenario: paginating an ActiveRecord collection and preparing the pagination metadata needed to build a simple frontend component.

Pagination usually requires having to answer two main questions:

  • What will the selected page contain?
    • How many items?
    • Which items?
  • How is the page related to the collection?
    • What is the total number of pages for this collection?
    • Is there a page after? And before?

Whilst the first will return the right subset of items back to the user, the latter will provide the frontend with the information needed to build the pagination controls. There are different types of controls you can build depending on your goals and UX preferences. In this example I will stick with a classic, something like this:

pagination controls

If you're having doubts about which controls to go with, I recommend this article.

So without further ado, let us write some code:

1. Creating a pagination mixin

Why a mixin?

When you're building an application you will probably need pagination in more than one endpoint. To avoid repeating the same implementation in different controllers we can make use of mixins. In practice, this means creating a Pagination module that can be included in different classes across your application.

The Pagination module

Following the pattern of most of the pagination gems, this Pagination module will consist of one public method: a paginate method that receives a collection and params. In our case, that collection will be an ActiveRecord collection. The params is a hash that will hold some of the current page attributes needed to paginate the collection. For instance, params will need to include page (the current page) and per_page (items that each page will retrieve from the collection).

module Pagination
  def paginate(collection:, params: {})
    pagination = Services::Pagination.new(collection, params)

    [
      pagination.metadata,
      pagination.results
    ]
  end
end

This module will be responsible for returning two sets of information: the page items (pagination.results) and the metadata that will support the pagination controls (pagination.metadata).

How do we get this data? We do not know yet, let's hold it for a while. What we know for now is that we will have to return this information to the controller. These are the answers to the questions we have raised before, in the intro of this article.

Now we can include this module in whatever class that needs pagination. In our case, that will be the controller:

class PostsController < ApplicationController
  include Pagination

  POSTS_PER_PAGE = 6

  def index
    @pagination, @posts = paginate(collection: Post.all, params: page_params)
  end

  private

  def page_params
    params.permit(:page).merge(per_page: POSTS_PER_PAGE)
  end
end

The collection will be all the posts, and the params will hold the page selected by the user and what we have defined to be the limit of each page (the POSTS_PER_PAGE constant).

Note that since the pagination module returns an array, you can 'unpack' that array and assign its values to multiple variables in one line. The first variable will hold the first value of the array and the second variable will hold the second value. You can learn more about this technique here.

2. Writing the pagination logic

Ok, so let us understand what is behind the magic of our module. The module calls a pagination service that will be responsible for getting the page items based on two values: items per page and the number that sets the start of each page. For an ActiveRecord collection that can be achieved with two methods only: limit and offset. As long as you know the values that these two methods receive - that is all you need to paginate.

module Services
  class Pagination
    attr_reader :collection, :params

    def initialize(collection, params = {})
      @collection = collection
      @params = params.merge(count: collection.size)
    end

    def metadata
      @metadata ||= ViewModel::Pagination.new(params)
    end

    def results
      collection
        .limit(metadata.per_page)
        .offset(metadata.offset)
    end
  end
end

limit limits the returned results to a maximum number. In our case, all pages should have 6 items each (the last page can have less, depending on the total size of the collection). So 6 is our limit. Remember this was defined as constant in the controller - POSTS_PER_PAGE - and passed as a param to our pagination service.

But what about offset? The offset will help you define the boundaries of your page. It skips over a number of posts when returning results. If your offset is 12 it will skip the first 12 posts and return all resulting records after that. When combined with limit it allows us to see pages beyond the first page. So if the offset is 12 and limit is 6, we will return 6 results after skipping the first 12. That will be page 3.

But these values are calculated in a separate class that will be solely responsible for building all the metadata needed for the pagination and for the frontend. This leads us to the final part of our backend:

Preparing the pagination metadata

module ViewModel
  class Pagination
    DEFAULT = { page: 1, per_page: 6 }.freeze

    attr_reader :page, :count, :per_page

    def initialize(params = {})
      @page     = (params[:page] || DEFAULT[:page]).to_i
      @count    = params[:count]
      @per_page = params[:per_page] || DEFAULT[:per_page]
    end

    def offset
      return 0 if page == 1

      per_page * (page.to_i - 1)
    end

    def next_page
      page + 1 unless last_page?
    end

    def next_page?
      page < total_pages
    end

    def previous_page
      page - 1 unless first_page?
    end

    def previous_page?
      page > 1
    end

    def last_page?
      page == total_pages
    end

    def first_page?
      page == 1
    end

    def total_pages
      (count / per_page.to_f).ceil
    end
  end
end

Besides providing our pagination service with the limit and offset values that we saw before, this class will work as a frontend helper as most of the methods in this class will hold the information needed to build the pagination controls.

Going back to our module, remember that the first element of the returned array will be an instance of this class. It will later be assigned to an instance variable @pagination in our PostsController and made available for the frontend views to consult.

3. Building the frontend component

At the bottom of your collection view, in this case posts/index.html.erb you can now add the pagination controls. It might be a good idea to isolate them in a component:

<%# posts/index.html.erb %>


<%= render partial: 'shared/pagination', locals: { pagination: @pagination } %>
<%# _pagination.html.erb %>

<div>
  <% if  pagination.previous_page? %>
    <%= link_to 'First', posts_path(page: 1) %>
    <%= link_to '< Previous', posts_path(page: pagination.previous_page) %>
  <% else %>
    <p>First</p>
    <p>&#60; Previous</p>
  <% end %>
  <p><%= "Page #{pagination.page} of #{pagination.total_pages}" %></p>
  <% if pagination.next_page? %>
    <%= link_to 'Next >', posts_path(page: pagination.next_page) %>
    <%= link_to 'Last', posts_path(page: pagination.total_pages) %>
  <% else %>
    <p>Next &#62;</p>
    <p>Last </p>
  <% end %>
</div>

So there you go, you can now access the ViewModel::Pagination methods like previous_page, next_page, or total_pages and direct the links to the right pages. Now go ahead and add the CSS styling as you please.

Have fun paginating! 📖

And if this article was helpful, I invite you to subscribe to my newsletter and write me back with comments or doubts.