It’s very easy to add autocomplete search with turbo streams:

turbo streams autocomplete search nice UI

1. Initial setup #

# config/seeds.rb
100.times do
  Post.create(title: Faker::Movie.unique.title)
end
# console
rails g scaffold post title
bundle add faker
rails db:migrate
rails db:seed
# app/models/post.rb
class Post < ApplicationRecord
  validates :title, presence: true, uniqueness: true
end
  • add a search route. It should be a post request, so that we can respond with turbo_stream
# config/routes.rb
  resources :posts do
    collection do
      post :search
    end
  end
  • now you can create a search form that leads to the above route;
  • add <div id="search_results"></div> as a target where to render search results:
# app/views/posts/_search_form.html.erb
<%= form_with url: search_posts_path, method: :post do |form| %>
  <%= form.search_field :title_search, value: params[:title_search], oninput: "this.form.requestSubmit()" %>
<% end %>

<div id="search_results"></div>
  • add the search action in the controller;
  • here we find the posts that contain our search query;
  • we can render them with a turbo_stream in our search target
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def search
    @posts = Post.where('title ILIKE ?', "%#{params[:title_search]}%").order(created_at: :desc)
    respond_to do |format|
      format.turbo_stream do
          render turbo_stream: [
            turbo_stream.update("search_results",
            partial: "posts/search_results",
            locals: { posts: @posts })
          ]
      end
    end
  end
end
  • add a partial with the search results;
  • to make it sexy, highlight the matches:
# app/views/posts/_search_results.html.erb
<%= posts.count %>
<% posts.each do |post| %>
  <br>
  <%= link_to post do %>
    <%= highlight(post.title, params.dig(:title_search)) %>
  <% end %>
<% end %>

Now you can render the search form anywhere:

# app/views/posts/index.html.erb
<%= render "posts/search_form" %>

That’s it!

… or do you still want to go further?

3. Don’t make requests on empty input #

As @gvpmahesh suggested, In the above approach, if you clear your search input, you still query the database to return 0 results. Makes no sence!

  • if search query is empty - render empty array of posts:
# app/controllers/posts_controller.rb
  def search
    if params.dig(:title_search).present?
      @posts = Post.where('title ILIKE ?', "%#{params[:title_search]}%").order(created_at: :desc)
    else
      @posts = []
    end
    respond_to do |format|
      format.turbo_stream do
          render turbo_stream: [
            turbo_stream.update("search_results",
            partial: "posts/search_results",
            locals: { posts: @posts })
          ]
      end
    end
  end

4. Move query to model #

Let’s improve even more!

  • add a scope to the model:
# app/models/post.rb
  scope :filter_by_title, -> (title) { where('title ILIKE ?', "%#{title}%") }
  • add the scope to the controller:
# app/controllers/posts_controller.rb
  @posts = Post.filter_by_title(params[:title_search]).order(created_at: :desc)
  # @posts = Post.where('title ILIKE ?', "%#{params[:title_search]}%").order(created_at: :desc)

This approach is much more mature!

5. Debounce to limit number of queries #

To send fewer requests to the databse, you can add a stimulus controller to submit form with a 500ms delay.

  • add a stimulus controller that will submit the form with a delay:
// app/javascript/controllers/debounce_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = [ "form" ]

  connect() { console.log("debounce controller connected") }

  search() {
    clearTimeout(this.timeout)
    this.timeout = setTimeout(() => {
        this.formTarget.requestSubmit()
      }, 500)
  }
}
  • add the stimulus controller to the form data: { controller: 'debounce' }
  • add the submit target to the form data: { debounce_target: 'form' }
  • trigger the debounce#search on the input field data: { action: "input->debounce#search" }
# app/views/posts/_search_form.html.erb
<%= form_with url: search_posts_path, method: :post, data: { controller: 'debounce', debounce_target: 'form' } do |form| %>
  <%= form.search_field :title_search, value: params[:title_search], data: { action: "input->debounce#search" } %>
<% end %>

Final result: #

turbo streams autocomplete search basic

Final thoughts: Postgresql optimisation? #

Do we expect the posts table to get quite big?

I think that even if this table is small now, we may need to create two indexes for the title column.

One of type BTREE (for equality comparisons) and one of type GIN (for pattern matching).

For the latter, we will also need to add the pg_trgm extension first in a separate migration.