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.
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:
We're using faker to generate some placeholder data, and Pagy for pagination.
Next, include "gem pagy" to the helper:
And again to the controller:
Now that everything is set up, we can move on to each infinite scrolling approach.
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.
In the comments view, add a "button_to" to the next page using Pagy with the method POST.
Next, add pagination to the controller and configure it to respond to POST requests for turbo_streams:
Allow index_path to respond to POST, not only GET requests:
Finally, add turbo stream's append and update methods. Append will add the next page's comments to the current comments render, and update will adjust the "next/load more" button to use a new URL now that the current page includes two pages of results.
Here are the results:
You can watch a full, detailed walkthrough of this approach at Ruby on Rails #67 Streams: Infinite Scroll Pagination, and view the full source on GitHub at Rails 7 app Turbo Streams pagination.
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.
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.
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.
Now in the posts view, render the posts collection and the next_page view that you just set up.
Finally, set up the scrollable list by setting the turbo frame tag to the ID for the current page. Then, render the list and render the new frame for the next page below it.
The result looks like this:
You can watch a full, detailed walkthrough of this approach at Ruby on Rails #68 Frames: Infinite Scroll Pagination, and view the code on GitHub at Rails 7 app Turbo Frames pagination.
As an aside, here are a few of the pagy helpers to make working with it easier:
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:
Voila! Smooth, auto-loading infinite scrolling on Rails with Hotwire.
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:
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]
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.
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.
As with the earlier examples, we can use the lazy loading trick to automatically start the next request. This time based on the cursor rather than the page.
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.