Instant search with Rails and Hotwire05 Aug 2021
Last year I wrote an article on building an instant search form with Rails and Stimulus. Since then, Turbo, the other half of Hotwire for the web has been released and the Hotwire stack is now the default for new applications in Rails 7.
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.
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 the Hotwire stack and you’ll find the most value from this article if you’re new to Turbo. While this article uses Stimulus, its core focus is on Turbo, and specifically how to use Turbo Frames in a Rails application.
Let’s get started.
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 these commands from your terminal. Your machine will need to be setup for standard Rails development, with Ruby and Rails. For reference, I’m writing this guide using Rails 7 and Turbo 7.1.
With the above commands run, we should have a new rails application up and running, and visiting https://localhost:3000/players should open a page that lists the players in your database and includes a link to create new 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:
Next update the
index action in the
PlayersController to process search form submissions:
At this point, our search form works — submit it and the page will reload with the list of players filtered to match your search query; however, each form submission has to be manually triggered and the entire page reloads instead of only rendering the new list of players. We won’t win any awards for the elegance of our code, but it works just fine and will serve as a simple base for us to build from.
Let’s start with adding rendering our search results with Turbo Frames first, and then we’ll wrap up by adding a Stimulus controller to submit the form automatically as the user types.
Adding Turbo Frames
Our goal is to use a Turbo Frame to update only the content of the list of players each time the form is submitted. To do this, we’ll first need to wrap the content we want to update in a
<turbo-frame> HTML tag.
We can use the
turbo_frame_tag helper method from
turbo-rails for this.
We’ll first create
players partial that renders the list of players wrapped in a turbo frame.
From your terminal:
And then fill that in with:
Here we’re rendering a collection of players, wrapped inside of a turbo frame tag.
Next update the players index to render the partial:
Finally, update the
With the turbo frame in place, refresh the page and see that it looks identical to what we had before but in the markup you’ll see a
<turbo-frame> element wrapping the list of players:
Now we’re wrapping our players in a Turbo Frame, but we’re not making use of that Turbo Frame on the server.
Rendering a Frame from the server
To do that, update the index action in the players controller to check the inbound request.
When the request targets a Turbo Frame, we’ll respond with just the Turbo Frame we want to update, otherwise we’ll render the full page:
This a bit a clunky (we’ll clean it up very soon!) but it demonstrates an important piece of how Turbo Frames work with Rails.
We’re using the built-in
turbo_frame_request? helper to check for a specific
turbo-frame header in the request from the browser.
When this header is present, our index action renders a partial that contains a matching Turbo Frame. Otherwise, we render the index page as normal.
Finally, we need to update our search form to tell Turbo that we want to target the
players Turbo Frame we just added:
Here we added the
turbo-frame data attribute. When using Turbo Frames, we can add this data attribute to tell Turbo that the response to the form submission (or link click) should target the given Turbo Frame.
This extra effort is necessary because the search form and the list of players are not wrapped inside of a single Turbo Frame.
When a form submission or link click occurs inside of a
<turbo-frame> element, Turbo will automatically attempts to retrieve and replace that frame’s content without any extra work from us.
While we could try wrapping the form inside of the
players Turbo Frame, doing so is undesirable. If a form is inside of a Turbo Frame, each time that frame is re-rendered, form inputs will lose their current values and any focus will be lost — not an ideal user experience.
Since we don’t want to wrap the form inside of the frame that contains our list of players, we need to explicitly tell Turbo that our form submission should update the
players Turbo Frame.
Cleaning up the Frame request
if turbo_frame_request? logic in the controller action is a bit messy. Thankfully we can clean this up a bit.
First, create a new turbo_frame view:
And fill that view in with:
Then update your
ApplicationController to automatically look for
turbo_frame views when a Turbo Frame request is received:
Here we’re using the same
turbo_frame_request? helper to set
request.variant on all inbound requests that contain a
With that update to the
ApplicationController, we can simplify the index action in the
Since the ApplicationController automatically sets the variant for us, individual controller actions no longer need to care about whether the request is for a Turbo Frame or not and Rails will invisibly render the right view for us.
With these changes in place, refresh the page, type in a search query and see that the page content updates as before… so how do we know it is working?
Check the server logs and see that when a form submission hits the server, Rails responds with
players partial content without re-rendering the application layout or the index view.
If things are set up correctly, you should see something like this:
rendering players/index.html+turbo_frame.erb in the logs which tells us everything is working as expected.
Updating the URL on form submission
A requirement for many search and filter experiences is updating the URL as the user makes selections so that they can bookmark or copy and paste a particular set of filters. While this isn’t really useful for our search form use case, it is important to know that Turbo makes it trivial to update the URL on a Turbo Frame request.
To do this, we can update our form like this:
Here we added the
turbo-action data attribute, giving it a value of
advance. This attribute tells Turbo to push the frame navigation into the browser’s history, updating the page’s URL in the process. This option is outlined in the Turbo documentation here and the PR that added the functionality provides full details on how it works.
Once this change is made, refresh the page, type in a search query and see that the URL updates in your browser. You can then copy/paste that URL into a new window and see that searching works during a full page visit as well (just remember to set the value of the input to the
params[:query] value if you go this route).
Automatic form submission
With our Turbo Frame powered form we already have a nice, efficient search experience in our application; however, it would be even better if the list of players could be updated as the user types.
To do this, we’ll add a small Stimulus controller that automatically submits the form as the user types.
To get started, generate the Stimulus controller with the built-in generator:
Then fill that controller (located at
search function waits for 200ms before calling
requestSubmit() on the element the controller is attached to in the DOM.
requestSubmit() requires a polyfill to work on Safari but Turbo helpfully includes that polyfill for us.
Next we’ll update the DOM to attach the Stimulus controller to the search form:
Here we added two data attributes, one
data-controller attribute to
<form> element, connecting our
FormSubmission controller. The other is a
data-action attribute, telling Stimulus to call
form-submission#search on each
input event emitted by the form’s text field.
With this in place, we can refresh the page and see that the form submits as we type, with a short delay to let the user pause typing before submitting the form.
Here’s what the final experience looks like if you’ve been following along from start:
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 the Hotwire stack, 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:
- An empty state: The list partial could render different content when
- 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
For a deeper dive into Turbo Frames, you might enjoy my article dedicated to Turbo Frames. To learn about the other key component of Turbo, Turbo Streams, you can dig into my Turbo Streams on Rails article. To explore the full power of Turbo, the official Handbook and Reference docs are a good starting place.
While this example is not yet production-ready, I hope it gives you a good starting point into the world of Turbo & Stimulus-powered Rails applications. The next time you’re building something new, consider whether Rails + the Hotwire Stack might be enough to deliver the experience your users expect while keeping your team happy and productive.
As always, thanks for reading!