In this article, we’ll learn how to build an infinite scroll pagination system using only a few lines of code. We will create a very simple Rails application and implement the infinite scroll feature in a Stimulus Controller that you can reuse to paginate all the resources of your app. We will do this step by step so let’s begin!
Let’s start by creating a new Rails application with Stimulus installed:
rails new infinite-scroll-article --webpack=stimulus
We’ll start by building a pagination feature that works without any Javascript. Let’s first create a model Article
with a string title and a text content.
rails g model Article title content:text
rails db:migrate
Now that we have our Article
model, let’s create a seed that creates 100 articles for us to paginate.
# db/seeds.rb
puts "Remove existing articles"
Article.destroy_all
puts "Create new articles"
100.times do |number|
Article.create!(
title: "Title #{number}",
content: "This is the body of the article number #{number}"
)
end
To persist those 100 articles in the database, let’s run the command:
rails db:seed
We are good to go for the model part, let’s now create a controller with only the #index
method and the corresponding view to display those 100 articles.
rails g controller articles index
In the routes file, let’s make our articles list the home page:
# config/routes.rb
Rails.application.routes.draw do
root "articles#index"
end
In the controller, let’s query all the articles from the database:
# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
def index
@articles = Article.all
end
end
Finally, let’s display all of our 100 articles in the view.
<!-- app/views/articles/index.html.erb -->
<h1>Articles#index</h1>
<% @articles.each do |article| %>
<article>
<h2><%= article.title %></h2>
<p><%= article.content %></p>
</article>
<% end %>
You can now launch your local server rails s
and webpack server webpack-dev-server
and see on the homepage the list of 100 articles we just created!
We’re now ready to add a very simple pagination as a second step.
For the pagination, we will use a very simple gem created by the Basecamp team called geared pagination. It is very small (less than 50 commits at the time I write this article) and very well written.
Let’s add the gem to our Gemfile and install it. Don’t forget to restart your server after that!
bundle add geared_pagination
bundle install
Using the gem is very easy, we just have to use the set_page_and_extract_portion_from
method in the controller like this:
# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
def index
# Note that we specify that we want 10 articles per page here with the
# `per_page` option
@articles = set_page_and_extract_portion_from Article.all, per_page: [10]
end
end
In the view, we simply have to add the pagination logic at the end of the page:
<!-- app/views/articles/index.html.erb -->
<h1>Articles#index</h1>
<% @articles.each do |article| %>
<article>
<h2><%= article.title %></h2>
<p><%= article.content %></p>
</article>
<% end %>
<% unless @page.last? %>
<%= link_to "Next page", root_path(page: @page.next_param) %>
<% end %>
The pagination works! Click on the next page link to see the page changing. But that’s not what we want! What we want is an infinite scroll and that’s the most interesting part of this article!
The infinite scroll will work as follow:
Are you ready? Let’s go!
First, let’s create a pagination controller with Stimulus and connect it to our articles index page.
Let’s add a nextPageLink
target and log it in the console when the controller initialized.
// app/javascript/controllers/pagination_controller.js
import { Controller } from "stimulus"
export default class extends Controller {
static targets = ["nextPageLink"]
initialize() {
console.log(this.nextPageLinkTarget)
}
}
To make it work, we also need to update our HTML by adding data-controller="pagination"
to the articles list and data-pagination-target="nextPageLink"
to the next page link. Our index code now looks like this:
<!-- app/views/articles/index.html.erb -->
<div data-controller="pagination">
<% @articles.each do |article| %>
<article>
<h2><%= article.title %></h2>
<p><%= article.content %></p>
</article>
<% end %>
<% unless @page.last? %>
<%= link_to "Next page",
root_path(page: @page.next_param),
data: { pagination_target: "nextPageLink" } %>
<% end %>
</div>
Refresh your page and you should now see the next page link logged into your console.
Now that everything is wired correctly, we are ready to add our feature. The first thing we are going to do, is to console.log("intersection")
when the viewport intersects the next page link.
How do you do this?
With a Javascript object called IntersecionObserver
! The Intersection Observer API provides a way to asynchronously observe changes in the intersection of a target element with an ancestor element or with a top-level document’s viewport.
Let’s add this in our Stimulus controller:
// app/javascript/controllers/pagination_controller.js
import { Controller } from "stimulus"
export default class extends Controller {
static targets = ["nextPageLink"]
initialize() {
this.observeNextPageLink()
}
// private
async observeNextPageLink() {
if (!this.hasNextPageLinkTarget) return
await nextIntersection(this.nextPageLinkTarget)
console.log("intersection")
}
}
const nextIntersection = (targetElement) => {
return new Promise(resolve => {
new IntersectionObserver(([element]) => {
if (!element.isIntersecting) {
return
} else {
resolve()
}
}).observe(targetElement)
})
}
Wow! That’s the most complicated part of the feature! Let’s break it down.
First, when the Stimulus controller is initialized, we start observing the next page link.
initialize() {
this.observeNextPageLink()
}
If there is no next page link on the page, then the controller does nothing. However, if there is a next page link, we’ll wait for the intersection and then console.log("intersection")
. Note that this process is asynchronous: we don’t know when the next intersection is going to happen!
How do we do asynchronous Javascript? With async / await and promises!
The function observeNextPageLink
is asynchronous for this reason. See how it reads like plain English now? Wait for the next intersection with the next page link and then console.log("intersection")
.
async observeNextPageLink() {
if (!this.hasNextPageLinkTarget) return
await nextIntersection(this.nextPageLinkTarget)
console.log("intersection")
}
Last but not least, the nextIntersection
function has to return a Promise
that will resolve when the next page link intersects the viewport. This can be done easily by creating a new IntersectionObserver
that will observe the next page link.
const nextIntersection = (targetElement) => {
return new Promise(resolve => {
new IntersectionObserver(([element]) => {
if (!element.isIntersecting) {
return
} else {
resolve()
}
}).observe(targetElement)
})
}
Now that our mechanic is in place, we need to replace our console.log("intersection")
with something useful. Instead of logging “intersection” in the console, we will fetch the articles from the next page and append them to the list of articles we already have!
To do AJAX requests with Rails, we will use the brand new rails/request.js library that was created in June 2021. This library is a wrapper around fetch
that you’ll normally use to do AJAX requests in Javascript. It integrates nicely with Rails, for example, it automatically sets the X-CSRF-Token
header that is required by Rails applications, this is why we’ll use it!
Let’s add it to our package.json using yarn:
yarn add @rails/request.js
Now let’s import the get
function in our Pagination Controller and replace the console.log("intersection")
with the actual logic. The code now looks like this:
import { Controller } from "stimulus"
import { get } from "@rails/request.js"
export default class extends Controller {
static targets = ["nextPageLink"]
initialize() {
this.observeNextPageLink()
}
async observeNextPageLink() {
if (!this.hasNextPageLinkTarget) return
await nextIntersection(this.nextPageLinkTarget)
this.getNextPage()
}
async getNextPage() {
const response = await get(this.nextPageLinkTarget.href) // AJAX request
const html = await response.text
const doc = new DOMParser().parseFromString(html, "text/html")
const nextPageHTML = doc.querySelector(`[data-controller~=${this.identifier}]`).innerHTML
this.nextPageLinkTarget.outerHTML = nextPageHTML
}
}
const nextIntersection = (targetElement) => {
return new Promise(resolve => {
new IntersectionObserver(([element]) => {
if (!element.isIntersecting) {
return
} else {
resolve()
}
}).observe(targetElement)
})
}
The only changes here are the import { get } from "@rails/request.js"
that we use to make a get AJAX request to our server and the console.log("intersection")
that was replaced by this.getNextPage()
. Let’s understand this last method.
async getNextPage() {
const response = await get(this.nextPageLinkTarget.href) // AJAX request
const htmlString = await response.text
const doc = new DOMParser().parseFromString(htmlString, "text/html")
const nextPageHTML = doc.querySelector(`[data-controller=${this.identifier}]`).outerHTML
this.nextPageLinkTarget.outerHTML = nextPageHTML
}
First, we issue a get request to the server, wait for the response and store it in the response
variable. Then we extract the text from the response and store it in the htmlString
variable. As we want to use querySelector on this htmlString
, we first need to parse it to make it an HTML document with DOMParser
. We then store this document in the doc
variable. We then extract the next page articles and the next page link from this document and append them to our articles list by replacing the current next page link.
Our infinite scroll is now working, but only for one iteration! We need to make it recursive. Every time new articles are added to the page, a new next page link is also added! We need to observe this new next page link to be able to have a read infinite scroll!
Let’s add this recursion!
Here is the final controller:
import { Controller } from "stimulus"
import { get } from "@rails/request.js"
export default class extends Controller {
static targets = ["nextPageLink"]
initialize() {
this.observeNextPageLink()
}
async observeNextPageLink() {
if (!this.hasNextPageLinkTarget) return
await nextIntersection(this.nextPageLinkTarget)
this.getNextPage()
await delay(500) // Wait for 500 ms
this.observeNextPageLink() // repeat the whole process!
}
async getNextPage() {
const response = await get(this.nextPageLinkTarget.href)
const html = await response.text
const doc = new DOMParser().parseFromString(html, "text/html")
const nextPageHTML = doc.querySelector(`[data-controller~=${this.identifier}]`).innerHTML
this.nextPageLinkTarget.outerHTML = nextPageHTML
}
}
const delay = (ms) => {
return new Promise(resolve => setTimeout(resolve, ms))
}
const nextIntersection = (targetElement) => {
// Same as before
}
Here, we only changed the two last lines of the observeNextPageLink
function by waiting 500ms to avoid scrolling too fast, and then, we observe the new next page link if there is one, thus repeating the whole process we just went through!
The last think you can do is hide the next page link on the page to make it a real infinite scroll.
<% unless @page.last? %>
<%= link_to "Next page",
root_path(page: @page.next_param),
data: { pagination_target: "nextPageLink" },
style: "visibility: hidden;" %>
<% end %>
You did it, you built a real infinite scroll with Rails and Stimulus!
This article is heavily inspired by hey.com’s infinite scroll. Thanks to the Basecamp team for leaving the source maps open. It was really helpful when I had to build a similar feature!
You can follow me on Twitter to get notified when I publish new articles. I sometimes do when I work on interesting features like this infinite scroll!