import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["input", "frame"]
static values = { url: String, delay: { type: Number, default: 300 }, min: { type: Number, default: 2 } }
connect() {
this.timeout = null
}
disconnect() {
clearTimeout(this.timeout)
}
search() {
clearTimeout(this.timeout)
this.timeout = setTimeout(() => this.reload(), this.delayValue)
}
reload() {
const query = this.inputTarget.value.trim()
if (query.length < this.minValue) {
this.frameTarget.removeAttribute("src")
this.frameTarget.src = "about:blank"
return
}
const url = new URL(this.urlValue, window.location.origin)
url.searchParams.set("q", query)
this.frameTarget.src = url.toString()
}
}
class SuggestionsController < ApplicationController
MIN_LENGTH = 2
MAX_RESULTS = 8
def index
return head(:no_content) unless turbo_frame_request?
query = params[:q].to_s.strip
@products =
if query.length >= MIN_LENGTH
Product
.where("name ILIKE :q", q: "%#{sanitize_sql_like(query)}%")
.order(:name)
.limit(MAX_RESULTS)
else
Product.none
end
render partial: "suggestions/results", locals: { products: @products, query: query }
end
private
def sanitize_sql_like(value)
ActiveRecord::Base.sanitize_sql_like(value)
end
end
<div data-controller="search"
data-search-url-value="<%= suggestions_path %>"
data-search-delay-value="300">
<label for="product-search">Search products</label>
<input type="search"
id="product-search"
autocomplete="off"
placeholder="Start typing…"
data-search-target="input"
data-action="input->search#search">
<%= turbo_frame_tag "suggestions_results",
src: nil,
loading: :lazy,
data: { search_target: "frame" } do %>
<p class="hint">Type at least two characters to see suggestions.</p>
<% end %>
</div>
<%= turbo_frame_tag "suggestions_results" do %>
<% if products.any? %>
<ul class="suggestions" role="listbox">
<% products.each do |product| %>
<li role="option">
<%= link_to product_path(product), class: "suggestion" do %>
<span class="name"><%= highlight(product.name, query) %></span>
<span class="meta"><%= number_to_currency(product.price) %></span>
<% end %>
</li>
<% end %>
</ul>
<% else %>
<p class="empty">No matches for "<%= query %>".</p>
<% end %>
<% end %>
This snippet wires up a live search-suggestions box in Rails using Hotwire, keeping the network chatty-ness under control with a debounced input and Turbo Frame lazy loading. The core idea is separation of concerns: the browser only signals intent to search, and the actual result rendering is deferred to a lazily-loaded frame that Turbo fetches on demand.
In search_controller.js, a small Stimulus controller debounces keystrokes so a request only fires after the user pauses typing (300ms). Rather than issuing an AJAX call itself, it simply rewrites the src attribute of a Turbo Frame to point at the suggestions endpoint with the current query. Because a Turbo Frame reloads whenever its src changes, this delegates the fetch, swap, and morphing entirely to Turbo — no manual DOM manipulation. The debounce guards against a request storm; each new keystroke clears the pending timeout, so only the final query in a burst hits the server. Empty queries reset src to about:blank to avoid a needless round-trip.
In suggestions_controller.rb, the index action is a plain Rails action returning HTML. It trims the query, enforces a minimum length, and caps results with limit to keep the payload small and the query cheap. The frame_missing guard responds sensibly when the request arrives outside a frame. The controller renders the _results partial that lives inside a matching Turbo Frame, so Turbo can extract and swap just that fragment.
In _search.html.erb, the turbo_frame_tag uses loading: :lazy and src: nil, meaning it stays inert until Stimulus assigns a src. The nested turbo_frame_tag "suggestions_results" is the swap target whose id must match the frame rendered by the server response, which is how Turbo knows which fragment to replace.
The trade-off is a slightly heavier response than a JSON API, but the win is zero client-side templating and automatic progressive enhancement. Pitfalls to watch: frame ids must match exactly, limit and a minimum query length protect the database, and debouncing should be tuned to balance responsiveness against load. This pattern shines for typeahead, filtering, and any suggestion UI where server-rendered HTML is preferable to a JSON round-trip.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
Share this code
Here's the card — post it anywhere.