Andy from Webcrunch

Subscribe for email updates:

Portrait of Andy Leverenz
Andy Leverenz

April 19, 2022

Last updated November 5, 2023

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.
Link this article
Est. reading time: 8 minutes
Stats: 9,898 views

Categories

Collection

Part of the Ruby on Rails collection