import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["form"]
static values = { delay: { type: Number, default: 250 } }
connect() {
this.timeout = null
}
disconnect() {
if (this.timeout) clearTimeout(this.timeout)
}
submit() {
if (this.timeout) clearTimeout(this.timeout)
this.timeout = setTimeout(() => {
this.formTarget.requestSubmit()
}, this.delayValue)
}
}
<div data-controller="search" data-search-delay-value="300">
<%= form_with url: search_path,
method: :get,
data: { turbo_stream: true, "search-target": "form" } do |f| %>
<%= f.search_field :q,
value: params[:q],
autocomplete: "off",
placeholder: "Search products\u2026",
data: { action: "input->search#submit" } %>
<% end %>
<div id="results">
<%= render "search/results", products: @products %>
</div>
</div>
<%= turbo_stream.replace "results" do %>
<div id="results">
<%= render "search/results", products: @products %>
</div>
<% end %>
class SearchController < ApplicationController
def index
scope = Product.order(:name)
if params[:q].present?
term = "%#{params[:q].strip}%"
scope = scope.where("name ILIKE :q OR sku ILIKE :q", q: term)
end
@products = scope.limit(20)
respond_to do |format|
format.html
format.turbo_stream
end
end
end
This snippet shows how a debounced live-search box streams filtered results back into the page without a full navigation, using Hotwire's Stimulus and Turbo Streams. The pattern replaces ad-hoc jQuery keyup handlers with a small, declarative controller that submits a form on input, while the server responds with a turbo_stream that replaces only the results list. It is the go-to approach in modern Rails when a search-as-you-type experience is wanted but a client-side SPA framework is overkill.
In search_controller.js, the Stimulus controller wires the input to a submit action. The core detail is debouncing: submit() clears any pending timeout and schedules a new one with setTimeout, so the form only fires after the user pauses typing for delayValue milliseconds. This avoids a request per keystroke, which would otherwise hammer the server and produce out-of-order responses. this.formTarget.requestSubmit() is used instead of submit() because it triggers Turbo's interception and validation, letting Turbo turn the request into a stream. The disconnect callback clears the timer to prevent a stray submit after the element leaves the DOM.
In search/index.html.erb, the form declares the controller via data-controller and binds input->search#submit on the field. Setting data: { turbo_stream: true } and method: :get makes Turbo request a stream response for the search query. The results live inside a turbo_frame / plain div with id: "results", which is the surgical target the server will later replace. Wrapping the results in a partial keeps the initial render and the streamed update identical.
In SearchController, index performs the query with a simple ILIKE scope and responds by format. For turbo_stream requests it renders search/index — but the crucial line is in index.turbo_stream.erb, where turbo_stream.replace "results" swaps only the results container. Because the server owns rendering, HTML stays consistent and there is no duplicated view logic in JavaScript.
The main trade-off is a round-trip per debounced keystroke rather than instant client filtering, but it keeps state authoritative on the server and works with pagination and permissions for free. A common pitfall is forgetting requestSubmit support in old browsers, or debouncing too aggressively and making search feel laggy; delayValue around 200-300ms is a reasonable default.
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
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
<form data-controller="query-sync" data-action="change->query-sync#apply">
<select name="status" class="rounded border p-2">
<option value="">Any</option>
<option value="open">Open</option>
<option value="closed">Closed</option>
</select>
Filter UI that syncs query params via Stimulus (no front-end router)
Share this code
Here's the card — post it anywhere.