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!

Dynamic search with Hotwire

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:

Simple search results!

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:

  1. 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.
  2. We added an HTML form using the rails form_with helper, and given it the searches_index_path as the endpoint. We’ve also used HTML data attributes to let the form know which stimulus controller to connect to, the frame to send results to, and the target.
  3. We added an HTML input field form.search_field :query and again leveraging an HTML data attribute, we’re connecting the input box to the stimulus search controller search 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

Dynamic search demo with Hotwire

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