Filter, search, and sort tables with Rails and Turbo Frames15 Oct 2021
Today we are going to build a table that can be sorted, searched, and filtered all at once, using Ruby on Rails, Turbo Frames, a tiny Stimulus controller, and a little bit of Tailwind for styling.
We will start with a sortable, Turbo Frame-powered table that displays a list of Players from a database. We built this sortable table in a previous article — you might find it helpful to start with that article, especially if you are new to Turbo.
When we are finished, users will be able to search for players by name, filter them by their team, and sort the table. Sorting, searching, and filtering all work together, in any combination, and they each occur without a full page turn. The end result will work like this:
This article is intended for folks who are comfortable with Ruby and Rails code. You won’t need any prior experience with Turbo Frames or Stimulus.
Let’s dive in!
If you want to follow along with this article and you haven’t already completed the sortable table article locally, you’ll want to begin by cloning this Github repo. If you have completed the sortable table article, this one picks up exactly where that one ends, so go ahead and work from where that article finished.
To set up the application after cloning from Github, from your terminal:
Once the application is ready, checkout the sortable branch, where this article picks up, and then run
bin/dev to compile assets and start your development server.
After you start the server, head to http://localhost:3000 and see that you have a seeded database of players and that you can sort the table by clicking on each column header.
If you’re curious how the the sorting works, the sortable tables article goes through the frame-powered sorting mechanism in detail.
With setup complete, let’s start building!
Add a search form
We’ll start with a simple search form, added inside the
players turbo frame in
This is a standard-issue Rails search form. When the form is submitted, a GET request is dispatched and
PlayersController#list responds to the request. For now, the form requires the user to click the Search button to submit the search request.
Next, we’ll update the
list method in
app/controllers/players_controller.rb to filter the list of players when the search form is submitted:
Here we’ve got a clunky, functional implementation of searching by name — when the
name parameter from the form’s text_field is present, we run a case insensitive query for players in the database with a name that matches the search query.
With these changes in place, refresh the page, type a query into the search form and submit it. You should see the players list update with the results of your query.
You’ll notice right away that searching clears any previously applied sorting on the table, and sorting the table clears out any search. So we’ve got a search form, but we have to click to submit the form and doing so clears out the user’s sorting preference.
We’ll fix both of those issues, starting with remove the submit button and searching as the user types instead.
We’ll use a small Stimulus controller to submit the search form as the user types. First, generate the Stimulus controller with the generator built in to
stimulus-rails. From your terminal:
Then, fill that controller in with:
search function calls requestSubmit on a
form target. Since we’ll call this
search function as the user is typing in a text input, we’ve add
setTimeout to ensure that the form isn’t submitted every time the user types a new character.
requestSubmit needs a polyfill for support on Safari and IE11. As an alternative to
requestSubmit, if you are using
Rails/ujs in your application,
Rails.fire(this.formTarget, 'submit') works without a polyfill.
With the Stimulus controller ready to go, next we’ll connect that controller the search form:
Here we first moved the search form outside of the
turbo-frame. This is necessary because if the search form is inside of the turbo frame, the search input will be reset every time the
players frame is rerendered (meaning every time our search form is submitted), like this:
Because the form is now outside of the frame, we add a
data-turbo-frame to target the
players frame. This tells Turbo to use the response from the form submission to replace the content of the players frame.
Finally, we add
data-action=“input->search-form#search” to the
name field, which tells Stimulus to call the
search function each time the
input event is fired on the name field.
We moved fairly quickly through some core Stimulus concepts here. The Stimulus handbook is a great reference point if you need to spend more time with any of these concepts.
With these changes in place, we can refresh the page and see that search results update as the user types. We still cannot sort and search at the same time though, so let’s tackle that next.
Sorting and searching at the same time
The reason we can’t search and sort at the same time is because the
list method relies on URL parameters to apply search and filter options. Because the search form doesn’t include the sort parameters and sorting doesn’t include the search parameter,
list has no way of retaining sort and search options across requests — every new request to
list starts from scratch.
There are a variety of ways to address this issue. The most direct is to move from using
params to storing filter options in the
Our basic approach will be to use
params to update a
filters hash in the
session object. Since
session is maintained across requests, as long as we update the session
filters hash with the search and sort
params on each request, we can persist search and sort options across requests.
A very ugly implementation of this concept, done directly in the
list method looks like this:
Here we ensure that
session['filters'] is a hash, update its value by merging in whitelisted
filter_params and then use the the
filters hash to search for players by name and order the list of players, as appropriate.
This ugly code is fully functional — if you update the
list method and add the
filter_params method to your controller you will be able to search and sort at the same time; however, this code is clunky, difficult to follow, and quickly becomes unmaintainable as your application grows.
So, if this code is ugly and unmaintainable, why are we looking at it?
Because we are going to refactor it into something neater and more scalable. Before we do that it is helpful to see what the most direct implementation can be so we can understand what is happening at a basic level.
When we’re done, we’ll still store
params in a hash in the
session object, and we’ll still use the hash values to query the database based on the user’s preferences. Our code will be nicer, but it’ll still be the same basic concept.
Let’s refactor this code next.
Building a Filterable concern
To make our filtering code more scalable and less error prone, we are going to start with a generalized
Filterable concern. We’ll include
Filterable in the
PlayersController and use it to filter
Filterable won’t know anything about the specifics of querying
Players, instead it will just implement logic to store query parameters in the session. Once the values are stored, they’ll be used to apply filters.
First, create the filterable concern. From your terminal:
Then, fill in
filterable.rb with the following:
There’s a lot of code here, let’s break it down.
filter! method is what we’ll call from the controller to apply filters in response to a request from a user. It takes a
resource will be an ActiveRecord class, like
filter! simply calls out to two internal methods,
apply_filters, which do the heavy lifting.
store_filters ensures that
session['class_name_filters'] exists, and then writes whitelisted parameters into the session key, replicating the first two lines of our ugly implementation directly in the
list method in the last section.
Once the filters are stored,
apply_filters calls a
filter class method from the class we’re interested in which should return a list of ActiveRecord objects.
You’ll notice here that
Filterable isn’t doing much on its own. Instead, it is relying on methods to exist on the class passed in to
filter!. This is by design — in a real application, we would likely need to build filtering mechanisms for many different ActiveRecord classes, and each will need to be able to filter by a unique set of columns. Trying to build all of that logic into the
Filterable module would quickly become even harder to maintain than tossing everything in the controller.
Rather than building all of that complexity in to
Filterable, we instead just rely on the target class to define
FILTER_PARAMS and a
filter method in whatever way works for that particular class.
Let’s see this in action by updating
app/models/player.rb to work with our new
Here we’ve defined the
FILTER_PARAMS constant with the three filtering we support on the players table.
Next, we added the
by_name scope to handle searching the players table by name.
Finally, we implement
filter, which replaces the queries that we previously built in
PlayersController#list and makes use of the new
by_name scope to be a bit more readable.
Player set up for filtering, we can update
PlayersController to include
Filterable and replace the filtering logic in
list with the new
Here we simply include
Filterable in the controller and then set the value of
players using the
filter! method provided by
Much nicer, right?
Before moving on, you’ll also notice that
Filterable is in the
controller/concerns directory, but it isn’t truly a Concern. In our case, controller concerns is the simplest place for this module to live, but we don’t need the full functionality of a true concern. You could just as easily place this module in another place if you prefer.
Our last step is to update the views to read values from the session instead of params so that we always display the correct set of applied filters to users.
First, update the table header in
_players like this:
We’ve replaced references to
session.dig calls with this change. When no filters have been applied,
session['player_filters'] won’t exist, so we use
dig to avoid nil errors in those cases.
app/heleprs/players_helper.rb like this:
Again we’re just replacing params with the equivalent session values so that sorting works correctly all the time and visual indicators are shown consistently.
With those changes in place, refresh the page and see that you can search and sort the table at the same time, and the UI always shows the proper sort indicator.
Nice work making it this far! We spent a good amount of time building a more scalable filtering solution, so let’s wrap up this article by putting that scalable solution to use by adding a filtering option to the table.
Add filtering by team
Right now users can search by player name and sort the table, but they can’t filter by team or season. Let’s add filtering by team to the filter options.
We’re going to use a select input for the Team filter, since
team is a
belongs_to relationship on the
Player class — we’ll have a dropdown menu that displays all available teams by name, each dropdown option will have
team_id as the value sent back to the server.
Since we’re using a select input, let’s make it look nice by taking a slight detour to install Tailwind’s forms plugin:
From your terminal:
And then update
tailwind.config.js to include the plugin:
Back to adding the team filter. We’ll start by adding the team filter to the UI. In the
This is a regular Rails
select helper. In it, we build a list of all teams in the database, set the selected value when one is present (
session.dig, again) and fire the
search function of the
search-form controller each time the select input changes.
Refresh the page, change the team input and see that it doesn’t work yet. We haven’t updated
Player to support filtering by team yet.
app/models/player.rb and update it like this:
Now we can start to see the benefit of the work we did in the last section. We can add filtering by a new option with just a few simple changes to the model.
We updated the model to add
team_id to the list of valid
FILTER_PARAMS, added the
by_team scope, and then added
by_team to the
No need to change our controller or touch the
Filterable module — updating the model is all we need.
Refresh the page, apply a team filter and see that filtering by team works along with searching by name and sorting. Great work!
Filtering the index action
We’ll wrap up this exercise by make a few more small adjustments to avoid weirdness when a user visits the
/players with filtering values already saved in their session.
First, now that we have access to the
filter! method, we can update the
index action to use that method instead of always setting the value of
players to all players in the database.
To do that, update
app/controllers/players_controller.rb like this:
With that change in place, we’ll now filter the list of players properly when a user reloads players page after applying filters in a previous request, making the experience on the index page a bit more consistent.
Finally, since we’re using the session values to restore previously applied filters, we need to update the
name search field to set its
value from the session. Without this change, when the user applies a name search and then refreshes the page, the list of players will be filtered by name, but the search term won’t be visible in the name field.
To fix this issue, update the name field in the
players partial like this:
value added to the
name field, we’ll always show the correct value to users, even on the initial page load.
With these small changes in places, we’ve now got consistent filtering experience that persists cleanly across requests and can be extended with new options as our user experience requirements change.
Nice work making it this far!
You’ve reached the end of the tutorial portion of the article, we’ll finish up by discussing a few ways we could improve this implementation in a production application.
While what we built today works fine, there are some things to consider if we were building a real, consumer-facing application.
Our code works and is pretty easy to maintain, but before we go, let’s touch on a few points to think about for production-grade applications. These are intended simply as things to think about as you build, and we won’t be going through any code here:
Session storage limitations
We are relying on the
session object to store filter options. This is fine for small applications, but as you grow, you may run into the limits of storing options like this in the session.
By default, Rails stores the session in a cookie which can only be ~4kb before it will start raising errors. You can use a different session store but you may want to consider a more flexible solution for storing filter options.
One option here is to use Kredis, a Redis-based solution that provides a nice interface for solving problems like our session persistance problem today.
Replace helper methods
The helper methods we are using to render sorting links and indicators could be implemented as ViewComponents.
This pattern allows us to write more testable and reusable view code and excels in cases like our example application. As this application grows, we should expect to have multiple tables across our application that all need sort links.
Rather than implementing them as helpers that are very specific to the players table, we could create them as generic components that can be thoroughly tested and then reused throughout the code base.
Expand Filterable implementation
Filterable relies on each model to define
FILTER_PARAMS and a
filter method from scratch.
In the real world, there would likely be enough overlap between the different implementations of
filter in each model that we would benefit from moving
filter out of each model and into a
Filter class that defines some shared logic, like applying
order, which is likely to be the same for every class.
For a really detailed implementation of a
Filterable pattern, take a look at the filterable_reflex from StimulusReflex Patterns.
Because frames are so powerful, we were able to easily hook into our existing frame code and add in searching and filtering without thinking too much about the front end implementation. It mostly just worked, once we built the filtering logic on the server.
This is the power of Turbo Frames — we can build fast, efficient user interfaces without stepping much outside of standard Rails code. The client side code stays light and maintainable, while our server looks and feels familiar to any level of Rails developer.
To dig deeper into Turbo and building modern Ruby on Rails applications with the Hotwire stack:
- Read my articles on Turbo Frames and Turbo Streams on Rails
- Dive into the Turbo and turbo-rails source and follow along with the Github activity for both
- Join the hotwire discussion forums
That’s all for today. As always, thanks for reading!