In the evolving landscape of Ruby on Rails development, creating responsive, dynamic user interfaces without heavy JavaScript frameworks has become increasingly accessible. With the introduction of Hotwire (HTML Over The Wire) and subsequent innovations like Turbo Drive, Frames, Streams, and the newer Turbo Morph, developers now have multiple options for building interactive applications.
But which technique should you choose for your project? When should you use one over another? This guide will help you navigate these choices with practical examples.
Table of Contents
- Understanding the Fundamentals
- Turbo Drive: The Foundation
- Turbo Frames: Targeted Updates
- Turbo Streams: Real-time Updates
- Turbo Morph: Fine-grained DOM Updates
- Decision Framework: When to Use What
- Real-world Example: Building a Comment System
- Performance Considerations
- Interactive Tutorial
Understanding the Fundamentals
Before diving into specific techniques, let's understand the core philosophy: all these approaches aim to make web applications feel faster and more responsive by avoiding full page refreshes while keeping the development model server-centric.
Turbo Drive: The Foundation
What it is: Turbo Drive (formerly Turbolinks) accelerates navigation by converting traditional link clicks and form submissions into AJAX requests, replacing only the <body>
of the page and merging the <head>
.
When to use Turbo Drive:
- For traditional multi-page applications where you want improved navigation speed
- When you need minimal changes to existing code
- As a foundation for other Turbo features
Example:
# No special code needed! Turbo Drive works automatically with:
<%= link_to "View Product", product_path(@product) %>
# For forms:
<%= form_with model: @product do |form| %>
<%= form.text_field :name %>
<%= form.submit %>
<% end %>
With Turbo Drive, these standard Rails elements now deliver a smoother user experience without writing any JavaScript.
Live Demo:
Try clicking navigation links in this application - notice how the page transitions feel instant and there's no full page refresh.
Turbo Frames: Targeted Updates
What it is: Turbo Frames allow you to define independent sections of your page that can be updated independently of the rest of the content.
When to use Turbo Frames:
- When you need to update a specific section of the page
- For creating reusable components that load their own content
- To implement master-detail interfaces
- For forms that shouldn't navigate away from the current page
Example:
<%# app/views/products/index.html.erb %>
<h1>Products</h1>
<%= turbo_frame_tag "product_list" do %>
<div class="products">
<%= render @products %>
</div>
<% end %>
<%= turbo_frame_tag "product_detail" %>
<%# app/views/products/_product.html.erb %>
<div class="product">
<%= link_to product.name, product, data: { turbo_frame: "product_detail" } %>
</div>
<%# app/views/products/show.html.erb %>
<%= turbo_frame_tag "product_detail" do %>
<div class="product-detail">
<h2><%= @product.name %></h2>
<p><%= @product.description %></p>
<p>Price: $<%= @product.price %></p>
</div>
<% end %>
Live Demo:
Click on a product in the list, and only the detail panel updates, creating a smooth, app-like experience.
Turbo Streams: Real-time Updates
What it is: Turbo Streams allow the server to send HTML fragments that can add, replace, update, or remove elements on the page, often used with ActionCable for real-time updates.
When to use Turbo Streams:
- For real-time features like chat, notifications, or collaborative editing
- When you need to update multiple parts of the page simultaneously
- For optimistic UI updates with form submissions
Example:
# app/controllers/comments_controller.rb
def create
@comment = @post.comments.create!(comment_params)
respond_to do |format|
format.html { redirect_to @post }
format.turbo_stream
end
end
<%# app/views/comments/create.turbo_stream.erb %>
<turbo-stream action="append" target="comments">
<%= render partial: "comments/comment", locals: { comment: @comment } %>
</turbo-stream>
<turbo-stream action="update" target="new_comment">
<%= render partial: "comments/form", locals: { post: @post, comment: Comment.new } %>
</turbo-stream>
For real-time updates via ActionCable:
# app/models/comment.rb
class Comment < ApplicationRecord
belongs_to :post
after_create_commit -> { broadcast_append_to post }
end
<%# app/views/posts/show.html.erb %>
<%= turbo_stream_from @post %>
<div id="comments">
<%= render @post.comments %>
</div>
Live Demo:
Try adding a comment below. Notice how it appears instantly without a page refresh, and if another user adds a comment, it will appear in real-time.
Turbo Morph: Fine-grained DOM Updates
What it is: The newest addition to the Turbo family, Turbo Morph provides more efficient DOM updates by diffing the current and new HTML to apply only the necessary changes.
When to use Turbo Morph:
- For complex UI updates where standard Turbo frames or streams might be inefficient
- When preserving UI state during updates is critical
- For optimized rendering of large lists or complex components
Example:
<%# app/views/dashboard/show.html.erb %>
<%= turbo_frame_tag "dashboard", morph: true do %>
<div class="dashboard">
<div class="metrics">
<%= render "metrics", data: @metrics %>
</div>
<div class="recent-activity">
<%= render "activities", activities: @activities %>
</div>
</div>
<% end %>
<%= link_to "Refresh", dashboard_path, data: { turbo_frame: "dashboard" } %>
In your controller:
# app/controllers/dashboard_controller.rb
def show
@metrics = current_user.metrics
@activities = current_user.recent_activities(25)
end
Live Demo:
Click the "Refresh" button to update the dashboard. Notice how elements maintain their state (like scroll position or form inputs) even as the content changes.
Decision Framework: When to Use What
Here's a simple decision framework for choosing the right technique:
- Start with Turbo Drive as your baseline - it works automatically and improves navigation
-
Use Turbo Frames when:
- You need to update only a specific part of the page
- You want to create master-detail interfaces
- You're building isolated components
-
Use Turbo Streams when:
- You need real-time updates
- You're updating multiple parts of the page at once
- You're handling form submissions with complex responses
-
Consider Turbo Morph when:
- You're updating complex UI with many elements
- You need to preserve DOM state during updates
- You're working with large lists or tables
Real-world Example: Building a Comment System
Let's see how these technologies work together in a real-world example: a blog post with comments.
<%# app/views/posts/show.html.erb %>
<article class="post">
<h1><%= @post.title %></h1>
<div class="content">
<%= @post.content %>
</div>
<h2>Comments (<span id="comment-count"><%= @post.comments.count %></span>)</h2>
<%= turbo_stream_from @post, :comments %>
<div id="comments" class="comments">
<%= render @post.comments %>
</div>
<%= turbo_frame_tag "new_comment" do %>
<%= render "comments/form", post: @post, comment: Comment.new %>
<% end %>
</article>
<%# app/views/comments/_form.html.erb %>
<%= form_with model: [post, comment], data: { controller: "reset-form" } do |form| %>
<%= form.text_area :content, placeholder: "Add a comment..." %>
<%= form.submit "Post" %>
<% end %>
# app/controllers/comments_controller.rb
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
@comment.user = current_user
respond_to do |format|
if @comment.save
format.turbo_stream
else
format.turbo_stream {
render turbo_stream: turbo_stream.replace(
"new_comment",
partial: "comments/form",
locals: { post: @post, comment: @comment }
)
}
end
end
end
private
def set_post
@post = Post.find(params[:post_id])
end
def comment_params
params.require(:comment).permit(:content)
end
end
<%# app/views/comments/create.turbo_stream.erb %>
<turbo-stream action="append" target="comments">
<%= render "comments/comment", comment: @comment %>
</turbo-stream>
<turbo-stream action="update" target="comment-count">
<%= @post.comments.count %>
</turbo-stream>
<turbo-stream action="replace" target="new_comment">
<%= render "comments/form", post: @post, comment: Comment.new %>
</turbo-stream>
# app/models/comment.rb
class Comment < ApplicationRecord
belongs_to :post
belongs_to :user
broadcasts_to ->(comment) { [comment.post, :comments] }, inserts_by: :append
end
In this example:
- Turbo Drive handles navigation to the post page
- Turbo Frame manages the comment form submission
- Turbo Streams handle real-time updates when new comments are added
- The comment count updates in real-time
Performance Considerations
While these tools make building interactive applications easier, there are some performance considerations:
Server Load: More frequent updates mean more server requests. Consider caching and pagination for high-traffic features.
DOM Size: Large pages with many Turbo Frames can become memory-intensive. Use Turbo Morph for more efficient updates on complex pages.
WebSocket Connections: For Turbo Streams with ActionCable, be mindful of connection limits in production environments.
Progressive Enhancement: Always ensure your application works without JavaScript as a fallback.
Interactive Tutorial
Let's build a simple task manager to see these techniques in action:
- Create a new Rails application:
rails new taskmanager --css=tailwind
cd taskmanager
rails g scaffold Task name:string description:text completed:boolean
rails db:migrate
- Update your routes:
# config/routes.rb
Rails.application.routes.draw do
resources :tasks do
member do
patch :toggle
end
end
root "tasks#index"
end
- Update the controller:
# app/controllers/tasks_controller.rb
def toggle
@task = Task.find(params[:id])
@task.update(completed: !@task.completed)
respond_to do |format|
format.html { redirect_to tasks_path }
format.turbo_stream
end
end
- Create the toggle view:
<%# app/views/tasks/toggle.turbo_stream.erb %>
<turbo-stream action="replace" target="<%= dom_id(@task) %>">
<%= render partial: "task", locals: { task: @task } %>
</turbo-stream>
<turbo-stream action="update" target="task-count">
<%= Task.count %> tasks, <%= Task.where(completed: true).count %> completed
</turbo-stream>
- Update the task partial:
<%# app/views/tasks/_task.html.erb %>
<%= turbo_frame_tag dom_id(task) do %>
<div class="task <%= task.completed? ? 'completed' : '' %>">
<div class="flex items-center p-4 border-b">
<%= link_to toggle_task_path(task),
data: { turbo_method: :patch },
class: "mr-2" do %>
<div class="w-6 h-6 border-2 rounded-full flex items-center justify-center <%= task.completed? ? 'bg-green-500 border-green-600' : 'border-gray-400' %>">
<% if task.completed? %>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
<% end %>
</div>
<% end %>
<div class="<%= task.completed? ? 'line-through text-gray-500' : '' %>">
<h3 class="font-medium"><%= task.name %></h3>
<p class="text-sm text-gray-600"><%= task.description %></p>
</div>
<div class="ml-auto">
<%= link_to "Edit", edit_task_path(task), class: "text-blue-500" %>
</div>
</div>
</div>
<% end %>
- Update the index view:
<%# app/views/tasks/index.html.erb %>
<div class="container mx-auto p-4">
<h1 class="text-2xl font-bold mb-4">Task Manager</h1>
<div id="task-count" class="mb-4">
<%= Task.count %> tasks, <%= Task.where(completed: true).count %> completed
</div>
<div id="tasks">
<%= render @tasks %>
</div>
<%= turbo_frame_tag "new_task", src: new_task_path, loading: "lazy" %>
<div class="mt-4">
<%= link_to "New Task", new_task_path, class: "px-4 py-2 bg-blue-500 text-white rounded", data: { turbo_frame: "new_task" } %>
</div>
</div>
- Update the new task form:
<%# app/views/tasks/new.html.erb %>
<%= turbo_frame_tag "new_task" do %>
<div class="bg-gray-100 p-4 rounded mb-4">
<h2 class="text-xl font-semibold mb-2">New Task</h2>
<%= render "form", task: @task %>
<div class="mt-2">
<%= link_to "Cancel", "#", class: "text-gray-500", data: {
controller: "turbo",
action: "click->turbo#frameRemove"
} %>
</div>
</div>
<% end %>
In this interactive example:
- Turbo Drive handles navigation
- Turbo Frames manage the task toggling and the new task form
- Turbo Streams update the task count in real-time
Conclusion
The modern Rails stack gives developers powerful tools to create dynamic, responsive applications without relying on heavy JavaScript frameworks. By understanding when to use Turbo Drive, Frames, Streams, and Morph, you can build highly interactive experiences while maintaining the simplicity and productivity that makes Rails great.
Remember these key takeaways:
- Start simple with Turbo Drive, then add complexity as needed
- Choose the right tool for each interactive element in your application
- Combine techniques for the best user experience
- Consider performance implications for high-traffic applications
With these tools in your arsenal, you can build modern, responsive web applications that feel fast and reactive while maintaining the Rails philosophy of server-rendered HTML-first development.
What are you building with these techniques? Share your projects and challenges in the comments below!
Top comments (0)