Turbo Frames in Rails: A Complete Guide to Lazy-Loaded Components

Learn how to implement lazy-loaded widgets using Turbo Frames in Ruby on Rails. Discover practical examples with ViewComponent, Phlex, and Views

What is Lazy Loading with Turbo Frames?

Modern web applications need to be both performant and maintainable.

In Rails applications, we often face the challenge of loading multiple independent widgets or components on a single page.

While traditional server-side rendering loads everything at once, modern approaches favor lazy loading to improve initial page load times and resource utilization.

Let's explore how to implement this using Turbo Frames, and compare it with various view patterns in Rails.

Understanding the Core Concept

The idea is to break down a complex page into independent widgets that load separately through their own HTTP requests.

Instead of loading everything upfront, we load a lightweight shell of the page first, then fetch each widget's content asynchronously. This approach:

  1. Improves initial page load time

  2. Reduces server load by deferring non-critical content

  3. Allows for independent caching of different page sections

  4. Makes the application more maintainable through component isolation

Implementation Approaches

1. Basic Turbo Frame Implementation

Let's start with a simple dashboard that has multiple widgets:

# app/controllers/dashboard_controller.rb
class DashboardController < ApplicationController
  def show
    # Minimal data needed for the initial page load
  end

  def recent_activities
    @activities = Activity.recent.limit(5)
    render partial: 'recent_activities'
  end

  def stats_widget
    @stats = calculate_stats
    render partial: 'stats_widget'
  end

  private

  def calculate_stats
    {
      total_users: User.count,
      active_today: User.active_today.count
    }
  end
end
<%# app/views/dashboard/show.html.erb %>
<div class="dashboard-container">
  <h1>Dashboard</h1>

  <%= turbo_frame_tag "recent_activities", src: recent_activities_dashboard_path do %>
    <div class="loading-placeholder">
      Loading recent activities...
    </div>
  <% end %>

  <%= turbo_frame_tag "stats_widget", src: stats_widget_dashboard_path do %>
    <div class="loading-placeholder">
      Loading statistics...
    </div>
  <% end %>
</div>

2. Using ViewComponent for Widget Structure

ViewComponents provide a more object-oriented approach to building these widgets:

# app/components/widget_frame_component.rb
class WidgetFrameComponent < ViewComponent::Base
  def initialize(id:, src:, loading_text: "Loading...")
    @id = id
    @src = src
    @loading_text = loading_text
  end

  def call
    turbo_frame_tag @id, src: @src do
      render PlaceholderComponent.new(text: @loading_text)
    end
  end
end

# app/components/recent_activities_component.rb
class RecentActivitiesComponent < ViewComponent::Base
  def initialize(activities:)
    @activities = activities
  end

  def call
    content_tag :div, class: "activities-widget" do
      @activities.map do |activity|
        render ActivityItemComponent.new(activity: activity)
      end.join.html_safe
    end
  end
end

# app/components/activity_item_component.rb
class ActivityItemComponent < ViewComponent::Base
  def initialize(activity:)
    @activity = activity
  end

  def call
    content_tag :div, class: "activity-item", data: { activity_id: @activity.id } do
      content_tag :div, class: "activity-content" do
        safe_join([
          render_activity_icon,
          render_activity_details,
          render_activity_timestamp
        ])
      end
    end
  end

  private

  def render_activity_icon
    content_tag :div, class: "activity-icon" do
      icon_class = activity_icon_mapping[@activity.activity_type] || "default-icon"
      tag.i class: "fas #{icon_class}"
    end
  end

  def render_activity_details
    content_tag :div, class: "activity-details" do
      content_tag :p do
        sanitize activity_description
      end
    end
  end

  def render_activity_timestamp
    content_tag :div, class: "activity-timestamp" do
      time_tag @activity.created_at, class: "text-gray-500" do
        time_ago_in_words(@activity.created_at) + " ago"
      end
    end
  end

  def activity_description
    case @activity.activity_type
    when "comment"
      "#{@activity.user.name} commented on #{@activity.subject.title}"
    when "like"
      "#{@activity.user.name} liked #{@activity.subject.title}"
    when "follow"
      "#{@activity.user.name} started following #{@activity.subject.name}"
    else
      "#{@activity.user.name} performed an action"
    end
  end

  def activity_icon_mapping
    {
      "comment" => "fa-comment",
      "like" => "fa-heart",
      "follow" => "fa-user-plus",
      "share" => "fa-share",
      "post" => "fa-pen"
    }
  end
end

# Optional: Add a preview for testing in ViewComponent
# app/components/previews/activity_item_component_preview.rb
class ActivityItemComponentPreview < ViewComponent::Preview
  def default
    activity = OpenStruct.new(
      id: 1,
      activity_type: "comment",
      created_at: 2.hours.ago,
      user: OpenStruct.new(name: "John Doe"),
      subject: OpenStruct.new(title: "Sample Post")
    )

    render ActivityItemComponent.new(activity: activity)
  end

  def with_different_types
    types = %w[comment like follow share post]

    render_with_collection(
      types.map do |type|
        activity = OpenStruct.new(
          id: rand(1..100),
          activity_type: type,
          created_at: rand(1..24).hours.ago,
          user: OpenStruct.new(name: "User #{rand(1..10)}"),
          subject: OpenStruct.new(
            title: "Content #{rand(1..100)}",
            name: "Person #{rand(1..100)}"
          )
        )

        ActivityItemComponent.new(activity: activity)
      end
    )
  end
end

Usage in the dashboard:

<%# app/views/dashboard/show.html.erb %>
<div class="dashboard-container">
  <h1>Dashboard</h1>

  <%= render WidgetFrameComponent.new(
    id: "recent_activities",
    src: recent_activities_dashboard_path,
    loading_text: "Loading recent activities..."
  ) %>

  <%= render WidgetFrameComponent.new(
    id: "stats_widget",
    src: stats_widget_dashboard_path,
    loading_text: "Loading statistics..."
  ) %>
</div>

3. Phlex Implementation

Phlex offers a more Ruby-centric approach to building these components:

# app/views/widgets/base_widget.rb
module Widgets
  class BaseWidget < Phlex::HTML
    include Phlex::Rails::Helpers::TurboFrameTag

    def initialize(id:, src:, loading_text: "Loading...")
      @id = id
      @src = src
      @loading_text = loading_text
    end

    def template
      turbo_frame_tag @id, src: @src do
        div(class: "loading-placeholder") { @loading_text }
      end
    end
  end
end

# app/views/widgets/recent_activities.rb
module Widgets
  class RecentActivities < Phlex::HTML
    def initialize(activities:)
      @activities = activities
    end

    def template
      div(class: "activities-widget") do
        @activities.each do |activity|
          render ActivityItem.new(activity: activity)
        end
      end
    end
  end
end

# app/views/widgets/activity_item.rb
module Widgets
  class ActivityItem < Phlex::HTML
    include ActionView::Helpers::DateHelper  # For time_ago_in_words

    def initialize(activity:)
      @activity = activity
    end

    def template
      div(class: "activity-item", data: { activity_id: @activity.id }) do
        div(class: "activity-content") do
          render_activity_icon
          render_activity_details
          render_activity_timestamp
        end
      end
    end

    private

    def render_activity_icon
      div(class: "activity-icon") do
        icon_class = activity_icon_mapping[@activity.activity_type] || "default-icon"
        i(class: "fas #{icon_class}")
      end
    end

    def render_activity_details
      div(class: "activity-details") do
        p { plain activity_description }
      end
    end

    def render_activity_timestamp
      div(class: "activity-timestamp") do
        time(
          datetime: @activity.created_at.iso8601,
          class: "text-gray-500"
        ) { "#{time_ago_in_words(@activity.created_at)} ago" }
      end
    end

    def activity_description
      case @activity.activity_type
      when "comment"
        "#{@activity.user.name} commented on #{@activity.subject.title}"
      when "like"
        "#{@activity.user.name} liked #{@activity.subject.title}"
      when "follow"
        "#{@activity.user.name} started following #{@activity.subject.name}"
      else
        "#{@activity.user.name} performed an action"
      end
    end

    def activity_icon_mapping
      {
        "comment" => "fa-comment",
        "like" => "fa-heart",
        "follow" => "fa-user-plus",
        "share" => "fa-share",
        "post" => "fa-pen"
      }
    end
  end
end

# Optional: Add a preview for testing in Phlex
# app/views/widgets/previews/activity_item_preview.rb
module Widgets
  class ActivityItemPreview < Phlex::HTML
    def default
      activity = OpenStruct.new(
        id: 1,
        activity_type: "comment",
        created_at: 2.hours.ago,
        user: OpenStruct.new(name: "John Doe"),
        subject: OpenStruct.new(title: "Sample Post")
      )

      render Widgets::ActivityItem.new(activity: activity)
    end

    def with_different_types
      types = %w[comment like follow share post]

      div do
        types.each do |type|
          activity = OpenStruct.new(
            id: rand(1..100),
            activity_type: type,
            created_at: rand(1..24).hours.ago,
            user: OpenStruct.new(name: "User #{rand(1..10)}"),
            subject: OpenStruct.new(
              title: "Content #{rand(1..100)}",
              name: "Person #{rand(1..100)}"
            )
          )

          render Widgets::ActivityItem.new(activity: activity)
        end
      end
    end
  end
end

4. Caching Strategy Implementation

Adding caching to these widgets improves performance further:

# app/components/stats_widget_component.rb
class StatsWidgetComponent < ViewComponent::Base
  def initialize(stats:)
    @stats = stats
  end

  def call
    content_tag :div, class: "stats-widget" do
      render(cached_content)
    end
  end

  private

  def cached_content
    Rails.cache.fetch(['stats_widget', @stats.cache_key_with_version], expires_in: 5.minutes) do
      render partial: 'stats_widget/content', locals: { stats: @stats }
    end
  end
end

# app/controllers/dashboard_controller.rb
def stats_widget
  @stats = calculate_stats
  fresh_when(etag: @stats.cache_key_with_version)

  render StatsWidgetComponent.new(stats: @stats) unless performed?
end

5. Error Handling and Fallback Content

Implement robust error handling for widget loading:

# app/components/resilient_widget_component.rb
class ResilientWidgetComponent < ViewComponent::Base
  def initialize(id:, src:, fallback_content: nil)
    @id = id
    @src = src
    @fallback_content = fallback_content
  end

  def call
    content_tag :div, class: "widget-container" do
      turbo_frame_tag @id, src: @src, data: { 
        controller: "widget",
        action: "turbo:frame-error->widget#handleError"
      } do
        render PlaceholderComponent.new
      end
    end
  end
end

# app/javascript/controllers/widget_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  handleError(event) {
    this.element.innerHTML = `
      <div class="widget-error">
        <p>Failed to load widget content</p>
        <button data-action="click->widget#retry">Retry</button>
      </div>
    `
  }

  retry() {
    const frame = this.element.querySelector('turbo-frame')
    frame.reload()
  }
}

6. Advanced Loading Strategy with Dependencies

Sometimes widgets need to load in a specific order or depend on shared data:

# app/controllers/dashboard_controller.rb
class DashboardController < ApplicationController
  def show
    @loading_strategy = WidgetLoadingStrategy.new(current_user)
  end
end

# app/services/widget_loading_strategy.rb
class WidgetLoadingStrategy
  def initialize(user)
    @user = user
    @widget_configs = calculate_widget_configs
  end

  def widget_url_for(widget_name)
    config = @widget_configs[widget_name]
    return unless config[:enabled]

    url_helpers.send("#{widget_name}_dashboard_path", priority: config[:priority])
  end

  private

  def calculate_widget_configs
    {
      recent_activities: {
        enabled: @user.can_view_activities?,
        priority: 1
      },
      stats_widget: {
        enabled: @user.can_view_stats?,
        priority: @user.admin? ? 1 : 2
      }
    }
  end
end

Usage in the view:

<%# app/views/dashboard/show.html.erb %>
<div class="dashboard-container" data-controller="widget-loader">
  <% [:recent_activities, :stats_widget].each do |widget_name| %>
    <% if url = @loading_strategy.widget_url_for(widget_name) %>
      <%= render ResilientWidgetComponent.new(
        id: widget_name,
        src: url,
        fallback_content: render(WidgetFallbackComponent.new(widget_name))
      ) %>
    <% end %>
  <% end %>
</div>

Best Practices and Considerations

  1. Performance Monitoring: Implement monitoring for widget load times and failures to identify bottlenecks.

  2. Progressive Enhancement: Ensure widgets work without JavaScript by providing meaningful fallback content.

  3. Caching Strategy: Use appropriate cache keys and durations based on widget content volatility.

  4. Error Boundaries: Implement proper error handling to prevent one widget's failure from affecting others.

  5. Loading States: Design meaningful loading states that prevent layout shifts.

  6. Resource Management: Consider implementing request debouncing and cancellation for rapid navigation.

Conclusion

This approach to widget management using Turbo Frames provides several benefits:

  • Better initial page load performance

  • Improved maintainability through component isolation

  • Flexible loading strategies

  • Built-in caching capabilities

  • Progressive enhancement support

Whether using ViewComponent, Phlex, or traditional Rails views, the pattern remains powerful and adaptable.

The key is choosing the right abstraction level for your specific needs while maintaining consistency across the application.

The combination of Turbo Frames with modern view patterns gives us a robust foundation for building complex, performant web applications without sacrificing developer experience or code maintainability.