Filter, search, and sort tables with Rails and Turbo Frames

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:

A screen recording of a user interacting with a table on a website. They click column headers to sort the table, use a drop down menu to filter the table by a specific team, and type in a search box to filter the table by name

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:

A screen recording of a user typing in a search form above a table with a list of players. When they finish typing the table updates based on their query and the search form they were typing is reset

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!

A screen recording of a user interacting with a table on a website. They click column headers to sort the table, use a drop down menu to filter the table by a specific team, and type in a search box to filter the table by name

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:

That’s all for today. As always, thanks for reading!

Hotwiring Rails newsletter.

Enter your email to sign up for updates on the new version of Hotwiring Rails, coming in spring 2024, and a once-monthly newsletter from me on the latest in Rails, Hotwire, and other interesting things.

Powered by Buttondown.