I'll never forget the moment Turbo Frames clicked for me. I was building a simple task app, frustrated that every form submission caused a full page reload. Then I added one HTML attribute, refreshed the page, and suddenly my form submissions felt like magic—instant updates with no page refresh.
That was my "wow" moment with Hotwire. In this post, I'll recreate that experience and show you how Turbo Frames can transform your Rails app from feeling clunky to feeling modern.
The Problem: Full Page Reloads
Let's start with a traditional Rails form that feels sluggish:
Before: Traditional Rails Form
Controller:
# app/controllers/tasks_controller.rb
class TasksController < ApplicationController
def index
@tasks = Task.all
@task = Task.new # For the new task form
end
def create
@task = Task.new(task_params)
if @task.save
redirect_to tasks_path, notice: 'Task created!'
else
@tasks = Task.all
render :index
end
end
private
def task_params
params.require(:task).permit(:title, :description)
end
end
View:
<!-- app/views/tasks/index.html.erb -->
<h1>My Tasks</h1>
<!-- New Task Form -->
<div class="new-task-form">
<h2>Add New Task</h2>
<%= form_with model: @task do |form| %>
<% if @task.errors.any? %>
<div class="errors">
<% @task.errors.full_messages.each do |message| %>
<p><%= message %></p>
<% end %>
</div>
<% end %>
<div class="field">
<%= form.label :title %>
<%= form.text_field :title %>
</div>
<div class="field">
<%= form.label :description %>
<%= form.text_area :description %>
</div>
<%= form.submit "Add Task" %>
<% end %>
</div>
<!-- Tasks List -->
<div class="tasks-list">
<% @tasks.each do |task| %>
<div class="task">
<h3><%= task.title %></h3>
<p><%= task.description %></p>
<p><em>Created: <%= task.created_at.strftime("%B %d, %Y") %></em></p>
</div>
<% end %>
</div>
The User Experience Problem
When a user submits this form:
1. Full page reload - Everything flickers and reloads
2. Lost scroll position - User gets bounced back to the top
3. Slow feedback - Server round-trip for the entire page
4. Flash messages disappear - If the user scrolls or navigates
5. Feels dated - Like websites from 2010
The Solution: Turbo Frames
Turbo Frames let you update just part of a page instead of the whole thing. Here's how to transform the experience:
Step 1: Wrap Content in Turbo Frames
Update your view to use Turbo Frames:
<!-- app/views/tasks/index.html.erb -->
<h1>My Tasks</h1>
<!-- Wrap the form in a turbo frame -->
<%= turbo_frame_tag "new_task" do %>
<div class="new-task-form">
<h2>Add New Task</h2>
<%= form_with model: @task do |form| %>
<% if @task.errors.any? %>
<div class="errors">
<% @task.errors.full_messages.each do |message| %>
<p><%= message %></p>
<% end %>
</div>
<% end %>
<div class="field">
<%= form.label :title %>
<%= form.text_field :title %>
</div>
<div class="field">
<%= form.label :description %>
<%= form.text_area :description %>
</div>
<%= form.submit "Add Task" %>
<% end %>
</div>
<% end %>
<!-- Wrap the tasks list in another turbo frame -->
<%= turbo_frame_tag "tasks_list" do %>
<div class="tasks-list">
<% @tasks.each do |task| %>
<div class="task">
<h3><%= task.title %></h3>
<p><%= task.description %></p>
<p><em>Created: <%= task.created_at.strftime("%B %d, %Y") %></em></p>
</div>
<% end %>
</div>
<% end %>
Step 2: Update the Controller
Modify your controller to handle Turbo Frame requests:
# app/controllers/tasks_controller.rb
class TasksController < ApplicationController
def index
@tasks = Task.all.order(created_at: :desc)
@task = Task.new
end
def create
@task = Task.new(task_params)
if @task.save
@tasks = Task.all.order(created_at: :desc)
respond_to do |format|
format.html { redirect_to tasks_path, notice: 'Task created!' }
format.turbo_stream do
render turbo_stream: [
turbo_stream.update("new_task", partial: "new_task_form", locals: { task: Task.new }),
turbo_stream.update("tasks_list", partial: "tasks_list", locals: { tasks: @tasks })
]
end
end
else
respond_to do |format|
format.html do
@tasks = Task.all.order(created_at: :desc)
render :index
end
format.turbo_stream do
render turbo_stream: turbo_stream.update("new_task", partial: "new_task_form", locals: { task: @task })
end
end
end
end
private
def task_params
params.require(:task).permit(:title, :description)
end
end
Step 3: Create Partials
Extract the form and list into partials for reuse:
<!-- app/views/tasks/_new_task_form.html.erb -->
<div class="new-task-form">
<h2>Add New Task</h2>
<%= form_with model: task do |form| %>
<% if task.errors.any? %>
<div class="errors">
<% task.errors.full_messages.each do |message| %>
<p><%= message %></p>
<% end %>
</div>
<% end %>
<div class="field">
<%= form.label :title %>
<%= form.text_field :title %>
</div>
<div class="field">
<%= form.label :description %>
<%= form.text_area :description %>
</div>
<%= form.submit "Add Task" %>
<% end %>
</div>
<!-- app/views/tasks/_tasks_list.html.erb -->
<div class="tasks-list">
<% tasks.each do |task| %>
<div class="task">
<h3><%= task