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
Photo by Jonny Caspari on Unsplash
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:
Improves initial page load time
Reduces server load by deferring non-critical content
Allows for independent caching of different page sections
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
Performance Monitoring: Implement monitoring for widget load times and failures to identify bottlenecks.
Progressive Enhancement: Ensure widgets work without JavaScript by providing meaningful fallback content.
Caching Strategy: Use appropriate cache keys and durations based on widget content volatility.
Error Boundaries: Implement proper error handling to prevent one widget's failure from affecting others.
Loading States: Design meaningful loading states that prevent layout shifts.
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.