Dynamic Search with Hotwire
Hotwire is an incredible framework for making rich, responsive, and snappy client experiences with the minimum of complexity. It leans on Rails standard concepts of convention over configuration and omakase to deliver a simple developer experience without any trade off to performance or capabilities.
In this example I’ll describe how I added a very simple dynamic search feature to a side project I’ve been working on, with a trivial amount of code and effort.
The project is trying to make it easy for me to cook delicious, healthy recipes with fresh local ingredients from my neighbourhood shops. Addressing a similar space to the numerous recipe box delivery services, but with the idea you could walk to your local shops to pick up the ingredients instead of having them shipped, and at the same time support your local community businesses.
Part of this service of course is the recipes, and when I thought about implementing a search I really wanted to provide that instant feedback as the user typed, to filter down recipes based on title and ingredients that match the search term letter by letter. Here’s what the finished version looks like, and next we’ll get in to the details!
Start Point
This example assumes you have a Rails 7 app up and running and you’re adding search capabilities to it. In my case, I
have a Recipe
model and some recipe data to search through. My thinking was you’ll be trying this in your own app
but see the footnote at the end of this article for a quick cheat-sheet to set up a new rails app with some recipes
if you just want to try it out.
The Plan
The idea then, is to add a new search
endpoint that receives a search term, and returns a list of
recipes that match that term in the title. We want to render those results in a nice drop down that
updates dynamically as we type, so we can narrow down our thoughts. Nice!
1. Search Endpoint
Firstly, for the new search endpoint we’re going to need a new controller and a route to map a URL request to the controller, and lastly a way to search our data and return results. We can use a Rails generator to create our controller and view as follows:
rails g controller searches index
And then we’ll add the logic for the index
action in app/controllers/searches_controller.rb
that
was generated above:
class SearchesController < ApplicationController
def index
if params.dig(:query).present?
@recipes = Recipe.containing(params[:query])
else
@recipes = []
end
end
end
This action is simply saying if there’s a query
parameter in the request, search our recipes for those
that match the query, and assign them to the @recipes
instance variable. Otherwise set @recipes
to be
an empty array, ie. no results []
.
The eagle-eyed amongst you might be wondering where this containing
method on recipe comes from. And
you’d be right! We need to implement that, so lets add a scope
to our Recipe
model now:
class Recipe < ApplicationRecord
validates :title, presence: true
scope :containing, -> (query) { where("title LIKE ?", "%#{query}%") }
end
Note! This is a rather naive implementation of search, but will gt you surprisingly far for the first version of your project and something I always start with.
Lastly for the endpoint, we need to render out the recipes that matched our search term with links so the
user can see the results and choose one if they like. We’ll add this in a new app/views/searches/index.html.erb
file:
<ul>
<% @recipes.each do |recipe| %>
<li>
<%= link_to recipe.title, recipe_path(recipe) %>
</li>
<% end %>
</ul>
OK. So now we have the search endpoint up and running and you should be able to test this in the browser
by visting localhost:3000/searches/index?query=spinach
. Assuming you have some recipes with “spinach” in
the title, or whatever model you are implementing this search with, you should see something like below.
Basically a simple list of text results that matched the query:
2. Hotwire Magic
Now the fun part. We want to add a search input box on the page, and when a user starts typing, dynamically show the matching results in a dropdown directly below. This should update and refresh as the user keeps typing or refining their search term.
We’ll accomplish this by sending a request to the /searches/index
endpoint when the user types in the input box.
Then we will capture the results from the server and render them in a dropdown div, without any page refresh.
As the user continues to type or edit, we’ll update and replace this div dynamically.
There are a few concepts going on here I’ll explain, so lets first add the following code
to app/views/layouts/application.html.erb
:
<div>
<%= form_with(url: searches_index_path(turbo_frame: "search_results"),
method: "get",
data: { "turbo-frame": "search_results",
controller: "search",
"search-target": "form" }) do |form| %>
<%= form.search_field :query,
placeholder: "Search Recipes",
autocomplete: "off",
data: { action: "input->search#search" } %>
<% end %>
<turbo-frame id="search_results" target="_top"></turbo-frame>
</div>
There are 3 main things going on here:
- We added an empty
<turbo-frame>
HTML element with an id of “search_results”. This is the target for our results that come back from the server to show the recipes that match the user’s search term. - We added an HTML form using the rails
form_with
helper, and given it thesearches_index_path
as the endpoint. We’ve also used HTMLdata
attributes to let the form know which stimulus controller to connect to, the frame to send results to, and the target. - We added an HTML input field
form.search_field :query
and again leveraging an HTMLdata
attribute, we’re connecting the input box to the stimulussearch
controllersearch
function, which will get called whenever there’s an input entered to the box.
Next, lets generate the stimulus controller referenced in the form, that will attach the input box to the search action:
rails g stimulus search
Then open up the generated stimulus controller at app/javascript/controllers/search_controller.js
and replace the generated code with the following:
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["form"];
search() {
clearTimeout(this.timeout);
this.timeout = setTimeout(() => {
this.formTarget.requestSubmit();
}, 200);
}
}
The main thing here is we’re submitting the form whenever there’s input sent from the box via
this.formTarget.requestSubmit()
. There’s a little extra here where we’re setting a timeout, just so
we don’t unnecessarily swamp the server with requests. Sending the input every 200ms is a good
start point and a great user experience.
Lastly, we need to mark the turbo-frame that we will be replacing with the results from search. This is part of the hotwire genius that can replace just that section of the page.
To do this, we just need to wrap the <ul> we previously added to app/views/searches/index.html.erb
with a <turbo-frame>
tag as follows:
<turbo-frame id="search_results">
<ul>
<% @recipes.each do |recipe| %>
<li>
<%= link_to recipe.title, recipe_path(recipe) %>
</li>
<% end %>
</ul>
</turbo-frame>
Demo based on the example code
Conclusion
I skipped any styles and kept the markup as basic as possible to focus on the hotwire details, but hopefully from this brief example, you were able to follow along and see just how trivial it is to leverage hotwire and stimulus in your rails apps to add very rich, and dynamic client side interactions.
To learn more about Hotwire I recommend reading the docs over at hotwired.dev or feel free to get in touch if you have questions or want to hear more.
Footnote
As mentioned above, my expectation was you would be adding dynamic search to your own app, so you would have an
existing app and models for Location
or Book
or whatever, but just in case you want to quickly try something,
run these commands to bootstrap a fresh demo app with some example recipes:
# create a new default Rails app
bin/rails new turbo-demo
cd turbo-demo
bin/setup
# create the Recipe model and controllers
bin/rails g resource Recipe title:string
bin/rails db:migrate RAILS_ENV=development
# open up the rails console and create some example recipes
bin/rails c
irb> Recipe.create!(title: "Spinach, Tomato and Cheese Omelette")
irb> Recipe.create!(title: "Pan Fried Salmon with Spinach")
irb> exit
# start the rails server
bin/dev