import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["input"]
static values = { wait: { type: Number, default: 300 }, url: String }
connect() {
this.timeout = null
this.abortController = null
}
submit() {
clearTimeout(this.timeout)
this.timeout = setTimeout(() => this.perform(), this.waitValue)
}
async perform() {
if (this.abortController) this.abortController.abort()
this.abortController = new AbortController()
const query = new URLSearchParams({ q: this.inputTarget.value }).toString()
try {
const response = await fetch(`${this.urlValue}?${query}`, {
headers: { Accept: "text/vnd.turbo-stream.html" },
signal: this.abortController.signal
})
const stream = await response.text()
Turbo.renderStreamMessage(stream)
} catch (error) {
if (error.name !== "AbortError") throw error
}
}
disconnect() {
clearTimeout(this.timeout)
if (this.abortController) this.abortController.abort()
}
}
<%= form_with url: products_path, method: :get,
data: {
controller: "search",
search_url_value: products_path,
search_wait_value: 250
} do |form| %>
<%= form.search_field :q,
value: params[:q],
autocomplete: "off",
placeholder: "Search products\u2026",
data: {
search_target: "input",
action: "input->search#submit"
} %>
<% end %>
<div id="results">
<%= render partial: "products/results", locals: { products: @products } %>
</div>
class SearchController < ApplicationController
def show
@products = Product
.where("name ILIKE ?", "%#{Product.sanitize_sql_like(params[:q].to_s)}%")
.order(:name)
.limit(25)
respond_to do |format|
format.turbo_stream do
render turbo_stream: turbo_stream.replace(
"results",
partial: "products/results",
locals: { products: @products }
)
end
format.html { render "products/index" }
end
end
end
This snippet shows how a live search box in a Hotwire app stays responsive without hammering the server or fighting Turbo. The concept is a debounced input that issues one Turbo Stream request per settled keystroke and lets Turbo render the results in place, while stale in-flight requests are cancelled so the last query always wins.
In search_controller.js, the Stimulus controller wires up a single input target and a debounced submit handler. Debouncing is done manually with setTimeout/clearTimeout rather than an external library so the delay is configurable through the wait value (static values = { wait: Number, url: String }). Each keystroke clears the pending timer, so the network call only fires once typing pauses for wait milliseconds. This trades a little latency for far fewer requests, which is the right trade for a search-as-you-type field.
The interesting part is coordinating with Turbo. Instead of submitting a real form and relying on the browser, the controller builds the query string and calls fetch with the Accept: text/vnd.turbo-stream.html header. Turbo's Turbo.renderStreamMessage then applies the returned <turbo-stream> actions directly, so the server controls exactly which DOM fragment updates. An AbortController per request cancels any earlier fetch still in flight; without this, a slow early response could overwrite a newer one, producing flicker or wrong results. The AbortError is swallowed on purpose since a cancelled request is expected, not a failure.
In _search.html.erb, the form is a normal, working form first: it has a real action and method so the feature degrades gracefully when JavaScript is disabled. The Stimulus data attributes layer the enhanced behavior on top, and data-action="input->search#submit" drives the debounce.
In search_controller.rb, the Rails controller answers both worlds. It responds to turbo_stream by rendering a stream that replaces the #results frame, and to html for the full-page fallback. The query is scoped and limited server-side. Reaching for this pattern makes sense whenever a filter must feel instant but the result markup should stay owned by the server rather than duplicated in JavaScript.
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.