Filtering Data Tables with Turbo Frame and Stimulus
This one is about Hotwire. We’ll add filtering to some books using Turbo Frames and Stimulus.
Note: If you’ve been building with Hotwire for a while, there is likely nothing new here for you. If you haven’t yet tried Hotwire, this will be a useful read and help you form a mental model of the core concepts. This post is closely related to a previous post where I implemented responsive search. And lastly, yes Turbo 8 will simplify certain things, but it’s still worth understanding how to use Turbo Frames this way.
Here’s a screenshot of the application with filtering:
We’ll use a reading/book notes tracking application. Let’s filter a list books a user has read by genre and something I call ‘reading mode’.
Sidenote: ‘reading mode’ is something I made up that I personally use when I track books I’ve read. I used to be a ‘finisher’ and felt I had to finish any book I start. I’ve longed changed my perspective on that. Most books are longer than they need to be or simply not worth my time at a given point in my life. So now I consume books in different ‘modes’ and track how I read them and when/why I stopped reading. My ‘reading_mode’ values are ‘read’, ‘reread’, ‘skipped’, ‘parsed’. ‘skipped’ is when I read 20% of the book to give it a fair shot and then say ‘no thank you’. ‘parse’ is when I go through the table of content and pick and choose what I read. Most programming books fall in the ‘parsed’ mode. Although last year I did read Metaprogramming Ruby 2 front to back and that was worthwhile. Books like On Writing and On Writing Well are marked ‘reread’ . Anyway, this is turning into a whole post but that’d be for a different newsletter. Let me get back to building filtering with Hotwire.
Back to filtering books. If we did nothing, how would filtering work? Well, selecting a ‘genre’ or ‘reading_mode’ in the form will cause a request to be submitted to the server. In a Rails 7 application, Turbo Drive will swap out the <body>
from the response.
So no full page reload but we can do even better using Turbo Frames. Ideally we want to leave the <form>
alone and only update the part of the page that has the filtered results. Here’s how we do that:
Data Table Filtering with Turbo Frame and Stimulus
We modify the default Book Controller’s index
view to add filtering with the following steps:
- Add the filters dropdown at the top of the
index
page in aform
. - Wrap the filtered books results in a
turbo-frame
element and give it a uniqueid
, as in<%= turbo_frame_tag "results" %>
below. - Target that
turbo-frame
element from the filterform
element by addingdata-turbo-frame = "results"
attribute to theform
element. - To update the browser url each time the form is submitted, add the
data-turbo-action = "advance"
attribute to theform
element as well. This tells the browser to append the new url to the browser’s history and allows the user to bookmark a url and use the browser back button. - Add a Stimulus controller with
data-controller
to auto submit the form oninput
event usingrequestSubmit()
. To allow the user to filter/search without having to click a button.
Here’s what the index.html.erb
view looks like with those changes:
<%= form_with(url: books_path, method: :get,
data: {
turbo_frame: "search_results",
turbo_action: "advance",
controller: "search-form",
action: "input->search-form#submit"
}) do |form| %>
<div class="filters">
<div>
<%= form.select :genre, @genres,
include_blank: "Any Genre", selected: params[:genre] %>
</div>
<div>
<%= form.select :reading_mode, @reading_modes,
include_blank: "Any Mode", selected: params[:reading_mode] %>
</div>
</div>
<% end %>
<%= turbo_frame_tag "search_results" do %>
<p><%= @books.count %> books found: </p>
<div class="books">
<table>
<thead>
<tr>
<th>Title</th>
<th>Author</th>
<th>Genre</th>
<th>Format</th>
<th>Reading Mode</th>
<th>Date Read</th>
</tr>
</thead>
<tbody>
<%= render partial: 'book', collection: @books %>
</tbody>
</table>
</div>
<% end %>
(I’m reusing the Stimulus controller from building search hence the search-form
and search_results
. I should’ve called the Stimulus controller form
and the turbo-frame results
.)
The actual filtering happens as an ActiveRecord
query. Not doing anything fancy there, as that’s relevant for this Turbo Frame demonstration. Just something like Book.with_genre(params[:genre]).with_reading_mode(params[:reading_mode])
in a filter
method on the model, which the controller calls.
def index
@genres = Book.pluck(:genre).uniq
@reading_modes = Book.pluck(:reading_mode).uniq
@books = Book.filter(params)
end
The form
makes a GET
request to the BooksController#index
action. The index
action returns the same index
template above, with the @books
instance variable set to the filtered list of books as specified by params
.
The response has a turbo-frame
tag with the matching id
from the request, so the results are replaced into the turbo-frame
element and nothing else on the page changes (the response also has HTML for the form
which is ignored, as it’s outside the turbo-frame
element.)
A Pattern is Beginning to Emerge
If you remember the previous post on building search with Hotwire, you may notice that overall implementing filtering is very similar to building search. In fact, it’s exactly the same.
The core pattern for building searching or filtering is this: we can target a ‘results’ turbo-frame
from a form
element by adding data-turbo-frame
to the form
. When the form
is submitted, instead of making a request to the server, it adds a src
attribute to the target turbo-frame
. The turbo-frame
then fires a request and the response updates just the turbo-frame
with the results, leaving the form
and rest of the page alone.
Another parallel pattern is about using Stimulus to auto submit the form so that the user doesn’t have to click a button. We get a more snappy experience with searching/filtering/sorting. Note that I used the same Stimulus controller on the filter form as the search form. Stimulus controllers are reusable.
That is all there is to it.
Of course we can filter by other fields, add sorting by all of the fields. We could add a range slider to the ‘date_read’ field. We can incorporate gems like pg_search
or meilisearch
to query the results. And we can add pagination to the results.
I resisted the urge to include all that in this post. My goal is to make the reader (and me) feel like they really ‘get’ the key concepts (and not feel bogged down with extraneous details). We can tweak and build more features on top of that understanding (I may cover sorting in a later post).
Subscribe for more Ruby, Rails, and Hotwire
Short posts, clear explanations