One of the reasons we love Ruby on Rails is that even though there is often the “rails way” to solve a problem, you can still take advantage of other great approaches.
Infinite scrolling is one such problem that has a variety of implementations, but none that quite worked the way we wanted it to. Infinite scrolling is the design pattern you often see used for long lists of items. Rather than implement traditional pagination, new items are loaded at the end of the list—often automatically when the user reaches the end, or via a “load more” button.
Here’s how we would do it with two different Hotwire approaches:
- Turbo Streams (autoload content while scrolling)
- Turbo Frames (“load more” button)
We’ve written in the past on the use cases and differences of turbo streams and turbo frames. We won’t talk about the pros and cons of the infinite scroll vs. pagination patterns in this post, but you will learn to implement different approaches with Hotwire.
Initial setup
This tutorial assumes some basic rails knowledge. You’ll need to get an app up and running. Then, scaffold some comments and posts, and add some seeds:
# db/seeds.rb 100.times do Post.create(title: Faker::Music.band, image: Faker::Avatar.image) Comment.create(body: Faker::Quote.famous_last_words) end
bundle add faker bundle add pagy rails g scaffold comment body:text --no-helper --no-assets --no-controller-specs --no-view-specs --no-test-framework --no-jbuilder rails g scaffold post title image --no-helper --no-assets --no-controller-specs --no-view-specs --no-test-framework --no-jbuilder rails db:migrate rails db:seed
We’re using faker to generate some placeholder data, and Pagy for pagination.
Next, include “gem pagy” to the helper:
# app/helpers/application_helper.rb include Pagy::Frontend
And again to the controller:
Turbo Streams: Click to load more
For the first approach, we’ll implement a classic “load more” button. This lets the user to manually append the next page to the end of the list. Enable Pagy countless; it is a recommended extra for that makes infinite scrolling easier in Pagy.
### config/initializers/pagy.rb require 'pagy/extras/countless'
In the comments view, add a “button_to” to the next page using Pagy with the method POST.
Turbo Frames: Auto-loading Infinite scroll
Now that we can load in the new items manually, it’s time to make them automatically load in when the user reaches the end of the list. By default, a lazy_loading turbo_frame only loads when it becomes visible in the DOM, as I’ve shown in my example on loading frames in a dropdown.
Knowing this, we don’t need to write any Javascript to observe whether the bottom of the page has become visible on screen! The built in lazy load functionality handles it for us. All we have to do is load a turbo_frame with the next page when we scroll to the bottom of current page. Let’s do it.
First, add pagination to the controller and set it up to respond with only the scrollable area when the incoming request has a pagination param.
# app/controllers/posts_controller.rb def index @pagy, @posts = pagy_countless(Post.order(created_at: :desc), items: 5) render "scrollable_list" if params[:page] end
Next, create a turbo frame with a unique ID. We’re generating an ID based on the ID of the next page. Set loading to “lazy” and target to “_top”. You can also experiment with the autoscroll property, but it doesn’t change the UX much in this use case.
Frames + Streams: Perfected Auto-loading infinite scroll
If we combine the techniques thus far, we end up with code that looks a bit like this:
Bonus: Cursor-based pagination without a pagination gem
One thing that often trips people up is paginating lists where the number of items can change while paginating. For example, let’s assume you have the following set of posts:
[A, B, C, D, E, F]
You paginate at 3 per page, set up a ‘load more’ button interaction, and request the first page. This leaves the first page as:
[A, B, C]
But what happens if you delete B though a turbo stream request? Your page now shows:
[A, C]
This updates the database as well, and it now contains:
[A, C, D, E, F]
While the first page shows 2 results instead of 3, the second page is using the newly updated database. Now when you request the next page of results, it it thinks D is now part of page 1 and only appends the next three results (in this case, there are only two more):
[A, C, E, F]
See the problem? We have an initial batch of results based on the original database, plus our deletion, and a second set of results based on the updated database. This leaves our page missing the item in the middle.
With cursor based paging, you say “give me the item after ‘C'” rather than a particular page number. Given this set we would get the correct result of
[A, C, D, E, F]
Ruby Implementation
Any unique key that you can “order by” can work as a cursor. In this example I use the ID since it’s the most convenient. The next item will always have an ID greater than the cursor, so even if the cursor item is removed, the next item is returned correctly. As you can see from these requirements, sorting by properties like date require a bit more thought than simple pagination, so its best to only use this approach when needed.
# app/controllers/posts_controller.rb POSTS_PER_PAGE = 5 def index @cursor = (params[:cursor] || "0").to_i @posts = Post.all.where("id > ?", @cursor).order(:id).take(POSTS_PER_PAGE) @next_cursor = @posts.last&.id @more_pages = @next_cursor.present? && @posts.count == POSTS_PER_PAGE render "scrollable_list" if params[:cursor] end
Theres an edge case in the above where the last page contains a full page, but all that happens in the final load is a page with no items. In this instance that isn’t a big deal, but it is something to be aware of.
Turbocharged pagination
Hopefully this has helped you better understand some approaches to pagination, taking full advantage of Hotwire’s Turbo streams and frames. If you haven’t already, check out our other Rails, Hotwire, and Turbo articles on our blog.