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:
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
But how can we calculate these values?
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>< 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 ></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! 📖
Top comments (0)