71
loading...
This website collects cookies to deliver better user experience
rails new infinite-scroll-article --webpack=stimulus
Article
with a string title and a text content.rails g model Article title content:text
rails db:migrate
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
rails db:seed
#index
method and the corresponding view to display those 100 articles.rails g controller articles index
# config/routes.rb
Rails.application.routes.draw do
root "articles#index"
end
# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
def index
@articles = Article.all
end
end
<!-- 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 %>
rails s
and webpack server webpack-dev-server
and see on the homepage the list of 100 articles we just created!bundle add geared_pagination
bundle install
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
<!-- 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 %>
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)
}
}
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>
console.log("intersection")
when the viewport intersects the next page link.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.// 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)
})
}
initialize() {
this.observeNextPageLink()
}
console.log("intersection")
. Note that this process is asynchronous: we don't know when the next intersection is going to happen!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")
}
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)
})
}
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!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!yarn add @rails/request.js
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)
})
}
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
}
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.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
}
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!<% unless @page.last? %>
<%= link_to "Next page",
root_path(page: @page.next_param),
data: { pagination_target: "nextPageLink" },
style: "visibility: hidden;" %>
<% end %>