Create an infinite scrolling blog roll in Rails with Hotwire

Posted on March 21, 2021 - 6 Minute Read

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.

demo

Step 1: Application Set-Up

  1. rails new rails-infinite-scroll-posts -d-postgresql --webpacker=stimulus
  2. rails db:setup
  3. bundle add turbo-rails
  4. rails turbo:install

Step 2: Create Post Scaffold

  1. rails g scaffold post title body:text
  2. rails db:migrate

Step 3: Add Seed Data

  1. bundle add faker -g=development
  2. Update db/seeds.rb
10.times do |i|
    Post.create(title: "Post #{i+1}", body: Faker::Lorem.paragraph(sentence_count: 500))
end
  1. rails db:seed

Step 4. Create the ability to navigate between Posts

  1. 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
  1. Include Module in Post Model
class Post < ApplicationRecord
  include Navigable
end

Note: We could just add the next and previous methods directly in the Post model, but using a Module means we can use these methods in future models.

  1. Update PostsController
class PostsController < ApplicationController
    ...
    def show
        @next_post = @post.next
    end
    ...
end

Step 5: Use Turbo Frames to lazy-load the next Post

  1. 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 an ID of dom_id(@post). For example, the dom_id(@post) call will evaluate to id="post_1"if the Post's ID is 1. This keeps the ID's unique.
  • We add another turbo_frame_tag within the outer turbo_frame_tag to lazy-load the next post. We can look for the next post thanks to our Navigable module that we created earlier.

    • The loading attribute ensures that the frame will only load once it appears in the viewport.
  • 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 the edit and index views.

    • This is only because those links are nested in the outermost turbo_frame_tag.

Step 6: Use Stimulus to update the path as new posts are loaded

  1. 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);
      }
    });
  }
 
}
  1. 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.

  • When entry.isIntersecting returns true, 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 to history.replaceState to account for an issue with Turbolinks.

Step 7: Add a loading state and styles (optional)

  1. 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>
  1. 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 %>
Categorized In:

Learn Ruby on Rails For Free


I created Rails Code Along to help teach you how to build, test, troubleshoot, and launch a REAL production Rails application. This course is free, and does not require you to sign up.

Learn Ruby on Rails For Free

Let's Connect