Turbo Frames — Partial Page Updates in Rails Without Writing JavaScript
Turbo Streams handle real-time updates pushed from the server. Turbo Frames handle a different problem: replacing sections of a page in response to user interaction — clicking a link, submitting a form — without doing a full page reload. They’re the feature that makes your Rails app feel fast without requiring a single-page architecture. Once you understand frames, you’ll start seeing opportunities for them everywhere.
What Turbo Frames Do
A Turbo Frame is a named section of your page. When a link or form inside a frame triggers a request, the response replaces only that frame’s content — the rest of the page stays untouched, no flash, no scroll reset, no full reload.
This is fundamentally different from Turbo Drive (which replaces the full <body>) and Turbo Streams (which allow targeted mutations from the server via WebSocket or SSE). Frames are request/response: user does something, server responds, a section updates.
The use cases are everywhere: inline editing, expandable sections, paginated tables, search results that filter without a reload, tab switching. Any time you want “just this part of the page to change,” Turbo Frames are the tool.
Basic Usage
Wrap a section of your view in a turbo_frame_tag with a unique ID:
Example:
<%# app/views/posts/show.html.erb %>
<%= turbo_frame_tag "post_#{@post.id}" do %>
<h1><%= @post.title %></h1>
<p><%= @post.body %></p>
<%= link_to "Edit", edit_post_path(@post) %>
<% end %>
The edit view wraps its content in a frame with the same ID:
Example:
<%# app/views/posts/edit.html.erb %>
<%= turbo_frame_tag "post_#{@post.id}" do %>
<%= form_with model: @post do |f| %>
<%= f.text_field :title %>
<%= f.text_area :body %>
<%= f.submit "Save" %>
<% end %>
<% end %>
When the user clicks “Edit,” Turbo intercepts the request, fetches the edit page, finds the frame with matching ID, and swaps only that content. The rest of the page — navigation, sidebar, other posts — stays exactly as it was.
The controller needs no changes. The frame matching happens entirely in the browser.
Inline Editing Pattern
The most common use case: edit a record inline without navigating away.
Example:
<%# app/views/comments/_comment.html.erb %>
<%= turbo_frame_tag dom_id(comment) do %>
<div class="comment">
<p><%= comment.body %></p>
<span class="meta"><%= comment.author.name %> · <%= time_ago_in_words(comment.created_at) %> ago</span>
<%= link_to "Edit", edit_comment_path(comment), class: "edit-link" %>
</div>
<% end %>
Example:
<%# app/views/comments/edit.html.erb %>
<%= turbo_frame_tag dom_id(@comment) do %>
<%= form_with model: @comment do |f| %>
<%= f.text_area :body, rows: 3 %>
<%= f.submit "Save" %>
<%= link_to "Cancel", comment_path(@comment) %>
<% end %>
<% end %>
When the form submits successfully, the controller redirects back to show (or renders the updated comment), and the frame updates with the new content. “Cancel” fetches the show view and restores the original frame. Zero JavaScript.
Example:
# app/controllers/comments_controller.rb
def update
@comment = Comment.find(params[:id])
if @comment.update(comment_params)
redirect_to @comment # Turbo follows the redirect, updates the frame
else
render :edit, status: :unprocessable_entity
end
end
Lazy Loading with Turbo Frames
Frames can load their content lazily — the initial page renders without that content, then the frame fetches it separately:
Example:
<%# Renders immediately, then fetches asynchronously %>
<%= turbo_frame_tag "dashboard_stats", src: dashboard_stats_path, loading: :lazy do %>
<div class="loading-placeholder">Loading stats...</div>
<% end %>
Example:
# app/controllers/dashboard_stats_controller.rb
def show
@stats = DashboardStats.calculate # expensive operation
render layout: false # frame only needs the frame content, not full layout
end
The main page loads instantly. The stats frame triggers a separate request and replaces the placeholder when ready. For dashboards with expensive calculations, this pattern turns a 3-second page load into an instant load with a progressive fill.
Targeting Frames from Links Outside Them
Sometimes you want a link outside a frame to update a specific frame. Use data-turbo-frame:
Example:
<nav>
<%= link_to "Ruby Posts", category_path("ruby"), "data-turbo-frame": "posts_list" %>
<%= link_to "Rails Posts", category_path("rails"), "data-turbo-frame": "posts_list" %>
</nav>
<%= turbo_frame_tag "posts_list" do %>
<%= render @posts %>
<% end %>
Clicking a nav link updates only posts_list, not the whole page. Tab switching, filter navigation, and paginated tables all work this way with no JavaScript.
Pro-Tip: When a Turbo Frame response doesn’t contain a matching frame ID, Turbo silently does nothing. This bites developers who forget to wrap the edit/update views in a matching frame. Add a development helper: in
application.html.erb, include Turbo’s debug mode during development (data-turbo-debugon the<body>) to get console warnings when frames don’t match. Much easier than debugging “why isn’t this updating?”
Frames vs. Streams — When to Use Which
| Scenario | Tool |
|---|---|
| User clicks something, section updates | Turbo Frame |
| Server pushes update without user action | Turbo Stream |
| Multiple parts of page update from one action | Turbo Stream |
| Single section replaces in response to interaction | Turbo Frame |
| Real-time (WebSocket) updates | Turbo Stream |
| Inline edit / lazy load / tab switching | Turbo Frame |
They’re complementary, not competing. A single action can return both a frame update (for the form area) and a stream update (to increment a counter elsewhere on the page).
Conclusion
Turbo Frames are the part of Hotwire that handles most interactive UI without touching JavaScript. The mental model is simple: name your sections, match the IDs across views, and let Turbo handle the rest. Inline editing, lazy loading, and filtered content become straightforward Rails patterns — no SPA complexity, no client-side state management, no JavaScript to write or maintain. For Rails developers used to full-page navigation, Turbo Frames unlock a layer of interactivity that was previously only available with significant JavaScript investment.
FAQs
Q1: What’s the difference between Turbo Frames and Turbo Streams?
Frames replace a single named section in response to a user-triggered request. Streams push multiple targeted DOM mutations from the server (via redirect response or WebSocket). Frames are synchronous and user-driven; streams can be async and server-pushed.
Q2: Can I update multiple parts of the page with a Turbo Frame?
A single frame response updates only the one matching frame. To update multiple sections, use Turbo Streams in your controller response — they can issue multiple operations (replace, append, remove) in one response.
Q3: Do Turbo Frames work with browser back/forward navigation?
Frame navigations don’t create browser history entries by default. Add data-turbo-action="advance" to the link or form to push a history entry, enabling back-button support for frame-based navigation.
Q4: How do I handle errors in a Turbo Frame form?
Render the edit view with status: :unprocessable_entity in your controller. Turbo detects the 422 status and replaces the frame with the error-containing form, without treating it as a successful redirect. Standard Rails form validation flow.
Q5: Can I use Turbo Frames with Stimulus.js?
Yes, and they work well together. Stimulus handles client-side behavior within frames (character counts, toggles, real-time validation). Turbo handles server-driven content replacement. They don’t interfere — Stimulus controllers reconnect automatically when frame content changes.
Check viewARU - Brand Newsletter!
Newsletter to DEVs by DEVs - boost your Personal Brand & career! 🚀