DEV Community

Cover image for Load More Pagination in Rails with Hotwire Turbo Streams
Dale Zak
Dale Zak

Posted on

Load More Pagination in Rails with Hotwire Turbo Streams

Hotwire (aka HTML Over The Wire) is a great addition to Rails, allowing you to build modern web applications without using much JavaScript. However if you are just getting started, it can be tough to get your head around how Turbo Frames and Turbo Streams work.

In a recent project I have a grid of cards and wanted a Load More button to append the next set of cards to the grid. There are a few infinite scroll tutorials using Hotwire, but I ran into a few hiccups trying to make those work for my case.

For pagination I tend not to use gems like pagy or kaminari, instead implement this functionality just using limit and offset.


user_controller.rb

def index
  authorize! :index, User
  @search = params.fetch(:search, nil)
  @offset = params.fetch(:offset, 0).to_i
  @limit = [params.fetch(:limit, 12).to_i, 48].min
  query = User.for_search(@search)
  @users = query.limit(@limit).offset(@offset).order(created_at: :asc).all
  @users_count = query.count(:all) if request.format.html?
  respond_to do |format|
    format.html { }
    format.json { }
    format.turbo_stream { }
  end
end
Enter fullscreen mode Exit fullscreen mode

In the controller I'm fetching @search, @offset, and @limit from the params, so I can use them in the views. The important line is format.turbo_stream { } so you can respond to turbo_steam requests.


index.html.erb

<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 row-cols-xl-4 g-4">
  <%= render partial: '/users/user', collection: @users, as: :user %>
  <div id="user_cards"></div>
</div>
<%= render partial: "/partials/load_more", locals: { count: @users_count, offset: @offset + @limit } %>
Enter fullscreen mode Exit fullscreen mode

In the view, I'm using Bootstrap 5 Grid of Cards to handle responsive list of cards. Two important aspects in the view. One, the <div id="user_cards"></div> placeholder inside of the grid, we'll use Turbo Stream to replace this with the next set of cards. Two, rendering the load more partial passing the count and offset.


_user.html.erb

<div class="col">
  <div class="card h-100">
    <div class="card-body">
      <h5 class="card-title"><%= link_to user.name, user, data: { turbo_frame: "_top" } %></h5>
      <p class="card-text"><%= user.title %></p>
    </div>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

The user partial is just plain Bootstrap, although I opted to make all cards expand to fill their current row.


_load_more.html.erb

<% count ||= 0 %>
<% offset ||= 0 %>
<% url ||= url_for(request.params.merge(offset: offset)) %>
<% if count > offset %>
<div id="load_more" data-controller="turbo">
  <div class="d-grid my-4">
    <%= link_to "Load More", url, class: "btn btn-block btn-outline-secondary", data: { action: "click->turbo#getTurboSteam" } %>
  </div>
</div>
<% end %>
Enter fullscreen mode Exit fullscreen mode

The load more partial has a full width Bootstrap button, but has three important elements. One, I default the url to the current URL updating the offset. So if the current path is users and the offset is 12, then the url will be users?offset=12 which will load the next set. Two, the if count > offset check will hide the load more button when there are no load results. Three, we're attaching a Stimulus action upon clicking the button, more on this below.


index.turbo_stream.erb

<%= turbo_stream.replace "user_cards" do %>
  <%= render partial: '/users/user', collection: @users, as: :user %>
  <div id="user_cards"></div>
<% end %>
<%= turbo_stream.replace "load_more" do %>
  <%= render partial: "/partials/load_more", locals: { total: @users.length, count: @users_count, offset: @offset + @limit } %>
<% end %>
Enter fullscreen mode Exit fullscreen mode

In the Turbo Stream response, for the user_cards element we replace it with next set of cards plus the <div id="user_cards"></div> placeholder, so it works for the next offset. For the load_more element, we replace the partial with the new offset parameter.


At this point, I thought everything should work ok, however ran into one major hiccup. 😲

Currently, Hotwire only sends the text/vnd.turbo-stream.html header for POST requests, however my load button is making a GET request. 😩

One possible solution, is to add data: { turbo_method: :post } to the link_to that will turn it into a POST which triggers Hotwire to do its Turbo Stream magic. Another possible solution, is to change the link_to to a button_to which renders a form so the request by default will be a POST.

However I didn't want to go these paths, since my index route doesn't respond to POST requests. To make this work, I'd need to add post "search" route to all my resources, which would change the URL from users?offset=12 to users/search?offset=12. Not a terrible thing, but still preferred to just use the index route.

So rather than using button_to or data: { turbo_method: :post }, I instead used a Stimulus controller to append the text/vnd.turbo-stream.html header so that Hotwire handles it as a Turbo Stream request. 😎


turbo_controller.js

import { Controller } from "@hotwired/stimulus"
import { get, post } from '@rails/request.js'
export default class extends Controller {
  getTurboSteam(event) {
    event.preventDefault()
    get(event.target.href, {
      contentType: "text/vnd.turbo-stream.html",
      responseKind: "turbo-stream"
    })
  }
  postTurboSteam(event) {
    event.preventDefault()
    post(event.target.href, {
      contentType: "text/vnd.turbo-stream.html",
      responseKind: "turbo-stream"
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

In the Stimulus controller, the getTurboSteam will make a GET request with text/vnd.turbo-stream.html header, and the postTurboSteam makes POST request with text/vnd.turbo-stream.html header. In this case, we're using getTurboSteam but postTurboSteam is there if I need it later.


That's it, everything should be (hot)wired up! 👏

Big thanks to Konnor Rogers, Stephen Margheim, Marco Roth, Josh LeBlanc, Sean Doyle for helping debug this GET vs POST hiccup, the Rails community is awesome! 🙌

Discussion (0)