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

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