<%= form_with url: projects_path, method: :get,
data: { controller: 'debounced-submit', turbo_frame: 'results' } do |f| %>
<%= f.search_field :q, value: params[:q], placeholder: 'Search…', class: 'w-full rounded border p-2' %>
<% end %>
<%= turbo_frame_tag 'results' do %>
<%= render 'results', projects: @projects %>
<% end %>
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = { delay: { type: Number, default: 250 } }
connect() {
this.timer = null
this.element.addEventListener("input", this.onInput)
}
disconnect() {
this.element.removeEventListener("input", this.onInput)
}
onInput = () => {
clearTimeout(this.timer)
this.timer = setTimeout(() => {
this.element.requestSubmit()
}, this.delayValue)
}
}
For search-as-you-type, I keep the server in charge and use a small Stimulus controller to debounce form submission. The controller listens to input events, waits ~250ms, then triggers a normal Turbo form submit. The server responds with index.turbo_stream.erb, replacing the results and the “X results” header. This avoids an API endpoint and keeps the query logic in one place (scope, includes, order). It’s also easy to add analytics or rate limiting later since everything is still a standard request. The biggest win is accessibility: it’s just a form, so it works with Enter key, browser autofill, and no-JS.