Infinite scroll with Phlex

Infinite Scroll with Phlex and Turbo Frames in Rails


Building infinite scroll pagination in Rails has traditionally required custom JavaScript with Intersection Observers, complex Stimulus controllers, or heavy JavaScript frameworks. But with Turbo's lazy-loading frames and Phlex components, we can build a clean, reusable infinite scroll system with almost no JavaScript.

The Stack

  • Rails 8+ with Turbo (Hotwire)
  • Phlex for view components
  • Pagy for pagination
  • Turbo Frames with loading: :lazy for automatic fetching

How It Works

The magic is in Turbo's loading: :lazy attribute on frames. When a lazy-loading frame enters the viewport, Turbo automatically fetches its src URL. We use this to:

  1. Render the initial page with a lazy-loading frame at the bottom
  2. When the frame becomes visible, Turbo fetches the next page
  3. The response appends new rows and replaces the frame with a new lazy-loading frame for the next page
  4. Repeat until there are no more pages

The Base Component

First, let's create a reusable base component that handles the Turbo Stream logic:

# app/components/infinite_scroll_stream.rb
module Components
class InfiniteScrollStream < Components::Base
include Phlex::Rails::Helpers::TurboFrameTag
include Phlex::Rails::Helpers::TurboStream

attr_reader :items, :pagy

def initialize(items:, pagy:)
@items = items
@pagy = pagy
end

def view_template
# Append new rows to the table body
turbo_stream.append(table_body_id) do
render_rows
end

# Replace the frame with a new lazy frame, or remove it when done
if pagy&.next
turbo_stream.replace(frame_id) do
turbo_frame_tag(frame_id, src: next_page_url, loading: :lazy) do
render_loading_indicator
end
end
else
turbo_stream.remove(frame_id)
end
end

private

# Subclasses must implement these methods
def table_body_id = raise NotImplementedError
def frame_id = raise NotImplementedError
def next_page_url = raise NotImplementedError
def render_row(_item) = raise NotImplementedError

def render_rows
items.each { |item| render_row(item) }
end

def render_loading_indicator
div(class: "flex justify-center py-8") do
div(class: "flex items-center gap-2 text-gray-500") do
span(class: "loading loading-spinner loading-md")
span { "Loading more..." }
end
end
end
end
end

Blog Post Example

Let's build infinite scroll for a blog posts listing.

The Stream Component

# app/views/posts/infinite_scroll_stream.rb
module Views
module Posts
class InfiniteScrollStream < Components::InfiniteScrollStream
def initialize(posts:, pagy:)
super(items: posts, pagy:)
end

private

def table_body_id = "posts-list"
def frame_id = "posts_load_more"
def next_page_url = posts_path(page: pagy.next, format: :turbo_stream)

def render_row(post)
article(class: "border-b border-gray-200 py-6") do
header(class: "mb-2") do
h2(class: "text-xl font-semibold") do
a(href: post_path(post), class: "hover:text-blue-600") { post.title }
end
p(class: "text-sm text-gray-500") do
"#{post.author.name} · #{post.published_at.strftime('%B %d, %Y')}"
end
end

p(class: "text-gray-700 line-clamp-3") { post.excerpt }

footer(class: "mt-3") do
a(href: post_path(post), class: "text-blue-600 hover:underline") do
"Read more →"
end
end
end
end
end
end
end

The Index Component

The initial page needs to render the first batch of posts and include the lazy-loading frame:

# app/views/posts/index.rb
module Views
module Posts
class Index < Views::Base
include Phlex::Rails::Helpers::TurboFrameTag

def initialize(posts:, pagy:)
@posts = posts
@pagy = pagy
end

def view_template
div(class: "max-w-3xl mx-auto px-4 py-8") do
h1(class: "text-3xl font-bold mb-8") { "Blog" }

# Container for posts with an ID for Turbo Stream appends
div(id: "posts-list") do
@posts.each do |post|
render_post(post)
end
end

# Lazy-loading frame for the next page
render_load_more_frame if @pagy.next
end
end

private

def render_post(post)
article(class: "border-b border-gray-200 py-6") do
header(class: "mb-2") do
h2(class: "text-xl font-semibold") do
a(href: post_path(post), class: "hover:text-blue-600") { post.title }
end
p(class: "text-sm text-gray-500") do
"#{post.author.name} · #{post.published_at.strftime('%B %d, %Y')}"
end
end

p(class: "text-gray-700 line-clamp-3") { post.excerpt }

footer(class: "mt-3") do
a(href: post_path(post), class: "text-blue-600 hover:underline") do
"Read more →"
end
end
end
end

def render_load_more_frame
turbo_frame_tag(
"posts_load_more",
src: posts_path(page: @pagy.next, format: :turbo_stream),
loading: :lazy
) do
div(class: "flex justify-center py-8") do
div(class: "flex items-center gap-2 text-gray-500") do
span(class: "loading loading-spinner loading-md")
span { "Loading more..." }
end
end
end
end
end
end
end

The Controller

The controller handles both HTML (initial page) and Turbo Stream (subsequent pages) requests:

# app/controllers/posts_controller.rb
class PostsController < ApplicationController
POSTS_PER_PAGE = 10

def index
@pagy, @posts = pagy(
Post.published.includes(:author).order(published_at: :desc),
limit: POSTS_PER_PAGE
)

respond_to do |format|
format.html do
render Views::Posts::Index.new(posts: @posts, pagy: @pagy)
end
format.turbo_stream do
render Views::Posts::InfiniteScrollStream.new(
posts: @posts,
pagy: @pagy
), layout: false
end
end
end
end

How the Flow Works

  1. Initial page load: User visits /posts, controller renders Views::Posts::Index with the first 10 posts and a lazy-loading frame pointing to /posts.turbo_stream?page=2
  2. User scrolls down: When the loading indicator enters the viewport, Turbo automatically fetches /posts.turbo_stream?page=2
  3. Turbo Stream response: The server responds with two Turbo Stream actions:
    • turbo_stream.append("posts-list") - Appends the next 10 posts
    • turbo_stream.replace("posts_load_more") - Replaces the frame with a new lazy frame for page 3
  4. Repeat: Steps 2-3 repeat until there are no more pages
  5. End of content: When there's no next page, we use turbo_stream.remove("posts_load_more") to remove the loading indicator

Adding Search Support

To support search queries that persist across pages:

# app/views/posts/infinite_scroll_stream.rb
module Views
module Posts
class InfiniteScrollStream < Components::InfiniteScrollStream
def initialize(posts:, pagy:, search_query: nil)
super(items: posts, pagy:)
@search_query = search_query
end

private

def next_page_url
params = { page: pagy.next, format: :turbo_stream }
params[:q] = @search_query if @search_query.present?
posts_path(params)
end

# ... rest of the implementation
end
end
end

Benefits of This Approach

  1. No custom JavaScript: Turbo handles all the fetching and DOM updates
  2. Progressive enhancement: Works without JavaScript (falls back to regular pagination)
  3. Reusable pattern: The base component can be used for any list/table
  4. Clean separation: Each stream component only knows about its own domain
  5. Type-safe views: Phlex gives us Ruby's type checking and IDE support

Gotchas

  1. Remember layout: false: Turbo Stream responses should not include the application layout
  2. Use .turbo_stream format: The URL must request the turbo_stream format
  3. Unique IDs: Each infinite scroll on a page needs unique table_body_id and frame_id values
  4. Include the right helpers: Don't forget Phlex::Rails::Helpers::TurboFrameTag and Phlex::Rails::Helpers::TurboStream

Conclusion

By combining Turbo's lazy-loading frames with Phlex's component model, we get a clean, maintainable infinite scroll implementation that requires zero custom JavaScript. The base component pattern makes it easy to add infinite scroll to any list in your application.

The key insight is that Turbo Frames with loading: :lazy give us automatic intersection observer behavior for free. We just need to structure our Turbo Stream responses to append content and replace the lazy frame with a new one for the next page.

Comments

Legal Information
By commenting, you agree to our Privacy Policy and Terms of Service.