Building a Live Search Experience with StimulusReflex and Ruby on Rails
David Colby
Posted on August 28, 2021
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:
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. Stimulate
“is 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:
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:
- Dive into the (excellent, very well-maintained) official documentation
- Check out demo applications demonstrating some core StimulusReflex concepts
- Join the StimulusReflex discord to learn from lots of folks way sharper than me
- Learn more advanced usage patterns with StimulusReflexPatterns from Julian Rubisch
- (Shameless plug alert) Sign up for Hotwiring Rails, my monthly newsletter on the latest developments in Rails-land as we prepare for the launch of Rails 7
As always, if you have questions or feedback about this article, you can comment here, or you can find me on Twitter.
Thanks for reading!
Posted on August 28, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.