Instant search with Rails 6 and Hotwire
Tags: ruby, rails, tutorial, webdev
Published: 2021-08-06 11:46:57

Last year I wrote [an article](https://www.colby.so/posts/live-search-with-stimulus-and-rails-6) on building an instant search form with Rails and StimulusJS. Since then, [Turbo](https://turbo.hotwired.dev/handbook/introduction), the other half of [Hotwire](https://hotwired.dev/) 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:

![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.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/aqgkwkmstabbdyntdp8z.gif)

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](https://github.com/DavidColby/hotwire-live-search).

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.

```shell
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](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:

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

```ruby
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](https://github.com/hotwired/turbo-rails/issues/120).

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:

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

```html
<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](https://github.com/hotwired/turbo/pull/113), `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:

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

And then fill that partial in with:

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

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

```shell
touch app/javascript/controllers/form_submission_controller.js
```

And fill that controller in with the below:

```javascript
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](https://www.betterstimulus.com/rails-ujs/submit-form.html).

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:

```erb
<%= 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](http://localhost:3000/players) in your browser, start typing, and you should see the list of players update automatically, 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.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/aqgkwkmstabbdyntdp8z.gif)

## 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](https://github.com/Casecommons/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!