18 April 2021
I recently added hotwire to my pet project snippetsafe and wanted to see if I could replace the infinite scroll functionalities for the snippet feeds without using any custom javascript at all. I'm happy to say that I succeeded and in this post I'm going to share how.
As I built this for snippetsafe I'm going to use the Snippet
model as an example. For the purposes of this post, a Snippet
has two attributes - title
, a string corresponding to the title of the snippet and body
, text corresponding to the content of the snippet.
The plan is to have an initial route we can hit that will display all of our snippets, from the view rendered by this action we will use the new turbo_frame_tag
to trigger a request for our snippets (in batches of 8) once the initial page has loaded, much like modern Single Page Apps. The HTML response containing our snippets will automatically be inserted onto our index page by the matching turbo_frame_tag
present on the page.
At the very bottom of each response will be another turbo_frame_tag
with a unique identifier that pertains to the next page of snippets that need to be loaded. We'll use the loading: :lazy
option to ensure that the request for the content of this tag is only triggered once the tag itself is visible on the viewport - i.e. once you have scrolled to the bottom of the page.
We're going to need to add the hotwire-rails gem to our rails application before we can get down to business:
# terminal
bundle add hotwire-rails
bundle install
rails hotwire:install
Now we've got everything installed, let's get cracking!
Firstly we need to create the index route to hit:
# config/routes.rb
Rails.application.routes.draw do
resources :snippets, only: :index
end
Then create the controller and add the index action:
# app/controllers/snippets_controller.rb
class SnippetsController < ApplicationController
def index; end
end
Finally we add the corresponding view:
<%# app/views/snippets/index.html.erb %>
<%= turbo_frame_tag "snippets_1", src: shared_snippets_path(page: 1) %>
The first two code blocks above are standard rails but it's in the view template where the magic is happening. The turbo_frame_tag
is being given a unique id of snippets_1
(which is essentially saying "first page of snippets"), this means that if Turbo receives any matching <turbo-frame>
in a response from the server it will swap out the content of the tag we have defined on the index page with the content of the tag it receives in the response.
The one final piece in the puzzle is the src: shared_snippets_path(page: 1)
. We'll define the path shortly, but the important point to note here is that if you provide a src
attribute to a <turbo-frame>
, Turbo will automatically make a request to the src
value when the page is loaded. This means that as soon as our index page is loaded a request is made to shared_snippets_path(page: 1)
and the response from that endpoint will be inserted into our index page as long as the returned <turbo-frame>
id matches the one already on the page.
On snippetsafe the list of snippets is used in many different places. As a result the route to obtain them is under the shared
namespace. Let's create it!
# config/routes.rb
Rails.application.routes.draw do
resources :snippets, only: :index
namespace :shared do
resources :snippets, only: :index
end
end
Then create the corresponding namespaced controller and action:
# app/controllers/shared/snippets_controller.rb
class Shared::SnippetsController < ApplicationController
PER_PAGE = 8.freeze
def index
@page = params[:page] ? params[:page].to_i : 1
@offset = (@page - 1) * PER_PAGE
@snippets = Snippet.offset(@offset).limit(PER_PAGE)
@next_page = @page + 1 if @snippets.size == PER_PAGE
end
end
Finally create the HTML template for this action:
<%# app/views/shared/snippets.html.erb %>
<%= turbo_frame_tag "snippets_#{@page}" do %>
<% @snippets.each do |snippet| %>
<div>
<h1><%= snippet.title %></h1>
<p><%= snippet.body %></p>
</div>
<% end %>
<% if @next_page %>
<%= turbo_frame_tag "snippets_#{@next_page}", loading: :lazy, src: shared_snippets_path(page: @next_page) %>
<% end %>
<% end %>
As explained above, as soon as we load our index page at /snippets
Turbo will automatically make a request to this newly created endpoint shared/snippets?page=1
thanks to the <turbo-frame>
with the src
attribute set to shared_snippets_path(page: 1)
. In the Shared::SnippetsController#index
action we grab the "page" from the params (or set it if it isn't passed), then we calculate the snippets we want to return through a combination of the ActiveRecord offset and limit functions. We finally determine whether there are further snippets to return by checking whether there are fewer snippets returned from the database than the amount we are aiming to show per page (8 - defined by the PER_PAGE
constant). If there are more snippet to show we set @per_page
as @page + 1
. These values are then used in the view.
In the view template, @page
is interpolated into the turbo_frame_tag
id so that it matches the id of the <turbo-frame>
that initiated the request (for the first iteration this is always "snippets_1"
). Within this <turbo-frame>
we then render the HTML for each snippet retrieved fromt he database. Finally, still within the same turbo_frame_tag
and only if there is another page (defined by the @next_page
variable) we render another <turbo-frame>
with an id of "snippets_#{@next_page}"
(which will evaluate to "snippets_2"
for the first iteration). Again we define the src
for this frame but passing the @next_page
as a param. The difference here is that we are also adding loading: :lazy
which will mean that the request for this <tubo-frame>
is only made once that frame is visible on the page. Once this happens the cycle starts again but with the page numbers incremented until there are no more snippets to view.
You now have the basics of an infinitely scrolling list view but there are still a few things you can do to improve it. How about adding an indicator to your <turbo-frame>
tags to indicate that you're app is making a request to fetch more snippets? Anything within a <turbo-frame>
tag is automatically replaced with the corresponding response, so something like this would work nicely:
<%# app/views/snippets/index.html.erb %>
<%= turbo_frame_tag "snippets_1", src: shared_snippets_path(page: 1) do %>
<h3>Loading...</h3>
<% end %>
I don't want to give you to much guidance in this department as part of the fun of building things is reading the docs and tinkering to see what's possible. Just have fun with it!