/ Tags: RAILS / Categories: RAILS

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.


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-debug on 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.

cdrrazan

Rajan Bhattarai

Full Stack Software Developer! 💻 🏡 Grad. Student, MCS. 🎓 Class of '23. GitKraken Ambassador 🇳🇵 2021/22. Works with Ruby / Rails. Photography when no coding. Also tweets a lot at TW / @cdrrazan!

Read More