Instant search with Rails and Hotwire

Last year I wrote an article on building an instant search form with Rails and Stimulus. Since then, Turbo, the other half of Hotwire for the web has been released and the Hotwire stack is now the default for new applications in Rails 7.

Turbo opens the door for an even simpler, cleaner implementation of an instant search form.

So, today we’re taking another look at how to build a search-as-you-type interface that allows us to query our database for matches and update the UI with those matches (almost) instantly.

When we’re done, our code will look almost identical to what you’re used to writing to build Rails applications, and the only JavaScript we’ll need will be to trigger form submissions as the user types.

The finished product will work like this:

A screen recording of a user typing in a word to search a list of Dallas Mavericks basketball players. As they type, the list of players updates automatically.

I’m writing this assuming that you’re comfortable with Ruby on Rails - you won’t need any knowledge of the Hotwire stack and you’ll find the most value from this article if you’re new to Turbo. While this article uses Stimulus, its core focus is on Turbo, and specifically how to use Turbo Frames in a Rails application.

You can find the complete code for this guide on Github. Follow along with this guide from the main branch, or see the complete implementation in the instant search branch.

Let’s get started.

Application Setup

First, we’ll need an application with both Turbo and Stimulus installed and ready to go. If you’ve already got an application setup, feel free to use that instead.

To follow along line-by-line, start by running these commands from your terminal. Your machine will need to be setup for standard Rails development, with Ruby and Rails. For reference, I’m writing this guide using Rails 7 and Turbo 7.1.

rails new hotwire-search -T && cd hotwire-search
rails g scaffold Player name:string
rails db:migrate
rails s
  

If you’re using Rails 6 or working from an existing application, you’ll also need to install Turbo and Stimulus. The simplest way to do this is to use the turbo-rails and stimulus-rails gems.

With the above commands run, we should have a new rails application up and running, and visiting https://localhost:3000/players should open a page that lists the players in your database and includes a link to create new players.

Go ahead and add a few new players from the UI, or open up your Rails console and add some players there.

Give them different names since we’ll be filtering the list of players by name in this project.

Add the search form and controller action

With setup complete, open up the code in your favorite editor.

We’ll start by updating the players index view to add a form that we’ll use to filter the list of players.

Update the players index view to add the form:

<p style="color: green"><%= notice %></p>

<h1>Players</h1>
<%= form_with(url: players_path, method: "GET") do |form| %>
  <%= form.label :query, "Search by name:" %>
  <%= form.text_field :query %>
  <%= form.submit 'Search', name: nil %>
<% end %>
<div id="players">
  <% @players.each do |player| %>
    <%= render player %>
    <p>
      <%= link_to "Show this player", player %>
    </p>
  <% end %>
</div>

<%= link_to "New player", new_player_path %>

Next update the index action in the PlayersController to process search form submissions:

def index
  if params[:query].present?
    @players = Player.where("name LIKE ?", "%#{params[:query]}%")
  else
    @players = Player.all
  end
end

At this point, our search form works — submit it and the page will reload with the list of players filtered to match your search query; however, each form submission has to be manually triggered and the entire page reloads instead of only rendering the new list of players. We won’t win any awards for the elegance of our code, but it works just fine and will serve as a simple base for us to build from.

Let’s start with adding rendering our search results with Turbo Frames first, and then we’ll wrap up by adding a Stimulus controller to submit the form automatically as the user types.

Adding Turbo Frames

Our goal is to use a Turbo Frame to update only the content of the list of players each time the form is submitted. To do this, we’ll first need to wrap the content we want to update in a <turbo-frame> HTML tag.

We can use the turbo_frame_tag helper method from turbo-rails for this.

We’ll first create players partial that renders the list of players wrapped in a turbo frame.

From your terminal:

touch app/views/players/_players.html.erb

And then fill that in with:

<%= turbo_frame_tag "players" do %>
  <%= render players %>
<% end %>

Here we’re rendering a collection of players, wrapped inside of a turbo frame tag.

Next update the players index to render the partial:

<p style="color: green"><%= notice %></p>

<h1>Players</h1>
<%= form_with(url: players_path, method: "GET") do |form| %>
  <%= form.label :query, "Search by name:" %>
  <%= form.text_field :query %>
  <%= form.submit 'Search', name: nil %>
<% end %>
<!-- Rendering the new partial here -->
<%= render "players", players: @players %>

<%= link_to "New player", new_player_path %>

Finally, update the player partial:

<div id="<%= dom_id player %>">
  <p>
    <%= player.name %> <%= link_to "View Player", player_path(player) %>
  </p>
</div>

With the turbo frame in place, refresh the page and see that it looks identical to what we had before but in the markup you’ll see a <turbo-frame> element wrapping the list of players:

<turbo-frame id="players">
	<!-- Snip player divs -->
</turbo-frame>

Now we’re wrapping our players in a Turbo Frame, but we’re not making use of that Turbo Frame on the server.

Rendering a Frame from the server

To do that, update the index action in the players controller to check the inbound request.

When the request targets a Turbo Frame, we’ll respond with just the Turbo Frame we want to update, otherwise we’ll render the full page:

def index
  if params[:query].present?
    @players = Player.where("name like ?", "%#{params[:query]}%")
  else
    @players = Player.all
  end
  if turbo_frame_request?
    render partial: "players", locals: { players: @players }
  else
    render "index"
  end
end

This a bit a clunky (we’ll clean it up very soon!) but it demonstrates an important piece of how Turbo Frames work with Rails.

We’re using the built-in turbo_frame_request? helper to check for a specific turbo-frame header in the request from the browser.

When this header is present, our index action renders a partial that contains a matching Turbo Frame. Otherwise, we render the index page as normal.

Finally, we need to update our search form to tell Turbo that we want to target the players Turbo Frame we just added:

<%= form_with(url: players_path, method: "GET", data: { turbo_frame: "players" }) do |form| %>
  <!-- Snip the form content -->
<% end %>

Here we added the turbo-frame data attribute. When using Turbo Frames, we can add this data attribute to tell Turbo that the response to the form submission (or link click) should target the given Turbo Frame.

This extra effort is necessary because the search form and the list of players are not wrapped inside of a single Turbo Frame.

When a form submission or link click occurs inside of a <turbo-frame> element, Turbo will automatically attempts to retrieve and replace that frame’s content without any extra work from us.

While we could try wrapping the form inside of the players Turbo Frame, doing so is undesirable. If a form is inside of a Turbo Frame, each time that frame is re-rendered, form inputs will lose their current values and any focus will be lost — not an ideal user experience.

Since we don’t want to wrap the form inside of the frame that contains our list of players, we need to explicitly tell Turbo that our form submission should update the players Turbo Frame.

Cleaning up the Frame request

The if turbo_frame_request? logic in the controller action is a bit messy. Thankfully we can clean this up a bit.

First, create a new turbo_frame view:

touch app/views/players/index.html+turbo_frame.erb

And fill that view in with:

<%= turbo_frame_tag "players" do %>
  <%= render @players %>
<% end %>

Then update your ApplicationController to automatically look for turbo_frame views when a Turbo Frame request is received:

class ApplicationController < ActionController::Base
  before_action :turbo_frame_request_variant
  private

  def turbo_frame_request_variant
    request.variant = :turbo_frame if turbo_frame_request?
  end
end

Here we’re using the same turbo_frame_request? helper to set request.variant on all inbound requests that contain a turbo-frame header.

With that update to the ApplicationController, we can simplify the index action in the PlayersController to:

def index
  if params[:query].present?
    @players = Player.where("name like ?", "%#{params[:query]}%")
  else
    @players = Player.all
  end
end

Since the ApplicationController automatically sets the variant for us, individual controller actions no longer need to care about whether the request is for a Turbo Frame or not and Rails will invisibly render the right view for us.

With these changes in place, refresh the page, type in a search query and see that the page content updates as before… so how do we know it is working?

Check the server logs and see that when a form submission hits the server, Rails responds with players partial content without re-rendering the application layout or the index view.

If things are set up correctly, you should see something like this:

Processing by PlayersController#index as HTML
  Parameters: {"query"=>"Dirk"}
   (0.1ms)  SELECT sqlite_version(*)
  ↳ app/controllers/players_controller.rb:7:in `index'
  Rendering players/index.html+turbo_frame.erb
  Player Load (0.1ms)  SELECT "players".* FROM "players" WHERE (name like '%Dirk%')
  ↳ app/views/players/index.html+turbo_frame.erb:2
  Rendered collection of players/_player.html.erb [1 times] (Duration: 0.2ms | Allocations: 65)
  Rendered players/index.html+turbo_frame.erb (Duration: 2.1ms | Allocations: 922)
Completed 200 OK in 5ms (Views: 2.4ms | ActiveRecord: 0.4ms | Allocations: 1903

Notice the rendering players/index.html+turbo_frame.erb in the logs which tells us everything is working as expected.

Updating the URL on form submission

A requirement for many search and filter experiences is updating the URL as the user makes selections so that they can bookmark or copy and paste a particular set of filters. While this isn’t really useful for our search form use case, it is important to know that Turbo makes it trivial to update the URL on a Turbo Frame request.

To do this, we can update our form like this:

<%= form_with(url: players_path, method: "GET", data: { turbo_frame: "players", turbo_action: "advance" }) do |form| %>
  <!-- Snip -->
<% end %>

Here we added the turbo-action data attribute, giving it a value of advance. This attribute tells Turbo to push the frame navigation into the browser’s history, updating the page’s URL in the process. This option is outlined in the Turbo documentation here and the PR that added the functionality provides full details on how it works.

Once this change is made, refresh the page, type in a search query and see that the URL updates in your browser. You can then copy/paste that URL into a new window and see that searching works during a full page visit as well (just remember to set the value of the input to the params[:query] value if you go this route).

A screen recording of a user typing in a word to search a list of Dallas Mavericks basketball players and submit a form. When the form submits, the list of players updates

Magical stuff.

Automatic form submission

With our Turbo Frame powered form we already have a nice, efficient search experience in our application; however, it would be even better if the list of players could be updated as the user types.

To do this, we’ll add a small Stimulus controller that automatically submits the form as the user types.

To get started, generate the Stimulus controller with the built-in generator:

rails g stimulus form-submission

Then fill that controller (located at app/javascript/controllers/form_submission_controller.js) with the following:

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {

  search() {
    clearTimeout(this.timeout)
    this.timeout = setTimeout(() => {
      this.element.requestSubmit()
    }, 200)
  }
}

Here our search function waits for 200ms before calling requestSubmit() on the element the controller is attached to in the DOM. requestSubmit() requires a polyfill to work on Safari but Turbo helpfully includes that polyfill for us.

Next we’ll update the DOM to attach the Stimulus controller to the search form:

<%= form_with(url: players_path, method: "GET", data: { controller: "form-submission", turbo_frame: "players", turbo_action: "advance" }) do |form| %>
  <%= form.label :query, "Search by name:" %>
  <%= form.text_field :query, data: { action: "input->form-submission#search" } %>
<% end %>

Here we added two data attributes, one data-controller attribute to <form> element, connecting our FormSubmission controller. The other is a data-action attribute, telling Stimulus to call form-submission#search on each input event emitted by the form’s text field.

With this in place, we can refresh the page and see that the form submits as we type, with a short delay to let the user pause typing before submitting the form.

Here’s what the final experience looks like if you’ve been following along from start:

A screen recording of a user typing in a word to search a list of Dallas Mavericks basketball players. As they type, the list of players updates automatically.

Wrapping up

This small example should help you get a taste for how simple it can be to build modern, highly-responsive applications with Ruby on Rails and the Hotwire stack, combining the feel of a SPA with the developer experience that the Rails world (usually) loves.

To use this in the real world, we’d need to think about things like:

  • An empty state: The list partial could render different content when players is empty
  • Cleaner, more performant database queries: Definitely don’t just leave your query sitting in the controller! For production use cases, you’d want to consider an option like pg_search

For a deeper dive into Turbo Frames, you might enjoy my article dedicated to Turbo Frames. To learn about the other key component of Turbo, Turbo Streams, you can dig into my Turbo Streams on Rails article. To explore the full power of Turbo, the official Handbook and Reference docs are a good starting place.

While this example is not yet production-ready, I hope it gives you a good starting point into the world of Turbo & Stimulus-powered Rails applications. The next time you’re building something new, consider whether Rails + the Hotwire Stack might be enough to deliver the experience your users expect while keeping your team happy and productive.

As always, thanks for reading!