How to build responsive search with Turbo Frames and Stimulus
Hi there,
This one is about Hotwire. I'm building a super simple responsive search using Turbo Frames and Stimulus.
Specifically using data-turbo-frame
on a <form>
element to update a search results section on the page.
We also add search-as-you-type with a little bit of JavaScript in a Stimulus controller.
This post is beginner friendly. If you're new to Hotwire, I focus on being thorough and don't skip over anything.
Sidenote: Using Turbo Frames is one way to build this search. With Hotwire there're usually multiple evolving ways to implement something. We can also build search with a Turbo Stream response (When Hotwire was first released, it wan't possible to respond with Turbo Streams to GET
requests. That's not the case anymore. This github issue has some history if you're curious). And of course, the upcoming Turbo 8 morphing will simplify things and minimize code changes needed for common use cases. Regardless, it's worth understanding how to build search with Turbo Frames and learn more about the src
attribute.
Here's a screenshot of what this search looks like (I was going to add a gif but that didn't work within email. It's on my twitter profile):
Let's get into how to build this responsive search:
So if we do nothing, when we submit a search form, the default experience with Rails 7 is that Turbo Drive will swap out the entire html
body with the response from the server. It does not do a full page reload. Which is good but the search input field will still loose focus. And that's not so nice for the user.
We can progressively enhance this behavior by introducing a Turbo Frame around the search results area on the page. This will allow the search input to retain focus, so that the user can keep typing.
Note that we'll only put the search results into a turbo-frame
element. We can't put the search form inside that turbo-frame
as well because the entire turbo-frame
will get updated with the response and the input field will still loose focus.
Let me show you what I mean:
Responsive Search with Turbo Frames
In this example, we're going to search a list books by their titles. The actual search is simply an ActiveRecord
query. Not worrying about doing anything fancy there.
The interesting part is the search form, that's at the top of our books's index.html.erb
view.
We modify the default index
view to add search with these 3 steps:
- Wrap the search results in a
turbo-frame
element and give it a uniqueid
, as in<%= turbo_frame_tag "search_results" do %>
below. - Add a search form and target that
turbo-frame
element from the searchform
element by addingdata-turbo-frame = "search_results"
attribute to theform
element. - To update the url each time a search form is submitted, add the
data-turbo-action = "advance"
attribute to theform
element as well. This tells the browser to append the new url to the browser's history. This improves the UX as it allows a user to bookmark a search url and use the browser back button.
Here's what the index.html.erb
view looks like with those changes:
<div class="search">
<%= form_with(url: books_path, method: :get,
data: { turbo_frame: "search_results", turbo_action: "advance" }) do |f| %>
<div>
<%= f.search_field :title, placeholder: 'Title...',
value: params[:title] %>
</div>
...
<div>
<%= f.submit "Find Books" %>
</div>
<% end %>
</div>
<%= turbo_frame_tag "search_results" do %>
<div class="books">
<%= render @books %>
</div>
<% end %>
The controller can simply have an index
action that does the search using user submitted params
and renders the results using the default index.html.erb
view. Or selects all books if there are no search params.
def index
@books = Book.search(params)
end
The search
method on the Book
model simply does an Active Record query on the book's title, something like Book.where("lower(title) LIKE ?", "%#{title.downcase}%")
. We won't focus on that part as it's not relevant.
With these code changes in place, let's see how the search form with this data-turbo-frame
attribute actually works.
How targeting a turbo-frame
from a form
works
When a form
element that targets a turbo-frame
with data-turbo-frame
attribute is submitted, it actually doesn't submit the request to the server.
What it does is that it adds a src
attribute to the target turbo-frame
, with the value equal to the search url
. In this case something like
<turbo-frame id="search_results" src="http://localhost:3000/books?title=beyond" complete="">
And when a turbo-frame
has a src
attribute, it automatically requests the specified url. We can see this in the dev tools network tab.
An http
request header named Turbo-Frame
with the value of search_results
is also added to that request.
The index
action of the Books controller will handle that request. The index
view, that's rendered in response to the search form submission, will indeed contain a turbo-frame
element with a matching value as in <turbo-frame id="search_results">
.
The content of that turbo-frame
in the response are the books that match the search criteria. They'll be extracted from the response and used to update just the search results part of the page, the <turbo-frame id="search_results">
element. (The rest of the html
in the response will be ignored).
Hence completing this dance.
All this while not touching the search form and therefore keeping the focus on the search input. Which is the better UX that we were after.
We can leave things here and that's better than the default.
But the next obvious enhancement is to update the search results as the user types, without having to hit enter or click a button.
Search-as-you-type with Stimulus
In order to allow users to search as they type, we need to submit the search form automatically. We can use a Stimulus controller to do this. (I go over stimulus in a previous post, in case you'd like a refresher it's here)
We create a Stimulus controller called search_form_controller.js
in app/javascript/controllers
with the command rails g stimulus search-form
.
This controller is attached to our <form>
element and has an action called submit
that submits the form on user input
.
<%= form_with(url: books_path, method: :get, data: { turbo_frame: "search_results", turbo_action: "advance", controller: "search-form", action: "input->search-form#submit" }) do |form| %>
The two new attributes we're attaching is data-controller
and data-action
.
The controller looks like this:
import { Controller } from "@hotwired/stimulus"
import debounce from "debounce";
export default class extends Controller {
initialize() {
this.submit = debounce(this.submit.bind(this), 350)
}
submit() {
this.element.requestSubmit();
}
}
In addition to the submit
method, this stimulus controller also has a little debounce functionality using a library to avoid sending excessive network requests as the user types. Instead of calling submit
for every input
event, it'll wait for 350ms (in this case) since the last request.
And that's all that's needed to build our responsive search functionality.
Turbo Frame src
Attribute
It's worth calling out that the entire functionality of our search hinges on adding this src
attribute with the desired url
to the search_results
Turbo Frame.
The Turbo Frame src
attribute is really powerful. We can use it to lazy load turbo-frames
for other use cases as well.
Updating the src
attribute even works if we change its value from the dev tools. Try it out, it's cool.
One last thing before we wrap up: when we add a Turbo Frame or a Turbo Stream, it does add a bit of complexity to our application. In this case, the trade-off is worthwhile to allow a more responsive search experience for the user.
Until next time,
Bhumi