Searching and filtering with Turbo 8
27 Mar 2024Today we are going to build on top of an article I published last week to expand our knowledge of Turbo 8’s page refreshes and, as a treat, we will write some beautiful Ruby code along the way.
In this article we are going to add searching and filtering to an existing sortable table interface. We will use Turbo 8 page refreshing, a tiny Stimulus controller, and regular old Rails code to build a solution that maintains the user’s page state and scroll position while they search and filter the results shown in a table, no Turbo Frames or Streams required.
When we are finished, the project will look like this:
This article assumes you are comfortable writing Rails code. You do not need any prior experience with Turbo or Stimulus to follow along. Reading the previous article first will help clarify some of the concepts that we move over quickly in this article. You really should read the previous article before you read this one, but I’m not the boss, live life on the edge if you want.
When you’re ready, dive in.
Setup
This article picks up where the previous article left off and you can pick up your code where you left it at the end of that article or clone this GitHub repo as a starting point.
If you choose to clone the repo instead of working from the previous project, you will need to work from the finished-project
branch of that repo. After you clone the repo to your local machine, start in your terminal:
If you did not follow along with the previous article, you will also need to populate some data in the Players
table. Run this simple script from the Rails console:
Finally, whether you followed along with the previous article or not, head to app/views/players/index.html.erb
and update it to fix an issue that causes the table to resize in a jarring way when the list of players changes:
Here we set fixed widths on the table columns and made the table wider by default. These changes are not core to the function of the project, but they make it nicer to look at as we work.
If all has gone well you can start the rails server with bin/dev
and go to http://localhost:3000/players and see that you have a list of players that can be sorted by clicking on the column headers.
Searching
Our goal is to be able to search players by name and to filter the list of players by their Team
while maintaining any existing sorting applied to the table.
We will take it one step at a time, starting by adding a simple search form to the players index that queries the Players
table by player name. Add the search form to the players index at app/views/players/index.html.erb
, just above the <table>
element:
This is a standard Rails form. Type in a name, click search, and a GET
request fires off to /players
. We will make it fancier soon, but first let’s make it work. This form submission will just reload the index page without changing the list of players because we are not doing anything with the form submission on the server. Let’s change that now.
Go to app/controllers/players/players_controller.rb
and update the index
action:
This code is clunky, but it gets the job done. It will be beautiful Ruby code before we finish this article, but it works and working is good enough for now.
Try another search on the players index page and you will see that the list of players filters down to match your search term. We are not using Turbo yet, but we have a working search form. Before we add page refreshing, clicking to submit a search form is very 2010s, results should update as the user types, right?
Let’s solve that next with a small Stimulus controller.
Automatically submit the search form
The Stimulus controller’s job will be to listen to changes to the form inputs (for now just the name field) and submit the form in response to those changes. This allows us to build a type-as-you-search experience instead of asking the user to click submit and see what comes back.
Get started by generating a new Stimulus controller from your terminal:
And then fill in the new controller at app/javascript/controllers/autosubmit_controller.js
:
The important line in this controller is this.element.requestSubmit()
. this.element
is a reference to the DOM element that the controller is attached to. In this project, this.element
will be the <form>
we added in the last section. Calling requestSubmit
on the form element, naturally, submits it.
A very important note here is that we are calling requestSubmit
on the form element, not submit
. submit
is an entirely different thing that submits the form but does not trigger the form’s submit event. Turbo listens for the form’s submit event to process the request, and using submit
will completely bypass Turbo. There is a long, worthwhile discussion on the Hotwire forums about requestSubmit
and submit
if you want to get into the weeds.
The clearTimeout
and setTimeout
calls are a simple implementation of a debounce function so that requestSubmit
does not fire every single time the user types a key. Instead, this.element.requestSubmit()
will not fire until 250ms after the user stops typing. This slight delay avoids hammering the server with a request for every keypress without adding a noticeable delay for the user. If debouncing is a new concept for you, this is a great article on the topic.
Now that we have our Stimulus controller, we need to attach it to the search form. Update the form in app/views/players/index.html.erb
:
Here we added the data-controller
attribute to the <form>
element and added data-action="autosubmit#submit"
to the text_field
. In Stimulus, input
elements assume that the action
to fire on is an input event, so you can leave the event out of the action descriptor. data-action="input->autosubmit#submit"
would work fine, if you really like being explicit.
Since the form submits as the user types, we also removed the unnecessary submit button. This means that users with JavaScript disabled will not be able to submit a form, but I am going to assume you trust me enough to enable JavaScript as you follow along with this tutorial.
Now that we have the Stimulus controller connected to the form, refresh the players index page and try out the new search experience.
Oh. That’s not what we wanted. Type-as-you-search is not very useful if we lose focus each time the search form submits!
What’s going on here? Right now, the search form is a standard Rails form. Each time we submit the form, a full page load occurs. The page content is replaced, scroll position is lost, and ephemeral state (like the user’s current focus) is lost.
This is where Turbo 8’s morphing page refreshes come in. Instead of a normal page turn, we can use morphing to replace only the content that’s changed, leaving page state and unchanged content untouched. Just like in the previous article, we will write very little code to make this happen.
Head to app/views/players/index.html.erb
and update the form again:
Here we added two new data attributes to the <form>
. data-turbo-action="replace"
tells Turbo to treat these form submissions as replace
visits instead of advance
visits. This change enables morphing refresh behavior for our form — as discussed in detail in the previous article, without setting data-turbo-action
on the visit, page refreshing will not be used.
As mentioned in the previous article, the use of replace
instead of the default advance
visit means that submitting the search form will not add a new entry into the user’s browser history, so they will not be able to use the back button to move between different searches. When a form is submitted as the user types, not preserving history for each search is probably the best call, but it is important to note that if preserving each form submission in the history is important, morphing page refreshes are not the right tool.
The other new data attribute is data-turbo-permanent=""
. We use this attribute to inform Turbo that the element (and the element’s children) should not be morphed on refresh visits. Form elements without data-turbo-permanent
will lose focus and be reset to their original (empty) state each time new content is morphed in during a refresh.
With both data attributes in place (and the turbo-refresh meta tags we added in the previous article), we have everything we need for our live search form to work as expected. Refresh the page and see that as you type, the list of players is updated without losing focus.
Great work so far!
We have written all of the Turbo 8 code we need to write. Two data attributes, two meta tags, and some standard Rails code to deliver search-as-you-type is a delight. In previous versions of Turbo we could achieve the same experience, but we needed to use Turbo Frames, which bring more complexity and a learning curve. We can stay closer to standard Rails, simplify our code, and still deliver a great user experience with page refreshes.
Let’s continue this article by improving the existing search and sort functionality and adding the ability to filter the table by team.
Search and sort together
As you tested out the code on your machine you may have noticed a problem with our search and sort functionality. You cannot use both at the same time.
When you apply a search, the sort order is removed, and when you apply a sort order, any search is cleared away. This happens because we are using params
to query and sort @players
in the PlayersController
index action, like this:
The search form does not submit with the sort
params, and the links to sort in the table headers do not include params from the search form and params
are not permanent, if they are not present in the current request, they do not exist.
There are a few ways to handle this situation. We could try to keep the current params
in sync, making sure the search form always included existing sort
params in the form submission and pushing the current state of the form
params into each sort link, but that is a lot of work.
Instead, we will move from using params
to query for Players
to storing search and sort values in the session
. Storing search and filter data in the session
allows us to persist that data across requests, and is a great solution to support more complex sorting and filtering UIs like ours.
To move from params
to session
, first update app/controllers/players_controller.rb
like this:
The queries are the same, but instead of using params
for values, we use session['filters']
. Before querying for the players we set session['filters']
based on any new inbound params
with merge!(filter_params)
.
filter_params
is a method that we have not defined yet. Define it next, staying in app/controllers/players_controller.rb
:
We use filter_params
to whitelist the attributes that can be used for filtering, any param
not in this list will be discarded instead of being stored in the session
.
With these changes in place, sorting and searching at the same time will work, but the sort indicators will not work as expected. This is because the indicators rely on params
, not session
values. Head to app/helpers/players_helper.rb
to fix that:
Here we replaced references to params
with session['filters']
, everything else is the same.
Finally, we need to read the value of the search field from session['filters']
instead of params
to, which we can do by updating the form in app/views/players/index.html.erb
:
Here we dig
for the value of session['filters']['name']
and use it to set the value of the search field, plain old Ruby here.
Now reload http://localhost:3000/players and see that you can search by name and apply sorting in both directions and everything works as expected.
Next up, we are going to add the ability to filter player’s by their Team
. In the process, we will clean up the query logic in the index
method. Let’s dig in.
Filter by team
Before we add a new filter for Team
, we should address the clunky code in PlayersController#index
. Each new filter we add means another conditional in the method, and it is already hard to understand at a glance.
Fortunately, we can make this code easier to read and maintain with a few small adjustments. Head to app/controllers/players_controller.rb
and update the index
method:
And then define the search_by_name
and apply_order
methods, staying in the PlayersController
:
Let’s pause here and look at what might be some surprising syntax. In the index
method we are using then and numbered parameters, like this:
then
is an alias for yield_self. then
(and yield_self
) yields self to a block and then returns the result of the block, and is helpful for exactly our use case; passing an object through a chain of method calls.
Numbered parameters (_1
) were introduced in Ruby 2.7 to simplify writing short blocks by allowing us to skip naming the parameters passed to the block. Alternatively, we could write the same line as:
But why write Ruby if we don’t care about aesthetics?
The use of then
with numbered parameters in this article came from these excellent articles from the folks at ThoughtBot:
Our refactored index
method still does the same thing, but it is now easier to scan the index
method to understand what’s going on. The refactor also gives us a pattern we can use to support filtering by Team
. Let’s do that now, staying in app/controllers/players_controller.rb
:
One new line here, .then { filter_by_team _1 }
. filter_by_team
is not defined yet, define it next in the PlayersController
private methods:
And then update filter_params
to whitelist the new team_id
attribute:
Now that we support filtering by team in the controller, we can update the form in app/views/players/index.html.erb
:
Here we added a team_id
select that displays all of the Team
records in the database. Note the data-action
attribute that submits the form automatically.
Load up http://localhost:3000/players one last time and see the new Team filter option. Test it out and see that you can apply any combination of sorting, searching, and filtering and it will all work as expected. With a couple lines of Turbo, and some regular old Rails and Ruby code we now have an interactive table UI that feels great to use and will look familiar to any Rails developer, even if they are new to Turbo.
That’s all for this article, great job following along and thank you for reading! As always, you can find the full code for this article on Github.
Wrapping up
Today we enhanced our sortable table by adding live searching and filtering, powered by a little bit of Stimulus and a splash of Turbo.
Turbo 8 plus Rails gives us the tools to write simple, Rails-y code without sacrificing user experience. To me, Turbo 8 feels peaceful in a way that previous versions of Turbo did not.
Peaceful is an odd term, maybe, to describe writing code, but that’s what it feels like after a few years of watching Turbo evolve, and seeing folks struggle with the conceptual shifts required to use Turbo. As we saw in this article and the first article in this series, page refreshes are vanilla Rails with a dash of magic on top. Things work like you expect them to in a way that feels, to me, peaceful, calm, and a tiny bit magical.
If you want to dive deeper into the topics we covered today:
- The articles discussing uses for yield_self and then that I linked above are really good reads, even if they don’t have anything to do with Hotwire directly
- In the previous article in this series, I linked to a Github PR with an important discussion about the difference between
advance
andreplace
visits and how they relate to page refreshes. - An article always inspires me, not related to Turbo directly is “Vanilla Rails is enough” by Jorge Manrubia. When I think about simple, peaceful Rails code, I think about vanilla Rails.