Load More Pagination in Rails with Hotwire Turbo Streams
Tags: rails, ruby, hotwire, bootstrap
Published: 2022-02-04 13:58:15

[Hotwire](https://hotwired.dev) (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](https://turbo.hotwired.dev/handbook/frames) and [Turbo Streams](https://turbo.hotwired.dev/handbook/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](https://github.com/ddnexus/pagy) or [kaminari](kaminari), instead implement this functionality just using `limit` and `offset`.

---

**user_controller.rb**
```ruby
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**
```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](https://getbootstrap.com/docs/5.1/components/card/#grid-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**
```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**
```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](https://getbootstrap.com/docs/5.1/components/buttons/#block-buttons), 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](https://stimulus.hotwired.dev/reference/actions) upon clicking the button, more on this below.

--- 

**index.turbo_stream.erb**
```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. :astonished:

_Currently_, Hotwire _only_ sends the `text/vnd.turbo-stream.html` header for `POST` requests, however my load button is making a `GET` request. :weary:

_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](https://stimulus.hotwired.dev/reference/controllers) to append the `text/vnd.turbo-stream.html` header so that Hotwire handles it as a Turbo Stream request. :sunglasses:

---

**turbo_controller.js**
```javascript
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_! :clap:

Big thanks to [Konnor Rogers](https://twitter.com/rogerskonnor), [Stephen Margheim](https://twitter.com/fractaledmind), [Marco Roth](https://twitter.com/marcoroth_), [Josh LeBlanc](https://github.com/joshleblanc), [Sean Doyle](https://github.com/seanpdoyle) for helping debug this `GET` vs `POST` hiccup, the Rails community is awesome! :raised_hands: