javascript ruby erb 96 lines · 4 tabs

Debounced Search Suggestions With a Turbo Frame Lazy-Loaded Results Partial

Shared by codesnips Aug 2026
4 tabs
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()
  }
}
4 files · javascript, ruby, erb Explain with highlit

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

Share this code

Here's the card — post it anywhere.

Debounced Search Suggestions With a Turbo Frame Lazy-Loaded Results Partial — share card
Link copied