Building a Live Search Experience with StimulusReflex and Ruby on Rails

As we approach the release of Rails 7, the Rails ecosystem is full of options to build modern web applications, fast. Over the last 9 months, I’ve written articles on building type-as-you-search interfaces with Stimulus and with the full Hotwire stack, exploring a few of the options available to Rails developers.

Today, we’re going to build a live search experience once more. This time with StimulusReflex, a “new way to craft modern, reactive web interface with Ruby on Rails”. StimulusReflex relies on WebSockets to pass events from the browser to Rails, and back again, and uses morphdom to make efficient updates on the client-side.

When we’re finished, our application will look like this:

A screen recording of a user on a web page with a list of player names below a search text box. The user types 'Dirk' in the search box and the list of player names is automatically filtered based on the search term. The user clicks a clear search link and the list of players resets while the text box is cleared.

It won’t win any beauty contests, but it will give us a chance to explore a few of the core concepts of StimulusReflex.

As we work, you will notice some conceptual similarities between StimulusReflex and Turbo Streams, but there are major differences between the two projects, and StimulusReflex brings functionality and options that don’t exist in Turbo.

Before we get started, this article will be most useful for folks who are comfortable with Ruby on Rails and who are new to StimulusReflex. If you prefer to skip ahead to the source for the finished project, you can find the full code that accompanies this article on Github.

Let’s dive in.

Setup

To get started, we’ll create a new Rails application, install StimulusReflex, and scaffold up a Player resource that users will be able to search.

From your terminal:

rails new stimulus-reflex-search -T
cd stimulus-reflex-search
bundle add stimulus_reflex
bundle exec rails stimulus_reflex:install
rails g scaffold Players name:string
rails db:migrate

In addition to the above, you’ll also need to have Redis installed and running in your development environment.

The stimulus_reflex:install task will be enough to get things working in development but you should review the installation documentation in detail ahead of any production deployment of a StimulusReflex application.

With the core of the application ready to go, start up your rails server and head to http://localhost:3000/players.

Create a few players in the UI or from the Rails console before moving on.

Search, with just Rails

We’ll start by adding the ability to search players without any StimulusReflex at all, just a normal search form that hits the existing players#index action.

To start, update players/index.html.erb as shown below:

<p id="notice"><%= notice %></p>

<h1>Players</h1>
<%= form_with(url: players_path, method: :get) do |form| %>
  <%= form.label :query, "Search by name:" %>
  <%= form.text_field :query, value: params[:query] %>
  <%= form.submit 'Search', name: nil %>
<% end %>
<table>
  <thead>
    <tr>
      <th>Name</th>
      <th colspan="3"></th>
    </tr>
  </thead>

  <%= render "players", players: @players %>
</table>

<br>

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

Here we’re rendering a search form at the top of the page and we’ve moved rendering players to a partial that doesn’t exist yet.

Create that partial next:

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

And fill it in with:

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

Finally, we’ll add a very rudimentary search implementation to the index method in the PlayersController, like this:

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

If you refresh /players now, you should be able to type in a search term, submit the search request to the server, and see the search applied when the index page reloads.

Now let’s start adding StimulusReflex, one layer at a time.

Creating a reflex

StimulusReflex is built around the concept of reflexes. A reflex, to oversimplify it a bit, is a Ruby class that responds to user interactions from the front end.

When you’re working with StimulusReflex, you’ll write a lot of reflexes that do work that might otherwise require controller actions and a lot of client-side JavaScript logic.

Reflexes can do a lot, from re-rendering an entire page on demand to kicking off background jobs, but for our purposes we’re going to create one reflex that handles user interactions with the search form we added in the last section.

Instead of a GET request to the players#index action, form submissions will call a method in the reflex class that processes the search request and updates the DOM with the results of the search.

We’ll start with generating the reflex using the built-in generator:

rails g stimulus_reflex PlayerSearch

This generator creates two files, a PlayerSearch reflex class in the reflexes directory and a player-search Stimulus controller in javascripts/controllers.

We’ll define the reflex in the PlayerSearch class, and then, optionally, we can use the player-search controller to trigger that reflex from a front end action and hook into reflex lifecycle methods that we might care about on the front end.

The simplest implementation of a working PlayerSearch reflex is to update an instance variable from the reflex, and then rely on a bit of StimulusReflex magic to do everything else. We’ll start with the magical version.

First, add a search method to the PlayerSearch reflex:

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

Then update the search form to trigger the reflex on submit:

<%= form_with(method: :get, data: { reflex: "submit->PlayerSearch#search" }) do |form| %>
  <%= form.label :query, "Search by name:" %>
  <%= form.text_field :query, value: params[:query] %>
  <%= form.submit 'Search', name: nil %>
<% end %>

And update PlayersController#index to only assign a new value to @players when it hasn’t already been set by the reflex action.

def index
  @players ||= Player.all
end

With these changes in place, we can refresh the players page, submit a search, and see that searching works fine. So what’s going on here?

In the form, we’re listening for the submit event and, when it is triggered, the data-reflex attribute fires the search method that we defined in the PlayerSearch reflex class.

PlayerSearch.search automatically gets access the params from the nearest form so we can use params[:query] like we would in a controller action.

We use the query param to assign a value to @players and, because we haven’t told it to do anything else, the reflex finishes by processing PlayersController#index, passing the updated players instance variable along the way and using morphdom to update the content of the page as efficiently as possible.

So we can finish this article by deleting the form’s submit button and moving the reflex from the submit event on the form to the input event on the text field, right?

Not so fast.

While what we have “works”, our implementation is currently inefficient and hard to maintain and expand. Future developers will have to piece together what’s going on in search. We’re also re-rendering the entire HTML body even though we know that only a small part of the page actually needs to change.

We can do a little better.

Using Selector Morphs

The magical re-processing of the index action happens because the default behavior of a reflex is to trigger a full-page morph when a reflex method runs.

While page morphs are easy to work with, we can be more explicit about our intentions and more precise in our updates by using selector morphs.

Selector morphs are more efficient than page morphs because selector morphs skip routing, controller actions, and template rendering. Selector morphs are also more clear in their intention and easier to reason about since we know exactly what will change on the page when the reflex runs.

Full page morphs are powerful and simple to use, but my preference is to use selector morphs when the use case calls for updating small portions of the page.

Let’s replace the magical page morph with a selector morph.

First, as you might have guessed, selector morphs use an identifier to target their DOM changes. We’ll add an id to the <tbody> in the players partial to give the selector morph something to target.

<tbody id="players-list">
<!-- Snip -->
</tbody>

Next we’ll update the search form:

<%= form_with(url: players_path, method: :get) do |form| %>
  <%= form.label :query, "Search by name:" %>
  <%= form.text_field :query, value: params[:query], data: { controller: "player-search", action: "input->player-search#search" }  %>
<% end %>

Here we’ve scrapped the submit button and we’ve replaced the data-reflex on the submit button with a Stimulus controller directly on the query text field.

The player-search controller was created by the generator we ran earlier to create the PlayerSearch reflex, and we’ll fill in the Stimulus controller next:

import ApplicationController from './application_controller'

export default class extends ApplicationController {
  search() {
    this.stimulate('PlayerSearch#search')
  }
}

Here we’re inheriting from a Stimulus ApplicationController, which was automatically created by the stimulus_reflex:install task we ran at the beginning of this article. Since we’re inheriting from ApplicationController, we have access to this.stimulate, which we can use to trigger any reflex we like.

Why would we use a Stimulus controller instead of a data-reflex attribute on a DOM element?

Using a Stimulus controller gives us a little more flexibility and power than if we attach the reflex to the DOM directly, which we’ll explore in the next section.

Before we expand the Stimulus controller, let’s finish up the implementation of the selector morph by updating the PlayerSearch#search like this:

def search
  players = Player.where('name LIKE ?', "%#{params[:query]}%")
  morph '#players-list', render(partial: 'players/players', locals: { players: players })
end

Here we no longer need players to be an instance variable. Instead, we pass it in as a local to the players partial which the selector morph renders to replace the children of #players-list.

With this in place, we can refresh the page and start typing in the search form. If you’ve followed along so far, you should see that as you type, the content of the players table is updated.

If you check the server logs, you’ll see that instead of the controller action processing and the entire application layout re-rendering, the server only runs the database query to filter the players and then renders the players partial. Skipping routing and full page rendering dramatically reduces the amount of time and resources used to handle the request.

Expanding the Stimulus controller

Now we’ve got live search in place using a selector morph. Incredible work so far!

Let’s finish up by expanding the Stimulus controller to make the user experience a bit cleaner and learn a little more about StimulusReflex in the process.

First, searching on each keystroke isn’t ideal. Let’s adjust search to wait for the user to stop typing before calling the PlayerSearch reflex.

search() {
  clearTimeout(this.timeout)
  this.timeout = setTimeout(() => {
    this.stimulate('PlayerSearch#search')
  }, 200)
}

Nothing fancy here, and you should probably consider a more battle tested debounce function in production, but it’ll do for today.

Next, it would be nice to give the user a visual cue that the list of players has updated. One way to do that is to animate the list when it updates and StimulusReflex helpfully gives us an easy way to listen for and react to reflex life-cycle events.

beforeSearch() {
  this.playerList.animate(
    this.fadeIn,
    this.fadeInTiming
  )
}

get fadeIn() {
  return [
    { opacity: 0 },
    { opacity: 1 }
  ]
}

get fadeInTiming() {
  return { duration: 300 }
}

get playerList() {
  return document.getElementById('players-list')
}

Here we’re combining a custom StimulusReflex client-side life-cycle callback (beforeSearch) with the Web Animations API to add a simple fade effect to the players list each time it updates.

In addition to the client-side events, StimulusReflex provides server-side life-cycle callbacks, which we don’t have a use for in this particular article, but they exist if you need them.

Now we have visual feedback for users as they type. Let’s finish this article by allowing users to clear a search without having to backspace the input until its empty.

This last exercise will give us a chance to look at using more than one selector morph in a single reflex and to expand the Stimulus controller a bit more.

Resetting search results

Our goal is to add a link to the page that displays whenever the search text box isn’t empty. When a user clicks the link, the search box should be cleared, the players list should be updated to list all of the players in the database, and the reset link should be hidden.

We’ll start by adding a new partial to render the link:

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

And fill that in with:

<% if query.present? %>
  <a href="#" data-action="click->player-search#reset">Clear search</a>
<% end %>

The reset link will only display if the local query variable is present. Clicks on the link are routed to a player-search Stimulus controller, calling the reset function that doesn’t exist yet.

Before we update the Stimulus controller, let’s adjust the index view, like this:

<p id="notice"><%= notice %></p>
<h1>Players</h1>
<div data-controller="player-search">
  <%= form_with(method: :get) do |form| %>
    <%= form.label :query, "Search by name:" %>
    <%= form.text_field :query, data: { action: "input->player-search#search" }, autocomplete: "off", value: params[:query] %>
  <% end %>
  <div id="reset-link">
    <%= render "reset_link", query: params[:query] %>
  </div>
  <table>
    <thead>
      <tr>
        <th>Name</th>
        <th colspan="3"></th>
      </tr>
    </thead>
    <%= render "players", players: @players %>
  </table>
</div>
<br>

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

Here we’ve inserted the a new reset_link partial, wrapped in a #reset-link div.

More importantly, we’ve adjusted how the player-search Stimulus controller is connected to the DOM. Instead of the controller being attached to the search text field, the controller is now on a wrapper div.

While we didn’t have to make this change to the controller connection, doing so makes it clear that the controller is interested in more than just the text input and opens up the possibility of using targets to more specifically reference DOM elements in the future.

This change also gives us an opportunity to look at one more piece of functionality of StimulusReflex-enabled functionality in Stimulus controllers.

Update the Stimulus controller like this:

search(event) {
  clearTimeout(this.timeout)
  this.timeout = setTimeout(() => {
    this.stimulate('PlayerSearch#search', event.target.value)
  }, 200)
}

reset(event) {
  event.preventDefault()
  this.stimulate('PlayerSearch#search')
}

We’ve made two important changes here.

First, since the Stimulus controller is no longer inside of the search form, the search reflex will no longer be able to reference params implicitly. We handle this change by passing the value of the search box to stimulate as an additional argument. Stimulateis extremely flexible” and we take advantage of that flexibility to ensure the search reflex receives the search query even without access to the search form’s params.

Next, we added reset, which simply triggers the search reflex without an additional argument.

On the server side, we need to update PlayerSearch#search like this:

def search(query = '')
  players = Player.where('name LIKE ?', "%#{query}%")
  morph '#players-list', render(partial: 'players/players', locals: { players: players })
  morph '#reset-link', render(partial: 'players/reset_link', locals: { query: query })
end

Here we updated search to take an optional query argument. The value of query is used to set the value of players and then two selector morphs replace the content of players-list and reset-link.

In action, our final product looks like this:

A screen recording of a user on a web page with a list of player names below a search text box. The user types 'Dirk' in the search box and the list of player names is automatically filtered based on the search term. The user clicks a clear search link and the list of players resets while the text box is cleared.

An alternative approach

If you review the method signature of stimuluate, you’ll notice that we could have solved the problem of passing the value of the search box to the server in other ways.

Instead of passing in event.target.value, we could have passed event.target like this: this.stimulate(‘PlayerSearch#search, event.target).

This approach would override the default value of the server-side Reflex element, allowing us to call event.target.value to access the value of the search box from the server.

While this would work for the search function, it wouldn’t work for reset since we need to ignore the value of the search box when resetting the form. We could make it all work by passing an element to override the default element assignment, but it would take more effort.

Passing in the value explicitly allows us to use PlayerSearch#search to handle both search and reset requests and keeps our code a bit cleaner on the server side.

This is a matter of preference without a definitive answer on which approach is “best”. Implementing a solution overriding element on the server side would work fine. Also viable would be using an entirely different reflex action for the reset link.

StimulusReflex offers plenty of flexibility, and some choices will come down to what feels best to you and your team.

Wrapping up

Today we looked at implementing a simple search-as-you-type interface with Ruby on Rails and StimulusReflex. This simple example should give you some indication of the power StimulusReflex has to deliver modern, fast web applications while keeping code complexity low and developer happiness high.

Even better, StimulusReflex plays nicely with Turbo Drive and Turbo Frames, giving developers the ability to mix-and-match to choose the best tool for the job.

To keep learning about building Rails applications with StimulusReflex:

As always, thanks for reading!

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.