In this tutorial, I'll show you how to add an infinitely scrolling blog roll using Rails and Hotwire. Note that this is different than Chris Oliver's awesome infinite scroll tutorial, in that we're loading a new post once a user scrolls to the bottom of a current post. Below is a demo.
Step 1: Application Set-Up
rails new rails-infinite-scroll-posts -d-postgresql --webpacker=stimulus
rails db:setup
bundle add turbo-rails
rails turbo:install
Step 2: Create Post Scaffold
rails g scaffold post title body:text
rails db:migrate
Step 3: Add Seed Data
bundle add faker -g=development
- Update
db/seeds.rb
10.times do |i|
Post.create(title: "Post #{i+1}", body: Faker::Lorem.paragraph(sentence_count: 500))
end
rails db:seed
Step 4. Create the ability to navigate between Posts
-
touch app/models/concerns/navigable.rb
module Navigable
extend ActiveSupport::Concern
def next
self.class.where("id > ?", self.id).order(id: :asc).first
end
def previous
self.class.where("id < ?", self.id).order(id: :desc).first
end
end
- Include Module in Post Model
class Post < ApplicationRecord
include Navigable
end
Note: We could just add the
next
andprevious
methods directly in thePost
model, but using a Module means we can use these methods in future models.
- Update PostsController
class PostsController < ApplicationController
...
def show
@next_post = @post.next
end
...
end
Step 5: Use Turbo Frames to lazy-load the next Post
- Add frames to
app/views/posts/show.html.erb
<p id="notice"><%= notice %></p>
<%= turbo_frame_tag dom_id(@post) do %>
<p>
<strong>Title:</strong>
<%= @post.title %>
</p>
<p>
<strong>Body:</strong>
<%= @post.body %>
</p>
<%= link_to 'Edit', edit_post_path(@post), data: { turbo_frame: "_top" } %> |
<%= link_to 'Back', posts_path, data: { turbo_frame: "_top" } %>
<%= turbo_frame_tag dom_id(@next_post), loading: :lazy, src: post_path(@next_post) if @next_post.present? %>
<% end %>
What's going on?
- We wrap the content in a
turbo_frame_tag
with anID
ofdom_id(@post)
. For example, thedom_id(@post)
call will evaluate toid="post_1"
if the Post's ID is 1. This keeps the ID's unique. - We add another
turbo_frame_tag
within the outerturbo_frame_tag
to lazy-load the next post. We can look for the next post thanks to ourNavigable
module that we created earlier.- The
loading
attribute ensures that the frame will only load once it appears in the viewport.
- The
- We add
data: { turbo_frame: "_top" }
to override navigation targets and force those pages to replace the whole frame. Otherwise, we would need to add Turbo Frames to theedit
andindex
views.- This is only because those links are nested in the outermost
turbo_frame_tag
.
- This is only because those links are nested in the outermost
Step 6: Use Stimulus to update the path as new posts are loaded
-
touch app/javascript/controllers/infinite_scroll_controller.js
import { Controller } from "stimulus"
export default class extends Controller {
static targets = ["entry"]
static values = {
path: String,
}
connect() {
this.createObserver();
}
createObserver() {
let observer;
let options = {
// https://github.com/w3c/IntersectionObserver/issues/124#issuecomment-476026505
threshold: [0, 1.0]
};
observer = new IntersectionObserver(entries => this.handleIntersect(entries), options);
observer.observe(this.entryTarget);
}
handleIntersect(entries) {
entries.forEach(entry => {
if (entry.isIntersecting) {
// https://github.com/turbolinks/turbolinks/issues/219#issuecomment-376973429
history.replaceState(history.state, "", this.pathValue);
}
});
}
}
- Update that markup in
app/views/posts/show.html.erb
<p id="notice"><%= notice %></p>
<%= turbo_frame_tag dom_id(@post) do %>
<div data-controller="infinite-scroll" data-infinite-scroll-path-value="<%= post_path(@post) %>" data-infinite-scroll-target="entry">
<p>
<strong>Title:</strong>
<%= @post.title %>
</p>
<p>
<strong>Body:</strong>
<%= @post.body %>
</p>
<%= link_to 'Edit', edit_post_path(@post), data: { turbo_frame: "_top" } %> |
<%= link_to 'Back', posts_path, data: { turbo_frame: "_top" } %>
</div>
<%= turbo_frame_tag dom_id(@next_post), loading: :lazy, src: post_path(@next_post) if @next_post.present? %>
<% end %>
What's going on?
- We use the Intersection Observer API to determine when the post has entered the viewport.
- We set the
threshold
to[0, 1.0]
to account for elements that are taller than the viewport. This ensures thatentry.isIntersecting
will returntrue
.
- We set the
- When
entry.isIntersecting
returnstrue
, we use History.replaceState() to update the URL with the path for the post that entered the viewport.- The value for the path is stored in the
data-infinite-scroll-path-value
attribute. - We add
history.state
as the first argument tohistory.replaceState
to account for an issue with Turbolinks.
- The value for the path is stored in the
Step 7: Add a loading state and styles (optional)
- Add Bootstrap via CDN to
app/views/layouts/application.html.erb
<!DOCTYPE html>
<html>
<head>
<title>RailsInfiniteScrollPosts</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag 'https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta2/dist/css/bootstrap.min.css', integrity: 'sha384-BmbxuPwQa2lc/FVzBcNJ7UAyJxM6wuqIj61tLrc4wSX0szH/Ev+nYRRuWlolflfl', crossorigin: 'anonymous' %>
<%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
<%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
</head>
<body>
<div class="container">
<%= yield %>
</div>
</body>
</html>
- Update markup and add a loader to
app/views/posts/show.html.erb
<p id="notice"><%= notice %></p>
<%= turbo_frame_tag dom_id(@post) do %>
<article data-controller="infinite-scroll" data-infinite-scroll-path-value="<%= post_path(@post) %>" data-infinite-scroll-target="entry">
<h2><%= @post.title %></h2>
<p><%= @post.body %></p>
<%= link_to 'Edit', edit_post_path(@post), data: { turbo_frame: "_top" } %> |
<%= link_to 'Back', posts_path, data: { turbo_frame: "_top" } %>
</article>
<%= turbo_frame_tag dom_id(@next_post), loading: :lazy, src: post_path(@next_post) do %>
<div class="d-flex justify-content-center">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<% end if @next_post.present? %>
<% end %>
Did you like this post? Follow me on Twitter to get even more tips.
Top comments (1)
Thanks for sharing. I intend to use this to lazy load a chat history...
NB: Your numbering on the steps is off.