Implementing Instant Search, Dynamic Forms, and Infinite Scroll with Hotwire and Turbo in Rails

Despite Hotwire's growing popularity, many developers struggle with implementing it correctly. Common pitfalls lead to broken interactions, performance bottlenecks, or unmaintainable code. In this guide, I'll walk you through the idiomatic integration of Hotwire for the most common use case: a browse page with instant search, infinite scrolling, dynamic per-record actions, and cursor-based pagination—all with minimal JavaScript and maximum performance. TL;DR: This post shows you how to build an interactive employee directory with instant search, dynamic forms, and infinite scrolling using just Hotwire (minimal JS). 5-Minute Quick Start Guide For the impatient developers, here's the executive summary: Create Search Form with Dynamic Fields: Encapsulate search logic outside your controller Structure your HTML with Turbo Frames: Wrap your search form and results table in frames Add auto-submission with Stimulus: Create a 5-line controller to debounce input events Setup infinite scrolling: Use auto-loading next pages with lazy loading turbo frames Respond with Turbo Streams: Update just what changed, not the entire page Now let's dive into the details! Understanding When to Use Turbo Frames vs. Streams Before we implement anything, let's clarify which Hotwire tool fits each job: When to use Turbo Frames When to use Turbo Streams Updating a single, isolated section Updating multiple parts of the page at once Natural parent-child relationships Non-hierarchical updates (flash messages) Simple replacements Complex operations (append, prepend, remove) Lazy-loading content Real-time updates from broadcasts For our search functionality, we'll use both: frames for the overall structure and streams for the dynamic updates. The Technical Patterns We'll Cover This guide focuses on implementing these key patterns with minimal JavaScript: Instant search with on-keyup events Dynamic filtering with dropdowns Efficient pagination without page reloads Flash message updates Conditional action links and toggles We'll use an employee listing page as our practical example. 1. The Search: Form Object Pattern in 3 Steps Let's be honest—no one wants a 200-line controller method full of filtering logic. Here's how we corral that filter chaos into a single object: # app/models/employee/filter.rb class Employee::Filter include ActiveModel::Model PERMITTED_PARAMS = [:query, :team_id, :status, :manager_id] attr_accessor *PERMITTED_PARAMS def initialize(attributes = {}) super(attributes || {}) end def apply(scope) scope .then { |s| query.present? ? s.search(query) : s } .then { |s| team_id.present? ? s.where(team_id:) : s } .then { |s| status.present? ? s.where(status:) : s } .then { |s| manager_id.present? ? s.where(manager_id:) : s } end def to_param { query: query, team_id: team_id, status: status, manager_id: manager_id }.compact # Removes nil values end def blank? to_param.empty? end end

Mar 26, 2025 - 21:50
 0
Implementing Instant Search, Dynamic Forms, and Infinite Scroll with Hotwire and Turbo in Rails

Despite Hotwire's growing popularity, many developers struggle with implementing it correctly. Common pitfalls lead to broken interactions, performance bottlenecks, or unmaintainable code. In this guide, I'll walk you through the idiomatic integration of Hotwire for the most common use case: a browse page with instant search, infinite scrolling, dynamic per-record actions, and cursor-based pagination—all with minimal JavaScript and maximum performance.

TL;DR: This post shows you how to build an interactive employee directory with instant search, dynamic forms, and infinite scrolling using just Hotwire (minimal JS).

example of the interactive application

5-Minute Quick Start Guide

For the impatient developers, here's the executive summary:

  1. Create Search Form with Dynamic Fields: Encapsulate search logic outside your controller
  2. Structure your HTML with Turbo Frames: Wrap your search form and results table in frames
  3. Add auto-submission with Stimulus: Create a 5-line controller to debounce input events
  4. Setup infinite scrolling: Use auto-loading next pages with lazy loading turbo frames
  5. Respond with Turbo Streams: Update just what changed, not the entire page

Now let's dive into the details!

Understanding When to Use Turbo Frames vs. Streams

Before we implement anything, let's clarify which Hotwire tool fits each job:

When to use Turbo Frames When to use Turbo Streams
Updating a single, isolated section Updating multiple parts of the page at once
Natural parent-child relationships Non-hierarchical updates (flash messages)
Simple replacements Complex operations (append, prepend, remove)
Lazy-loading content Real-time updates from broadcasts

For our search functionality, we'll use both: frames for the overall structure and streams for the dynamic updates.

The Technical Patterns We'll Cover

This guide focuses on implementing these key patterns with minimal JavaScript:

  1. Instant search with on-keyup events
  2. Dynamic filtering with dropdowns
  3. Efficient pagination without page reloads
  4. Flash message updates
  5. Conditional action links and toggles

We'll use an employee listing page as our practical example.

1. The Search: Form Object Pattern in 3 Steps

Let's be honest—no one wants a 200-line controller method full of filtering logic. Here's how we corral that filter chaos into a single object:

# app/models/employee/filter.rb
class Employee::Filter
  include ActiveModel::Model

  PERMITTED_PARAMS = [:query, :team_id, :status, :manager_id]

  attr_accessor *PERMITTED_PARAMS

  def initialize(attributes = {})
    super(attributes || {})
  end

  def apply(scope)
    scope
      .then { |s| query.present? ? s.search(query) : s }
      .then { |s| team_id.present? ? s.where(team_id:) : s }
      .then { |s| status.present? ? s.where(status:) : s }
      .then { |s| manager_id.present? ? s.where(manager_id:) : s }
  end

  def to_param
    {
      query: query,
      team_id: team_id,
      status: status,
      manager_id: manager_id
    }.compact # Removes nil values
  end

  def blank?
    to_param.empty?
  end
end