Infinite scroll with Phlex

Mikael Henriksson
5 min read
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

Mikael Henriksson
Mikael Henrikssonabout 1 month ago

Testsing