Basecamp recently released Hotwire which includes Turbo. Using Turbo, we can quickly paginate a long list of items that can be asynchronously loaded without any javascript.
Let's say we have an app that lists credit card transactions. This list can become very long which we wouldn't want to load upfront due to the performance impact. To avoid loading all the transactions upon page load but, still allow our users to see all transactions, we will only render the first few transactions initially and then show a "Load More" link. The "Load More" link will grab more transactions, append them to the existing list of transactions, and then update our "Load More" link to point to a new URL for the next collection of transactions.
(Note: We are going to use Geared Pagination for pagination but this solution will work with any pagination solution so long as you know the current and next page numbers.)
Here's where our app currently stands:
# app/views/transactions_controller.rb
class TransactionsController < ApplicationController
def index
set_page_and_extract_portion_from(Transaction.all)
respond_to do |format|
format.html
format.js
end
end
end
<%# app/views/transactions/index.html.erb %>
<p id="notice"><%= notice %></p>
<div>
<%= link_to 'New Transaction', new_transaction_path %>
</div>
<h1>Transactions</h1>
<div>
<ul><%= render @page.records %></ul>
<% unless @page.last? %>
<%= link_to "Load More", transactions_path(page: @page.next_param), remote: true, id: "load-more" %>
<% end %>
</div>
<%# app/views/transactions/index.js.erb %>
document.querySelector("ul").insertAdjacentHTML("beforeend", "<%= j render partial: @page.records, as: :transaction %>");
<% if @page.last? %>
document.querySelector("#load-more").remove();
<% else %>
document.querySelector("#load-more").href = "<%= transactions_path(page: @page.next_param) %>";
<% end %>
This solution works fine. Code is pretty concise and explicit, but we are using a separate js.erb view which we now also have to maintain along with the html.erb view for the index action.
Using Turbo we can remove app/views/transactions/index.js.erb
and the respond_to
block in the controller since we will only be responding to HTML.
In app/views/transactions/index.html.erb
we will make the following changes inside the ul
element:
<ul>
<%= turbo_frame_tag "transactions-#{@page.number}" do %>
<%= render @page.records %>
<%= turbo_frame_tag "transactions-#{@page.next_param}" do %>
<% unless @page.last? %>
<%= link_to "Load More", transactions_path(page: @page.next_param) %>
<% end %>
<% end %>
<% end %>
</ul>
If we reload our page and try it out, it works!
The trick to making this work is nesting the turbo-frames. On this first page, our outer frame has the id of transactions-1
and the inner one has the id of transactions-2
. When we click the "Load More" link the server is going to respond with the inner HTML of the body
element of what page 2 looks like. In that case, our outer frame has the id of transactions-2
and our inner one has the id of transactions-3
. Once we get that response, Turbo will replace any frames that occur on the initial page and on the one sent back.
Since the transactions-1
turbo-frame
doesn't appear on the second page, nothing happens to it. But there is a transactions-2
turbo-frame
on both our first and second pages so that frame is replaced. That new frame will render the Transaction
's on the second page right below the first page's transactions, and remove the first page's "Load More" and replace it with a "Load More" link to the third page. Our HTML would end up looking something like:
<ul>
<%= turbo_frame_tag "transactions-1" do %>
<%= render @page.records %> <%# page 1 records %>
<%= turbo_frame_tag "transactions-2" do %>
<%= render @page.records %> <%# page 2 records %>
<%= turbo_frame_tag "transactions-3" do %>
<%= link_to "Load More", transactions_path(page: 3) %>
<% end %>
<% end %>
<% end %>
</ul>
Magic 🪄!
(Note: Styling could become an issue with this solution with elements being nested inside turbo-frame tags but so far I haven't run into any issues.)
Top comments (6)
Forem uses a lot of similar techniques as Hotwire and Turbo. Inspired by other ideas passed around by Basecamp, but we wound up building in our own approaches. I wonder if there would ever be a day where we shift towards adopting more of this.
We already use some Stimulus, but it's kind of all-or-nothing in terms of really doing this the right way.
I wonder if this is working without
format.turbo_stream
under respond_to block, or maybe I am missing some knowledge on this?You don't need the turbo_stream format because you are clicking links inside a turbo_frame and turbo knows when that happens to looks for a matching id on the requested html page.
turbo.hotwired.dev/reference/frame...
It would be great to have a follow up with this article on how to integrate it with turbo stream to target on the paginated records from the new page.
Looks good! But this break broadcasts :/
How do you mean?