Filter, search, and sort tables with Rails and Turbo Frames
Tags: rails, tutorial, ruby, hotwire
Published: 2021-10-15 19:34:30

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](https://www.colby.so/posts/sortable-table-with-rails-and-turbo-frames) — 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/abgabcu9q2wbxwc0dukx.gif)

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](https://www.colby.so/posts/sortable-table-with-rails-and-turbo-frames) locally, you’ll want to begin by cloning [this Github repo](https://github.com/DavidColby/player_sorting_frames). 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](https://github.com/DavidColby/player_sorting_frames/tree/sortable), 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](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`.

```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:

```ruby
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:

```javascript
// 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](https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/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](https://github.com/javan/form-request-submit-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:

```erb
<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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/99ptawtf1q9kjuqgp718.gif)

Because the form is now outside of the frame, we add a `data-turbo-frame` to target the `players` frame. This [tells Turbo](https://turbo.hotwired.dev/handbook/frames#targeting-navigation-into-or-out-of-a-frame) 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](https://stimulus.hotwired.dev/reference/controllers#identifiers) the `search-form` controller to the DOM and set the `form` [target](https://stimulus.hotwired.dev/reference/targets#attributes-and-names).

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](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/input_event) is fired on the name field.

We moved fairly quickly through some core Stimulus concepts here. The [Stimulus handbook](https://stimulus.hotwired.dev/handbook/introduction) 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:

```ruby
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](https://api.rubyonrails.org/v6.1.4/classes/ActiveSupport/Concern.html). 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:

```ruby
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.

```ruby
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.

```ruby
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](https://api.rubyonrails.org/v6.1.4/classes/ActiveSupport/Concern.html). 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:

```erb
<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:

```ruby
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:

```javascript
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:

```erb
<%= 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:

```ruby
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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/6b7qgskgktg5814w0bmo.gif)

## 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:

```ruby
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:

```erb
<%= 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](https://guides.rubyonrails.org/v4.1.4/action_controller_overview.html#session) but you may want to consider a more flexible solution for storing filter options.

One option here is to use [Kredis](https://github.com/rails/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](https://viewcomponent.org/).

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](https://www.stimulusreflexpatterns.com/patterns/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](https://www.colby.so/posts/turbo-frames-on-rails) and [Turbo Streams](https://www.colby.so/posts/turbo-streams-on-rails) on Rails
* Dive into the [Turbo](https://github.com/hotwired/turbo) and [turbo-rails](https://github.com/hotwired/turbo-rails) source and follow along with the Github activity for both
* Join the [hotwire discussion forums](https://discuss.hotwired.dev/)
* (Shameless plug) Sign up for my monthly newsletter, [Hotwiring Rails](https://landing.mailerlite.com/webforms/landing/d7z0n0), to stay up to date on the latest developments in Rails-land

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