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
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 } %>
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>
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 %>
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 %>
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"
})
}
}
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! ๐
Top comments (1)
Thank you for this article, I've been looking around for a while for something to avoid having to do a POST request on an index action :)
Small typo I noticed in the last paragraph: I assume it should be
getTurboStream
and notgetTurboSteam