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!
Project setup
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:
bundle install
yarn install
rails db:setup
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 app/views/players/_players.html.erb
.
<%= turbo_frame_tag "players", class: "shadow overflow-hidden rounded border-b border-gray-200" do %>
<div class="flex justify-end mb-1">
<%= form_with url: list_players_path, method: :get do |form| %>
<%= form.text_field :name, placeholder: "Search by name", value: params[:name], class: "border border-blue-500 rounded p-2" %>
<%= form.button "Search", class: "bg-blue-500 text-white p-2 rounded-sm" %>
<% end %>
</div>
<!-- Snip the table -->
<% end %>
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:
def list
players = Player.includes(:team)
players = players.where('name ilike ?', "%#{params[:name]}%") if params[:name].present?
players = players.order("#{params[:column]} #{params[:direction]}")
render(partial: 'players', locals: { players: players })
end
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.
Real-time searching
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:
rails g stimulus search_form
Then, fill that controller in with:
// app/javascript/controllers/search_form_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = [ "form" ]
search() {
clearTimeout(this.timeout)
this.timeout = setTimeout(() => {
this.formTarget.requestSubmit()
}, 200)
}
}
This controller’s 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 clearTimeout
and setTimeout
to ensure that the form isn’t submitted every time the user types a new character.
Note that 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:
<div class="flex justify-end mb-1">
<%= form_with url: list_players_path, method: :get, data: { controller: "search-form", search_form_target: "form", turbo_frame: "players" } do |form| %>
<%= form.text_field :name,
placeholder: "Search by name",
class: "border border-blue-500 rounded p-2",
autocomplete: "off",
data: { action: "input->search-form#search" }
%>
<% end %>
</div>
<%= turbo_frame_tag "players", class: "shadow overflow-hidden rounded border-b border-gray-200" do %>
<!-- Snip table -->
<% end %>
Here we first moved the search form outside of the players
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.
We also added data-controller="search-form"
and data-search_form_target="form"
to the form element. These Stimulus attributes connect the search-form
controller to the DOM and set the form
target.
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 session
.
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:
def list
session['filters'] = {} if session['filters'].blank?
session['filters'].merge!(filter_params)
players = Player.includes(:team)
players = players.where('players.name ilike ?', "%#{session['filters']['name']}%") if session['filters']['name'].present?
players = players.order("#{session['filters']['column']} #{session['filters']['direction']}")
render(partial: 'players', locals: { players: players })
end
private
def filter_params
params.permit(:name, :column, :direction)
end
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 players
.
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:
touch app/controllers/concerns/filterable.rb
Then, fill in filterable.rb
with the following:
module Filterable
def filter!(resource)
store_filters(resource)
apply_filters(resource)
end
private
def store_filters(resource)
session["#{resource.to_s.underscore}_filters"] = {} unless session.key?("#{resource.to_s.underscore}_filters")
session["#{resource.to_s.underscore}_filters"].merge!(filter_params_for(resource))
end
def filter_params_for(resource)
params.permit(resource::FILTER_PARAMS)
end
def apply_filters(resource)
resource.filter(session["#{resource.to_s.underscore}_filters"])
end
end
There’s a lot of code here, let’s break it down.
The 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
argument. resource
will be an ActiveRecord class, like Player
. filter!
simply calls out to two internal methods, store_filters
and 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 Filterable
concern.
class Player < ApplicationRecord
belongs_to :team
FILTER_PARAMS = %i[name column direction].freeze
scope :by_name, ->(query) { where('players.name ilike ?', "%#{query}%") }
def self.filter(filters)
Player.includes(:team)
.by_name(filters['name'])
.order("#{filters['column']} #{filters['direction']}")
end
end
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.
With Player
set up for filtering, we can update PlayersController
to include Filterable
and replace the filtering logic in list
with the new filter!
method.
class PlayersController < ApplicationController
include Filterable
# snip
def list
players = filter!(Player)
render(partial: 'players', locals: { players: players })
end
end
Here we simply include Filterable
in the controller and then set the value of players
using the filter!
method provided by Filterable
.
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:
<tr>
<th id="players-name" class="text-left py-3 px-6 uppercase font-semibold text-sm hover:cursor-pointer relative">
<%= sort_indicator if session.dig('player_filters', 'column') == "name" %>
<%= build_order_link(column: "name", label: "Name") %>
</th>
<th id="players-team" class="text-left py-3 px-6 uppercase font-semibold text-sm hover:cursor-pointer relative">
<%= sort_indicator if session.dig('player_filters', 'column') == "teams.name" %>
<%= build_order_link(column: "teams.name", label: "Team") %>
</th>
<th id="players-seasons" class="text-left py-3 px-6 uppercase font-semibold text-sm hover:cursor-pointer relative">
<%= sort_indicator if session.dig('player_filters', 'column') == "seasons" %>
<%= build_order_link(column: "seasons", label: "Seasons") %>
</th>
</tr>
We’ve replaced references to params
with 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.
Next, update app/heleprs/players_helper.rb
like this:
module PlayersHelper
def build_order_link(column:, label:)
if column == session.dig('player_filters', 'column')
link_to(label, list_players_path(column: column, direction: next_direction))
else
link_to(label, list_players_path(column: column, direction: 'asc'))
end
end
def next_direction
session['player_filters']['direction'] == 'asc' ? 'desc' : 'asc'
end
def sort_indicator
tag.span(class: "sort sort-#{session['player_filters']['direction']}")
end
end
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:
yarn add @tailwindcss/forms
And then update tailwind.config.js
to include the plugin:
plugins: [
require('@tailwindcss/forms'),
]
Incredible stuff.
Back to adding the team filter. We’ll start by adding the team filter to the UI. In the players
partial:
<%= form_with url: list_players_path, method: :get, data: { controller: "search-form", search_form_target: "form", turbo_frame: "players" } do |form| %>
<%= form.select :team_id,
options_for_select(
Team.all.pluck(:name, :id),
session.dig('player_filters', 'team_id')
),
{ include_blank: 'All Teams' },
class: "border-blue-500 rounded",
data: {
action: "change->search-form#search"
}
%>
<!-- Snip the search input -->
<% end %>
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.
Head to app/models/player.rb
and update it like this:
FILTER_PARAMS = %i[name team_id column direction].freeze
scope :by_team, ->(team_id) { where(team_id: team_id) if team_id.present? }
def self.filter(filters)
Player.includes(:team)
.by_name(filters['name'])
.by_team(filters['team_id'])
.order("#{filters['column']} #{filters['direction']}")
end
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 filters
method.
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 index
in app/controllers/players_controller.rb
like this:
def index
@players = filter!(Player)
end
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:
<%= form.text_field :name,
placeholder: "Search by name",
value: session.dig('player_filters', 'name'),
class: "border border-blue-500 rounded p-2",
autocomplete: "off",
data: { action: "input->search-form#search" }
%>
With 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.
Production-grade considerations
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
Right now, 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.
Wrapping up
Today we built on a Turbo Frame foundation to expand a sortable table view to a table that can be searched, filtered, and sorted without full page turns or lots of custom JavaScript.
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
- (Shameless plug) Sign up for my monthly newsletter, Hotwiring Rails, to stay up to date on the latest developments in Rails-land
That’s all for today. As always, thanks for reading!
Top comments (2)
Really great article! Love the patterns here for filterable, I’ve done this before but not nearly as nicely, will be using something more like this going forward.
Great article David! Thanks so much for sharing! I'll implement this in my projects ;)