Building an instant search form with Stimulus.js and Rails

August 5, 2021 update: Since publishing this post, Turbo was released. While the method described here for building a live search interface with Stimulus still works just fine, if you’re using Stimulus, you might be interested in the other half of Hotwire on the web. In August of 2021, I published a post describing how to build the same interface described here with hotwire-rails, instead of just Stimulus.

Today we’re going to build a search form that instantly filters results using Ruby on Rails and Stimulus, a “modest” JavaScript framework designed to add small bits of interactivity without a lot of heavy lifting.

When we’re finished, we’ll have a list of Players that users can search by name. As they type in the search box, we’ll make a request to the server for players that match and display the results on the frontend (almost) instantly. Here’s what the end result will look like:

A screen recording of a user typing a search term into a search form and seeing results in a list below the form. I’m writing this assuming that you’ve already got Rails installed on your machine and that you are generally familiar with Rails and JavaScript. If you’re just getting started with either, you can still follow along, but you might find some things confusing!

I’m also not going to do a full overview of how Stimulus works, since the official docs do that well. Instead, our Stimulus work will be focused on building the desired behavior in Stimulus and connecting our Stimulus code to our HTML.

I’m going to walk through this step-by-step from a new Rails 6 project. If you want to follow along from an existing project that already has Stimulus and rails-ujs installed, skip slightly ahead to the Adding a search form section.

Let’s get started.

Setting up our project

First, we’ll create our Rails project, specifying that we want to include Stimulus:

rails new stimulus_live_search_demo --webpack=stimulus
cd stimulus_live_search_demo
  

Then, we’ll create our database and scaffold up a Player resource that our users will be soon be searching against:

rails db:create
rails generate scaffold player name:string
rails db:migrate
  

That’s it — that’s all the setup we need to do. Now we can dive into the fun stuff.

Adding a search form

Our users will need a search form if we want them to be able to search. We’ll first create a partial for our search form:

mkdir app/views/shared
touch app/views/shared/_search_form.html.erb
  

And then update app/views/players/index.html.erb to render the search form:

<!-- app/views/players/index.html.erb -->
<p id="notice"><%= notice %></p>

<h1>Players</h1>
<div>
  <%= render partial: "shared/search_form" %>
  <div>
    <% @players.each do |player| %>
      <%= render partial: "player", locals: { player: player } %>
    <% end %>
  </div>
</div>

<br>

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

And add our player partial, which we’ll use later to render our search results:

touch app/views/players/_player.html.erb
  
<!-- app/views/players/_player.html.erb -->
<div>
  <%= player.name %>
</div>
  

Our search form partial looks like this:

<!-- app/views/shared/_search_form.html.erb -->

<%= form_with url: player_search_index_path, method: :get do |form| %>
  <div>
    <%= form.text_field :search, autocomplete: "off" %>
  </div>
<% end %>
  

You’ll notice that our search form url references a controller that we haven’t created yet, the PlayerSearch controller.

While we could build a search method in our PlayersController creating a separate controller lets us keep our controller actions RESTful and is a little more Rails-y, in my opinion.

Let’s add our controller now:

rails generate controller PlayerSearch index
  
class PlayerSearchController < ApplicationController
  layout false
  
  def index
    @players = Player.where("name LIKE ?", "%#{params[:search]}%")
  end
end
  

Notice the layout false which ensures that our index view will only render the HTML in app/views/player_search/index.html.erb. If we don’t set layout to false, every search request we make will render our index view inside of the layout defined in app/views/layouts/application.html.erb — we don’t want that since we’re planning to insert the search results into a piece of an existing page.

Next we’ll add the content of our PlayerSearch index view. Our view simply loops over an array of players and renders the player partial each iteration.

<!-- app/views/player_search/index.html.erb -->
<% @players.each do |player| %>
  <%= render partial: "players/player", locals: { player: player } %>
<% end %>
  

Finally, we’ll update our routes file so that we can access thePlayerSearch index method.

Rails.application.routes.draw do
  resources :players
  resources :player_search, only: [:index]
end
  

Now that we have the search form rendering and the controller action built, our next step is to implement the Stimulus search controller.

The Stimulus controller

First, let’s create our Stimulus controller:

touch app/javascript/controllers/search_controller.js
  

This controller has two jobs — submitting the search form as the user types and handling the response from the server each time a search query completes.

Let’s implement form submission first, in a function we’ll call search:

search() {
  Rails.fire(this.formTarget, 'submit')
}
  

Rails.fire is, a poorly documented function available through rails-ujs. It takes an element (formTarget, in this case, which we’ll attach add to our HTML shortly) and the name of an action to trigger (like click or submit) and then triggers that event on the element. Despite the poor documentation around this method, it is the preferred method of submitting forms programmatically in Stimulus controllers.

To access Rails.fire in our Stimulus controller, we need to assign Rails to the window. We can do that with a small update to app/javascript/packs/application.js.

window.Rails = require("@rails/ujs")
require("turbolinks").start()
require("@rails/activestorage").start()
require("channels")

import "controllers"
Rails.start()
  

Now we can submit the search form programmatically.

Next, let’s add the function to handle search results from the server. We’ll do this with a new function in our Stimulus controller:

handleResults() {
  const [data, status, xhr] = event.detail
  this.resultsTarget.innerHTML = xhr.response
}
  

This function grabs the inbound response from the server and replaces the contents of a (yet-to-be-defined) DOM element with the portion of the response we care about.

If the variable assignment looks mysterious to you, check out the Rails guide Working with JavaScript in Rails which describes the event handlers available in rails-ujs and the parameters that those event handlers include. In our case, we’ll be using the ajax:success event.

When finished, our complete Stimulus controller looks like this:

import { Controller } from "stimulus"

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

  connect() {
    console.log("Connected!")
  }

  search() {
    Rails.fire(this.formTarget, 'submit')
  }

  handleResults() {
    const [data, status, xhr] = event.detail
    this.resultsTarget.innerHTML = xhr.response
  }
}
  

Connecting the controller to the frontend

The Stimulus controller is ready — our last step is to connect our Stimulus controller and our HTML. To do so, we’ll need to update the index view in our Players view and the search_form partial we created earlier.

First, the updates to the the index view:

<!-- app/views/players/index.html.erb -->
<p id="notice"><%= notice %></p>

<h1>Players</h1>
<div data-controller="live-search">
  <%= render partial: "shared/search_form" %>
  <div data-target="live-search.results">
    <% @players.each do |player| %>
      <%= render partial: "player", locals: { player: player } %>
    <% end %>
  </div>
</div>

<br>

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

The data-controller attribute added to our parent div connects our Stimulus controller to the DOM. The data-target attribute on the results container div is used by handleResults in our Stimulus controller to render the results of our search when a request is submitted. If any of this syntax is confusing, the best place to start is the Stimulus handbook which explains how Stimulus uses these data attributes in detail.

Last step before we can start typing search terms — updating our search form partial:

<!-- app/views/shared/_search_form.html.erb -->
<%= form_with url: player_search_index_path, method: :get, data: { action: "ajax:success->live-search#handleResults", target: "live-search.form" } do |form| %>
  <div>
    <%= form.text_field :search, data: { action: "input->live-search#search" }, autocomplete: "off" %>
  </div>
<% end %>
  

We’re doing a few things here, let’s walk through it.

First, the data-action on our form element calls our handleResults method each time this form is submitted successfully. The data-target on the form element gives us an easy reference to the form in the Stimulus controller which we use in the search function to submit the form.

On the text field, we add a data-action that calls the search method each time the input event is fired on this input. Because using the browser’s autocomplete functionality on a search form isn’t usually desired, we also turn off autocomplete. You can skip turning off autocomplete if you like, it won’t bother me.

That’s it — as users type in the search field we automatically send a request to the server to find matching players and update the players list so users can see results in real time. Add a few players to your database and try it out!

A screen recording of a user typing a search term into a search form and seeing results in a list below the form.

Improving our search method

As you followed along with this tutorial, you might have noticed that our implementation submits the form every time the input event fires. This means that if the user types 10 characters in a row we’ll make 10 separate search requests to the server. In a toy application like ours that’s okay, but we can do a little better.

Instead of submitting the form each time an input event fires, we can instead wait for the user to stop typing before submitting the form. One way of doing this is by using a debounce function to delay form submission until after the user has stopped an action for a period of time. A simple implementation of debouncing that makes use of setTimeout and clearTimeout looks like this:

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

This change to delay the Rails.fire call until 200ms after the last input event fires.

In a real world application, instead of implementing your own debounce function, you should consider a resilient, well-architected solution from a library like Lodash. You can read more about debouncing here.

Wrapping up

Together we’ve built an instant search results with a very small amount of Stimulus and a few data attributes in our HTML. What’s really cool about this implementation is that it is reusable anywhere in your application that you want the same behavior. Since the Stimulus controller just submits a form and replaces data on the page, you could easily reuse the exact same Stimulus controller to build a search UX for any resource in your application!

From the base we’ve built in this demo application, we could add more complex search and filtering options and implement loading states to improve user experience on slower requests.

Thanks for reading!

Helpful resources

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.