April 19, 2022
•Last updated July 11, 2024
Turbocharged real-time search with Ruby on Rails 7
Adding basic search functionality to a Ruby on Rails app is not the toughest task in the book but when you think about it before hotwire.dev was around the process of making "live" search work was rather cumbersome.
This guide is a very primitive example of creating a real-time search form for a resource in a Ruby on Rails 7 application. We'll use various components of the hotwire.dev ecosystem to achieve our goals.
Create a new app
Starting things off I'll create a new app and pass a few flags. You needn't do the same with the CSS and JS flags but this is what I tend to run for new apps.
rails new turbo_charged_search -T -c tailwind -j esbuild
Generate a Band
model
We need something to search for so I chose bands being a musician myself. Below I ran all lines in consecutive order in order to create the Band
model and boot up the Rails 7 app.
cd turbo_charged_search
rails g scaffold Band name:string
rails db:migrate
bin/dev
Update routes.rb
You should be greeted by the default rails landing page. We want to make our root route out bands#index
route. I'll update the config/routes.rb
to reflect this.
# config/routes.rb
Rails.application.routes.draw do
resources :bands
root "bands#index"
end
Update index view to include search form
Next, we need a search form. I added a custom form not normally found on index pages. This form features a URL
property. We need the form to respond to a GET
request as well so note the method: :get
options. Finally, we'll make use of turbo frames in this guide so I'll add them as a data attribute.
<%= form_with(url: bands_path, method: :get, data: {turbo_frame: "bands"}) do |form| %>
<%= form.label :query, "Search by band name:", class: "block mb-2" %>
<div class="flex space-x-3">
<%= form.text_field :query, class: "py-3 px-4 rounded border ring-0 focus:ring-4 focus:ring-orange-100 focus:shadow-none focus:border-orange-500 focus:outline-none" %>
<%= form.submit 'Search', class: "px-4 py-3 font-medium
bg-orange-300 text-neutal-900 rounded flex items-center cursor-pointer hover:bg-orange-400 focus:ring-4 ring-0 focus:ring-orange-100" %>
</div>
<% end %>
The search form features a text_field
we assign as :query
which we will extract on the controller layer.
Of course, we style the form using Tailwind CSS here. You can use custom CSS or another CSS framework if you prefer.
Change BandsController
index logic.
In the index
action inside the bands_controller.rb
file, we add a conditional around if the query comes back with some parameters or not. If so we can perform a new query using a SQL LIKE
comparison. This will return results with similar characters as what is entered in the form.
If not :query
parameter is present we'll display all the bands currently in the database by default.
def index
if params[:query].present?
@bands = Band.where("name LIKE ?", "%#{params[:query]}%")
else
@bands = Band.all
end
end
At this point, the form should work but it requires page loads and actual bands to be present. Let's start with the band's issue.
Create some bands
To move quickly I'll use rails console
to create a few bands to search through. Feel free to use your favorites here. These are completely random ones that came to my mind.
rails c
["Aerosmith", "Metallica", "Tool", "Led Zeppelin", "Killswitch Engage"].each do |band_name|
Band.create(name: band_name)
end
Realtime features
Because we want to re-render results in real time we need to add all listed bands within a new partial called _bands.html.erb
. We need to also wrap this with a turbo_frame_tag called "bands" to get this to function.
Add new bands partial in app/views/bands
.
<!-- app/views/bands/_bands.html.erb-->
<%= turbo_frame_tag "bands" do %>
<%= render bands %>
<% end >
Back in app/views/bands/index.html
we can now render the partial as a one-liner and pass through the instance of @bands
. Rails is smart enough to know to render this as a collection of records based on the naming conventions and file locations we are using.
Update app/views/bands/index.html.erb
<p style="color: green"><%= notice %></p>
<h1 class="font-bold text-3xl mb-3">Bands</h1>
<%= form_with(url: bands_path, method: :get, data: {turbo_frame: "bands"}) do |form| %>
<%= form.label :query, "Search by band name:", class: "block mb-2" %>
<div class="flex space-x-3">
<%= form.text_field :query, class: "py-3 px-4 rounded border ring-0 focus:ring-4 focus:ring-orange-100 focus:shadow-none focus:border-orange-500 focus:outline-none" %>
<%= form.submit 'Search', class: "px-4 py-3 font-medium bg-orange-300 text-neutal-900 rounded flex items-center cursor-pointer hover:bg-orange-400 focus:ring-4 ring-0 focus:ring-orange-100" %>
</div>
<% end %>
<!-- render new partial here -->
<div class="my-6">
<%= render "bands", bands: @bands %>
</div>
<%= link_to "New band", new_band_path, class: "px-4 py-3 font-medium bg-orange-300 text-neutal-900 rounded inline-flex items-center cursor-pointer hover:bg-orange-400 focus:ring-4 ring-0 focus:ring-orange-100" %>
Update band
partial:
The partial in app/views/bands/_band.html.erb
needs a little love now. I formatted this a bit and added a link to each band that will return in search results.
<!-- app/views/bands/_band.html.erb-->
<div id="<%= dom_id band %>">
<p class="text-lg leading-loose">
<%= link_to band.name, band_path(band), class: "text-orange-600 underline hover:text-orange-700" %>
</p>
</div>
Responding to turbo_frame_requests
Inside the bands_controller.rb
we need to make use of a turbo_frame_request?
method that ships with the turbo-rails
gem. This looks at the request.variant
property in Rails to determine why the "type" of request is coming back. In our particular case this will be a turbo_frame request based on the data: { turbo_frame: "bands" }
properties we added to the search form.
Now that we know the request type we can respond conditionally inside the index
action. Below I render the "_bands.html.erb" partial passing the @bands
instance variable through as a local variable.
Following these steps essentially allows for the real-time user experience to occur. This only happens upon clicking the submit button though. Can we enhance this more?
def index
if params[:query].present?
@bands = Band.where("name LIKE ?", "%#{params[:query]}%")
else
@bands = Band.all
end
# Not too clean but it works!
if turbo_frame_request?
render partial: "bands", locals: { bands: @bands }
else
render :index
end
end
Note: You could optionally check for a turbo_frame_request for every request in your application controller but that's mostly an extraction. In this case, we have more control over what exactly gets rendered.
Update URL for each form submission:
If you go to perform a search right now you may notice the URL never actually changes with each new search. To fix this you can add another data attribute to the form related to turbo
<%= form_with(url: bands_path, method: :get, data: {turbo_frame: "bands", turbo_action: "advance"}) do |form| %>
Automatically search as you type
Having to click to search is old-school. We can make this more real-time with a touch of JavaScript. Stimulus.js ships by default with Rails 7. We'll generate a new controller called search_form_controller.js
running the command below.
rails g stimulus search-form
create app/javascript/controllers/search_form_controller.js
rails stimulus:manifest:update
Remove the connect(){}
method and replace it with another called search()
. This will be called as a user triggers the input
event on the text field. Then we'll use JavaScript's setTimeout
function to submit the form every 200 milliseconds if the user triggers that input
event mentioned before. The clearTimeout
function is another native JavaScript function that does as advertised. Here we call it each time an input
event gets triggered. This acts as a looping mechanism for the life span of the input
event.
// application/javascript/controllers/search_form_controller.js
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="search-form"
export default class extends Controller {
search() {
clearTimeout(this.timeout)
this.timeout = setTimeout(() => {
this.element.requestSubmit()
}, 200)
}
}
To get the JavaScript working we need to follow some conventions of the Stimulus.js framework. A data-controller
element needs to surround all the code used to manipulate the UI. I added the data element to the form.
After that, I added a data element to the text_field
. This one is an action
which often represents some sort an event that takes place on an element in the DOM. Here we listen for the input
event and then target the stimulus controller search
method. (data: {action: "input->search-form#search"}
).
<%= form_with(url: bands_path, method: :get, data: {controller: "search-form", turbo_frame: "bands", turbo_action: "advance"}) do |form| %>
<%= form.label :query, "Search by band name:", class: "block mb-2" %>
<div class="flex space-x-3">
<%= form.text_field :query, class: "py-3 px-4 rounded border ring-0 focus:ring-4 focus:ring-orange-100 focus:shadow-none focus:border-orange-500 focus:outline-none", data: {action: "input->search-form#search"} %>
<%= form.submit 'Search', class: "px-4 py-3 font-medium bg-orange-300 text-neutal-900 rounded flex items-center cursor-pointer hover:bg-orange-400 focus:ring-4 ring-0 focus:ring-orange-100" %>
</div>
<% end %>
With those additions in place, we have ourselves a fancy real-time search form made with a sprinkle of JavaScript and a few turbo frames. Pretty neat!
Improvment ideas
Obviously, this example is very very primitive. There is a ton we could extract and reuse on the controller front, not to mention the gnarly query we used to search bands. The front end is messy and could use a make-over. The list goes on.
Some recommendations to extend this further might be:
- Use something like pg_search for better search functionality and performance
- Extract more logic from the controller to be used for other types of turbo_frame requests. A lot of that logic could move to the
ApplicationController
and become near automatic based on the request - Consider turbo streams as an alternative realtime updating mechanism.
Categories
Collection
Part of the Hotwire and Rails collection
Products and courses
-
Hello Hotwire
A course on Hotwire + Ruby on Rails.
-
Hello Rails
A course for newcomers to Ruby on Rails.
-
Rails UI
UI templates and components for Rails.