Instant search with Rails 6 and Hotwire

Last year I wrote an article on building an instant search form with Rails and StimulusJS. Since then, Turbo, the other half of Hotwire for the web has been released. 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:

I’m writing this assuming that you’re comfortable with Ruby on Rails. You won’t need any knowledge of Hotwire (Turbo or Stimulus) to follow along. Those that will get the most value from this guide will likely be newer to Hotwire and Turbo, looking for an example of how to build a fairly common user experience with Hotwire's toolset.

You can find the complete code for this guide on Github.

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 the below commands from your terminal. Your machine will need to be setup for standard Rails development, with Ruby, Rails, and Yarn installed.

For reference, I’m writing this guide using Rails 6.1 and turbo-rails 7.0.0-rc.1.

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

With the above commands run, we should have a new rails application up and running, and visiting http://localhost:3000/players should open a page that lists the Players in your database and allows you to add and remove 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 id="notice"><%= notice %></p>

<h1>Players</h1>
<%= form_with(url: search_players_path) do |form| %>
  <%= form.label :query, "Search by name:" %>
  <%= form.text_field :query, data: { action: "input->form-submission#search" } %>
<% end %>
<table>
  <thead>
    <tr>
      <th>Name</th>
      <th colspan="3"></th>
    </tr>
  </thead>

  <tbody>
    <% @players.each do |player| %>
      <tr>
        <td><%= player.name %></td>
        <td><%= link_to 'Show', player %></td>
        <td><%= link_to 'Edit', edit_player_path(player) %></td>
        <td><%= link_to 'Destroy', player, method: :delete, data: { confirm: 'Are you sure?' } %></td>
      </tr>
    <% end %>
  </tbody>
</table>

<br>

<%= link_to 'New Player', new_player_path %>

Since we’ve added a form_with that expects a route named search_players_path to exist, we’ll update our routes file to add that path:

Rails.application.routes.draw do
  resources :players do
    collection do
      post 'search'
    end
  end
end

One important thing to note is that because we are going to respond with a turbo stream, our form needs to send a POST request, not a GET. There are ways to work around this behavior but instead of using workarounds, we’ll just use a POST request for our search form.

You can read more about the reasons for this behavior, and find possible workarounds, on Github.

While we could add a dedicated controller for searching, in this case we’ll keep things simple by just adding the route to the existing PlayersController. With the path added, next let’s update the PlayersController to add our search method:

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

  render turbo_stream: turbo_stream.replace(
    'players',
    partial: 'list',
    locals: {
      players: @players
    }
  )
end

We won’t win any awards for the elegance of our database query, but the important part of the method is the render call.

Rather than a standard HTML response, requests to players/search will send back a turbo_stream response that targets an element on the page with the id of players and replaces the contents of that element with a list partial.

Responding with a turbo_stream instead of HTML results in a payload sent to the browser that will look something like this:

<turbo-stream action="replace" target="players">
  <template>
    <!-- Content from the rendered partial -->
  </template>
</turbo-stream>

Note that this is still HTML, just wrapped in the special <turbo-stream> element and sent with a header (text/vnd.turbo-stream.html; charset=utf-8) that indicates the response is a turbo stream.

When Turbo sees a response with the turbo-stream header and a turbo-stream wrapped HTML fragment, it reads the action and the target or, newly added, targets from the <turbo-stream> and uses that to update only the relevant part(s) of the DOM.

Add a stream target

Now we know a little bit about what’s happening behind the scenes with Turbo, but none of this will work in our application yet. We’re responding to form submissions with a partial that we haven’t created yet and our form can't be submitted since we don't have a submit button.

Let’s dive back in to the code by creating our list partial. From your terminal:

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

And then fill that partial in with:

<tbody id="players">
  <% players.each do |player| %>
    <tr>
      <td><%= player.name %></td>
      <td><%= link_to 'Show', player %></td>
      <td><%= link_to 'Edit', edit_player_path(player) %></td>
      <td><%= link_to 'Destroy', player, method: :delete, data: { confirm: 'Are you sure?' } %></td>
    </tr>
  <% end %>
</tbody>

Note the id attribute on the <tbody> element. This id must match the target passed to turbo_stream.replace in our controller, otherwise nothing will happen when we search. The rest of this partial is just boilerplate generated by the Rails scaffold.

Finally, update the players index view to use the list partial we just created:

<!-- Snip -->
<table>
  <thead>
    <!-- Snip -->
  </thead>

  <%= render "list", players: @players %>
</table>
<!-- Snip -->

Submit the search form

With our list partial in place, our last step is to automatically submit the search form as the user types. To do that, we’ll create a small Stimulus controller and attach it to our search form. First, in your terminal:

touch app/javascript/controllers/form_submission_controller.js

And fill that controller in with the below:

import { Controller } from "stimulus"
import Rails from "@rails/ujs";

export default class extends Controller {
  static targets = [ "form" ]

  search() {
    clearTimeout(this.timeout)
    this.timeout = setTimeout(() => {
      Rails.fire(this.formTarget, 'submit')
    }, 200)
  }
}

Here the search function waits for the user to stop typing and then submits our form. To trigger the form submission, we’re borrowing a technique outlined in Better Stimulus.

With the Stimulus controller added and the list partial created, the last step is to add the Stimulus controller to our form by updating our search form in the players index view as follows:

<%= form_with(url: search_players_path, data: { controller: 'form-submission', form_submission_target: "form" }) do |form| %>
  <%= form.label :query, "Search by name:" %>
  <%= form.text_field :query, data: { action: "input->form-submission#search" } %>
<% end %>

Here we’re in regular Stimulus land. We add data attributes to the <form> element to connect our controller and set the necessary form target and then the text field is updated with a data-action attribute that triggers the search function in the form-submission controller on input.

With this last piece in place, load up /players in your browser, start typing, and you should see the list of players update automatically, like this:

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 Hotwire, 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:

  • A loading state: Replacing the players <tbody> on when form submission starts could be a good starting point
  • 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

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

As always, thanks for reading!

15