#20 Turbo Streams: autocomplete search
It’s very easy to add autocomplete search with turbo streams:
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
2. Autocomplete search #
- 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: #
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.
Did you like this article? Did it save you some time?