DEV Community

loading...
Cover image for Create an infinite scrolling blog roll in Rails with Hotwire

Create an infinite scrolling blog roll in Rails with Hotwire

stevepolitodesign profile image Steve Polito Originally published at stevepolito.design Updated on ・4 min 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
Enter fullscreen mode Exit fullscreen mode
  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
Enter fullscreen mode Exit fullscreen mode
  1. Include Module in Post Model
class Post < ApplicationRecord
  include Navigable
end
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 %>
Enter fullscreen mode Exit fullscreen mode

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

}
Enter fullscreen mode Exit fullscreen mode
  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 %>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode
  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 %>
Enter fullscreen mode Exit fullscreen mode

Discussion (1)

pic
Editor guide
Collapse
motdde profile image
Oluwaseun Oyebade • Edited

Thanks for sharing. I intend to use this to lazy load a chat history...

NB: Your numbering on the steps is off.