• Home
  • About
  • Portfolio
  • Contact
CodeCurious
  • Home
  • About
  • Portfolio
  • Contact
Go Back

How Turbo Frames Work In Ruby On Rails

Learn how Turbo Frames work in Ruby on Rails to build faster, dynamic interfaces without writing JavaScript.

Jean Emmanuel Cadet
By Jean Emmanuel Cadet • Ruby on Rails Developer

Last updated : Jul 31, 2026 • 26 min read

How Turbo Frames Work in Ruby on Rails

Last updated : Jul 31, 2026 • 26 min read

Share with friends

Modern web applications are expected to feel fast and alive. Users click a link and expect the page to respond instantly. They submit a form and expect immediate feedback, not a jarring full-page reload that resets their scroll position and flashes a blank screen for half a second. For years, the answer to this expectation was JavaScript, often a lot of it, wired up with heavy frontend frameworks and a growing pile of client-side complexity.

Hotwire changed that conversation for Rails developers. Instead of shipping a JavaScript framework to the browser and asking it to manage state, Hotwire keeps rendering on the server and sends small, targeted HTML updates over the wire. Turbo Frames are the piece of Hotwire responsible for scoping page updates to specific regions of a page, so that clicking a link or submitting a form updates just the part of the page that needs to change.

In this article, you will learn what Turbo Frames are, how they work behind the scenes, and how to use them in a Rails 8 application. You will build a full CRUD example from scratch, learn how frames interact with links and forms, see how Turbo Frames compare to Turbo Streams and traditional AJAX, and walk through common mistakes and best practices. By the end, you should feel confident adding Turbo Frames to your own Rails projects and know exactly when to reach for them.


What Are Turbo Frames?

A Turbo Frame is a named region of an HTML page that can be updated independently of the rest of the page. You define a frame using the turbo_frame_tag helper, which renders a <turbo-frame> custom element with a unique id. Any link or form inside that frame automatically scopes its response to that frame, rather than reloading the whole document.

Think of a Turbo Frame as a self-contained window into a piece of content. When something inside that window changes, only the window updates. The rest of the page, the navigation bar, the sidebar, the footer, stays exactly as it was.

Turbo Frames sit alongside Turbo Drive and Turbo Streams as one of the three main components of Turbo, which itself is one part of the broader Hotwire ecosystem (Turbo plus Stimulus). Turbo Drive handles full-page navigation by intercepting link clicks and form submissions and swapping the <body> without a full browser reload. Turbo Frames go a step further and let you scope that swapping down to a specific element. Turbo Streams, which we will get to later, let you push multiple, granular DOM updates from a single response, often in response to background events.

To understand why this matters, compare two approaches to updating a comment count after a user adds a comment.

With full-page navigation, the browser sends a request, the server renders an entire HTML document, and the browser throws away the old page and paints the new one. Everything reloads: images, stylesheets that are already cached anyway get reattached, scroll position resets, and any client-side state you had (like an open dropdown) disappears.

With frame-based navigation, the browser still sends a request, but only the HTML inside the matching Turbo Frame gets replaced. The rest of the DOM never changes. This is a much smaller unit of update, and it is the foundation of what makes Hotwire applications feel snappy without a JavaScript framework.


How Turbo Frames Work

It helps to understand the full request lifecycle of a Turbo Frame, from the moment a user clicks something to the moment the DOM updates.

Here is the flow, step by step:

1. User clicks a link or submits a form inside a <turbo-frame>
2. Turbo intercepts the click/submit event (instead of the browser's default navigation)
3. Turbo sends an XHR/fetch request to the server
4. The server processes the request and renders a full HTML response
5. Turbo receives the response and looks for a matching <turbo-frame id="...">
6. Turbo extracts only that matching frame's content from the response
7. Turbo replaces the original frame's content with the new content
8. The rest of the page remains untouched

A simplified ASCII view of this looks like:

Browser                          Rails Server
-------- ------------
[turbo-frame id="post_1"]
|
| click link inside frame
v
Turbo intercepts click
|
| fetch request -------------> Controller action runs
| renders full view/partial
| <-------------- HTML response --
v
Turbo scans response for
<turbo-frame id="post_1">
|
v
Matching frame content
replaces old frame content
|
v
[turbo-frame id="post_1"] (updated)

The important detail here is step 4 and 5. The server does not need to know it is responding to a Turbo Frame request. In most cases, it renders the same full page it would render for a normal request. Turbo does the work of finding the frame with the matching id in that response and discarding everything else. This is what people mean when they describe Hotwire as "HTML over the wire." You are not building a separate JSON API and a separate rendering layer. You are rendering HTML, and Turbo intelligently picks out the piece it needs.

This also means your Rails views stay simple. There is no need to maintain two versions of a page, one for the initial load and one for AJAX responses. The controller and view code you already write for a normal page mostly just works with Turbo Frames, as long as your frame IDs line up.


Setting Up Turbo Frames in Rails 8

Rails 8 ships with Hotwire included by default when you generate a new application. If you are starting fresh, you get Turbo and Stimulus configured out of the box through the turbo-rails and stimulus-rails gems, wired up with importmap-rails (or esbuild/jsbundling if you chose a different JavaScript setup during app generation).

To confirm Turbo is available, check your Gemfile:

# Gemfile
gem "turbo-rails"
gem "stimulus-rails"

And your app/javascript/application.js should include:

// app/javascript/application.js
import "@hotwired/turbo-rails"
import "controllers"

With importmap, your config/importmap.rb will already pin the Turbo package:

# config/importmap.rb
pin "@hotwired/turbo-rails", to: "turbo.min.js"

No additional installation is typically required for a new Rails 8 app. If you are adding Turbo to an older application, running bin/rails turbo:install will handle the setup for you.

The core helper you will use constantly is turbo_frame_tag. It generates a <turbo-frame> element with an id attribute:

<%= turbo_frame_tag "post_1" do %>
<p>This content lives inside a Turbo Frame.</p>
<% end %>

This renders as:

<turbo-frame id="post_1">
<p>This content lives inside a Turbo Frame.</p>
</turbo-frame>

That id is the single most important piece of a Turbo Frame. It is how Turbo matches the frame in the initial page to the frame in the server's response. Get the ID wrong, or leave it out, and the frame will not update the way you expect.

A typical Rails 8 project structure for a Turbo Frame heavy feature looks like this:

app/
controllers/
posts_controller.rb
views/
posts/
index.html.erb
show.html.erb
edit.html.erb
_post.html.erb
_form.html.erb

Nothing exotic here. Turbo Frames work with the same MVC structure you already know from building Rails applications.


Your First Turbo Frame

Let's build a complete example: a simple posts CRUD feature where editing a post happens inline, without leaving the index page.

First, the routes:

# config/routes.rb
Rails.application.routes.draw do
resources :posts
end

Nothing special here. Turbo Frames do not require custom routes. They work with standard RESTful resources.

Next, the controller:

# app/controllers/posts_controller.rb
class PostsController < ApplicationController
before_action :set_post, only: [ :show, :edit, :update, :destroy ]

def index
@posts = Post.all.order(created_at: :desc)
end

def show
end

def edit
end

def update
if @post.update(post_params)
redirect_to @post
else
render :edit, status: :unprocessable_entity
end
end

private

def set_post
@post = Post.find(params[:id])
end

def post_params
params.require(:post).permit(:title, :body)
end
end

This is a plain Rails controller. There is no Turbo-specific logic here at all, which is one of the nicest things about working with Turbo Frames. Your controllers stay thin and familiar.

Now the index view, where each post is wrapped in its own frame:

<%# app/views/posts/index.html.erb %>
<h1>Posts</h1>

<% @posts.each do |post| %>
<%= turbo_frame_tag post do %>
<%= render "post", post: post %>
<% end %>
<% end %>

Note that turbo_frame_tag post accepts an ActiveRecord object directly. Rails will generate an id like post_1, post_2, and so on, using dom_id. This convention matters because it lets your show, edit, and update views generate matching frame IDs without you writing the ID string by hand every time.

The post partial:

<%# app/views/posts/_post.html.erb %>
<div class="post">
<h2><%= post.title %></h2>
<p><%= post.body %></p>
<%= link_to "Edit", edit_post_path(post) %>
</div>

That link_to "Edit" is a normal Rails link helper. Nothing Turbo-specific about it either. But because it lives inside a <turbo-frame>, Turbo automatically intercepts the click and scopes the navigation to that frame.

The edit view:

<%# app/views/posts/edit.html.erb %>
<%= turbo_frame_tag @post do %>
<%= render "form", post: @post %>
<% end %>

And the form partial:

<%# app/views/posts/_form.html.erb %>
<%= form_with model: post do |form| %>
<% if post.errors.any? %>
<div class="error-messages">
<ul>
<% post.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>

<div>
<%= form.label :title %>
<%= form.text_field :title %>
</div>

<div>
<%= form.label :body %>
<%= form.text_area :body %>
</div>

<%= form.submit %>
<% end %>

Here is what happens when a user clicks "Edit" on the index page:

  1. Turbo intercepts the click because it happens inside turbo_frame_tag post.
  2. Turbo sends a request to edit_post_path(post).
  3. The edit action renders edit.html.erb, which wraps the form in a turbo_frame_tag @post, generating the same frame ID as the index page (post_1).
  4. Turbo finds the matching frame in the response and swaps only that content in.
  5. The post on the index page transforms into an inline edit form, with the rest of the list untouched.

When the user submits the form and it succeeds, the controller redirects to @post, which renders show.html.erb. If that view also wraps its content in turbo_frame_tag @post, the frame updates again, this time showing the updated post. If validation fails, render :edit, status: :unprocessable_entity re-renders the form with error messages, still scoped to the same frame.

This single pattern, wrapping content in a frame with a matching ID across index, show, and edit views, is the backbone of most Turbo Frame features you will build.


Navigating Within Turbo Frames

Links and forms behave differently depending on where they live relative to a frame.

Links inside a frame default to updating that frame. If you click a link inside turbo_frame_tag "post_1", Turbo looks for a turbo-frame id="post_1" in the response and swaps it in.

Form submissions inside a frame behave the same way. The form's response is scanned for a matching frame ID.

Nested navigation happens when you have a frame inside another frame. By default, a link inside a nested frame will only update the nearest enclosing frame, not the outer one. This is useful, but it can also cause confusion if you are not intentional about your frame structure. Keep nested frames shallow, and give each one a distinct, meaningful ID.

Frame targeting lets you break out of this default scoping using the data-turbo-frame attribute on a link or form. For example:

<%= link_to "Edit", edit_post_path(post), data: { turbo_frame: "post_form" } %>

This tells Turbo to look for a frame with id="post_form" in the response, regardless of which frame the link is currently inside. This is useful for patterns like opening a form in a dedicated area of the page rather than replacing the item itself.

target="_top" is the escape hatch for breaking out of frame scoping entirely. Adding data: { turbo_frame: "_top" } to a link tells Turbo to treat the response as a full-page navigation, replacing the entire <body> instead of just the frame. This is common for "Delete" actions or any link where staying scoped to a small frame does not make sense:

<%= link_to "View full post", post_path(post), data: { turbo_frame: "_top" } %>

You can also set this at the frame level so that everything inside defaults to breaking out:

<%= turbo_frame_tag "post_1", target: "_top" do %>
...
<% end %>

Updating Content Dynamically

The real value of Turbo Frames shows up once you start chaining these patterns together for common CRUD interactions.

Displaying a form inline works exactly like the edit example above: wrap the show view and the edit view in frames with the same ID, and clicking "Edit" swaps one for the other.

Creating records follows a similar shape. You can render a "New Post" link inside a frame, and have it load a form scoped to that same frame:

<%= turbo_frame_tag "new_post" do %>
<%= link_to "New Post", new_post_path %>
<% end %>
<%# app/views/posts/new.html.erb %>
<%= turbo_frame_tag "new_post" do %>
<%= render "form", post: @post %>
<% end %>

Displaying details without a full navigation is another common case: clicking a post title loads its full details into a frame instead of navigating to a separate page, which is especially useful for list-and-detail interfaces like admin panels or dashboards.

Replacing partials is what Turbo is doing under the hood in every one of these cases. Because your controller renders the same partials it always would, and Turbo simply finds the right frame in that HTML and swaps it in, you rarely need custom rendering logic for these interactions. This is the entire point of Turbo Frames: it eliminates unnecessary full-page reloads by making "reload just this piece" the default behavior for anything inside a frame, without asking you to write a separate rendering path for it.


Turbo Frames vs Turbo Streams

Turbo Frames and Turbo Streams solve related but different problems, and mixing them up is one of the most common sources of confusion for developers new to Hotwire.

A Turbo Frame updates a single, contained region of the page in response to a request that originated from inside that frame (a link click or form submit). It is a request/response pattern scoped to one area.

A Turbo Stream can update multiple, potentially unrelated parts of the page in a single response, and it is not limited to replacing content. Turbo Streams support several actions: append, prepend, replace, update, remove, and before/after. A single Turbo Stream response might append a new comment to a list, update a comment counter elsewhere on the page, and clear a form, all in one response.

<%# Example Turbo Stream response %>
<%= turbo_stream.append "comments", partial: "comments/comment", locals: { comment: @comment } %>
<%= turbo_stream.update "comments_count", @post.comments.count %>
<%= turbo_stream.replace "new_comment_form", partial: "comments/form", locals: { comment: Comment.new } %>

Use Turbo Frames when:

  • You are scoping navigation or form submission to a single, well-defined region.
  • The interaction is initiated by the user clicking or submitting something inside that region.
  • You want simple, declarative behavior without writing any custom view logic for the update.

Use Turbo Streams when:

  • A single action needs to update multiple, disconnected parts of the page.
  • You need to append or remove items from a list (like adding a new comment without reloading the whole list).
  • You want to broadcast updates from background jobs or other users' actions over ActionCable, so changes appear in real time for everyone viewing the page.

They complement each other well. A common pattern is to use a Turbo Frame to handle the form submission UI, and a Turbo Stream response from that same submission to update a list and reset the form simultaneously. For example, a comment form living inside a frame can respond with a Turbo Stream that appends the comment to the list and clears the frame's form, combining scoped submission handling with multi-target updates.


Turbo Frames vs Traditional AJAX

If you have built AJAX-driven interfaces before, either with jQuery or hand-rolled fetch calls, Turbo Frames will feel like a significant simplification.

JavaScript complexity: traditional AJAX requires you to write JavaScript for every interaction: attach event listeners, prevent default form behavior, construct a request, parse the response, find the right DOM node, and update it. Turbo Frames need none of that. The browser-native <turbo-frame> element and Turbo's built-in interception handle all of it declaratively.

Maintainability: with AJAX, your update logic tends to live in scattered JavaScript files, often duplicating markup that already exists in your Rails views (once in ERB, once in a JS template or JSON-to-HTML conversion). Turbo Frames keep a single source of truth: your Rails views. There is no second rendering layer to keep in sync.

Performance: Turbo Frame responses are typically small, targeted HTML fragments matched out of a normal server response. You are not shipping a JSON API layer plus a client-side templating engine plus a bundle to parse it all. The server does what it already does well, which is render HTML.

Developer experience: adding a new interactive region to a page with Turbo Frames usually means wrapping existing markup in a turbo_frame_tag and making sure the ID matches on both ends. Compare that to wiring up a new AJAX endpoint, a JavaScript handler, and a DOM update function.

Server rendering and progressive enhancement: this is where Hotwire's philosophy really stands out. Turbo Frames degrade gracefully. If JavaScript fails to load for some reason, links and forms inside a frame still work, they just perform full-page navigations instead of scoped updates, because the underlying HTML is always a complete, valid response. Your application keeps functioning even in a reduced state, which is a form of progressive enhancement that pure client-side AJAX interfaces rarely offer without extra work.

For a deeper comparison of Hotwire against a full client-side framework like React, including where React still makes sense, CodeCurious has a dedicated breakdown at <a href="https://codecurious.dev/articles/hotwire-vs-react-which-should-you-pick-in-2026">Hotwire vs React: Which Should You Pick in 2026</a>.


Turbo Frames and Forms

Forms are where Turbo Frames get the most day-to-day use, so it is worth covering the details carefully.

Creating records: a form inside a frame that responds with render :new, status: :unprocessable_entity on failure, or a redirect on success, works automatically with the enclosing frame, as long as the target view (whether it's the new template again or the show/index template after redirect) contains a frame with a matching ID.

Editing records: the same pattern applies. The edit action's frame ID should match the show action's frame ID, so that a successful update can swap the edit form back out for the updated content.

Validation errors: this is a detail that trips people up. When a form submission fails validation, you must render with a non-2xx status code, typically :unprocessable_entity (422), for Turbo to correctly treat the response as a frame update rather than following it as a redirect:

def create
@post = Post.new(post_params)

if @post.save
redirect_to @post
else
render :new, status: :unprocessable_entity
end
end

If you forget the status code, Turbo may not display the form errors as expected. This is one of the most common Turbo Frame bugs, and it is worth double-checking any time a form's error state does not seem to update.

Form re-rendering: when validation fails, make sure the re-rendered view still wraps the form in a frame with the same ID as the original. If the IDs do not match, Turbo will not find anything to swap, and the user will see no visible change even though the server responded correctly.

Redirect behavior: a successful redirect_to inside a Turbo Frame request works a little differently than you might expect. Turbo follows the redirect and looks for a frame with a matching ID in the redirected response. If your show view does not include a frame with the same ID the form was scoped to, the redirect will not visibly update anything inside that frame.

If you are handling more complex, nested form input, especially anything involving parameters your users provide directly, it is worth reviewing how Rails' strong parameters protect your application from mass assignment vulnerabilities. CodeCurious has a full walkthrough at <a href="https://codecurious.dev/articles/understanding-strong-parameters-in-rails">Understanding Strong Parameters in Rails</a>, which pairs well with any Turbo Frame form you are building.

Best practices for Turbo Frame forms:

  • Always use the correct HTTP status codes on validation failure.
  • Keep frame IDs consistent across new, create, edit, update, and show actions for the same resource.
  • Avoid putting business logic directly in the controller action. If a create or update action starts doing more than saving a record, consider extracting that logic into a Service Object so your controller stays focused on request/response handling. CodeCurious covers this pattern in detail in <a href="https://codecurious.dev/articles/ruby-on-rails-service-objects-explained-2026-guide">Ruby on Rails Service Objects Explained</a>, which is especially useful once your Turbo Frame forms start triggering things like notifications, external API calls, or multi-model updates.

Common Use Cases

Turbo Frames show up naturally in a wide range of interface patterns:

  • User profiles: inline editing of profile fields without a dedicated edit page.
  • Comments: loading a comment thread lazily, or editing a single comment inline.
  • Shopping carts: updating quantities or removing items without reloading the whole cart page.
  • Dashboards: loading individual widgets or panels independently, so a slow query in one panel does not block the rest of the dashboard from rendering.
  • Search filters: submitting a filter form and updating just the results list, leaving the filter controls and page chrome untouched.
  • Pagination: paginating a list inside a frame so only the list updates, not the surrounding page.
  • Modal dialogs: loading modal content into a frame when a link is clicked, keeping the modal's markup and logic separate from the rest of the page.
  • Settings pages: breaking a large settings form into independently editable sections, each scoped to its own frame.
  • Admin panels: managing records in a table where each row can be edited or deleted inline.

In each of these cases, the same underlying pattern applies: wrap the region in a frame, make sure the server's response contains a matching frame ID, and let Turbo handle the swap.


Common Mistakes

A handful of issues account for the vast majority of Turbo Frame bugs.

Missing frame IDs: if the response Turbo receives does not contain a <turbo-frame> with an ID matching the one that made the request, Turbo will render nothing inside that frame and log a warning in the browser console. Always double-check that both ends of an interaction use the same ID.

Returning incorrect responses: rendering a different partial or view than expected, or forgetting to wrap it in a turbo_frame_tag at all, produces a frame with no matching content.

Nested frame confusion: when frames are nested, it is easy to lose track of which frame a link or form is actually scoped to. If an inner link seems to update the wrong region (or nothing at all), check whether it is nested inside a frame you did not intend to target, and use data-turbo-frame to be explicit.

Unexpected redirects: a redirect after a form submission needs a matching frame ID in the destination view, or the frame will appear to do nothing, even though the underlying record was successfully updated.

Full-page responses: if a controller action accidentally renders a layout (for example, by not skipping the layout for a partial-only response, or by hitting an error page that renders the full application layout), Turbo may not find the expected frame at all, since error pages typically do not include your custom frame markup.

Caching problems: Turbo Frames can be marked loading="lazy" to defer their initial load, and by default frames are cached by Turbo Drive between visits. If you are seeing stale content in a frame after an update, check whether the surrounding page structure was served from Turbo's page cache and consider whether the frame should not be cached, or whether the underlying data needs a full reload trigger.

Troubleshooting most of these issues comes down to opening your browser's developer console. Turbo logs warnings when it cannot find a matching frame, which is often the fastest way to identify a mismatched ID.


Performance Benefits

The performance case for Turbo Frames is straightforward, but it is worth being specific about where the gains come from.

Reduced page reloads: every full-page navigation reloads and reprocesses the entire DOM, re-runs any Stimulus controllers connected to elements outside the changed region, and can cause visible flicker. Scoping updates to a frame avoids all of that for the parts of the page that did not change.

Faster navigation: because Turbo Frame responses only need to contain the markup for the frame's matching content (even if the server technically renders the full page), the perceived speed of an interaction improves. Users see updates appear instantly in place, without a blank-screen flash between pages.

Smaller responses: while the server often does render a complete HTML document for a frame request (since your view templates typically extend the full layout), Turbo only uses the piece it needs. In practice, many Rails developers optimize further by skipping the layout for these requests where appropriate, shrinking the actual payload sent over the wire.

Better perceived performance: this is arguably the biggest win. A dashboard with five independently loading frames feels faster to a user than a single page that waits for all five data sources before rendering anything, because each frame can render as soon as its own data is ready, and the user sees progress rather than a blank loading state.

Improved user experience: preserving scroll position, avoiding flash-of-unstyled-content on reload, and keeping unrelated interface state (like an open dropdown or a scrolled list) intact all contribute to an interface that feels considerably more polished, without writing custom JavaScript to manage any of it.

A realistic example: an e-commerce admin dashboard with a sales chart, a recent orders table, and an inventory alerts panel, each wrapped in its own Turbo Frame with loading="lazy", can render the page shell immediately and let each panel populate independently as its data becomes available, rather than blocking the entire page behind the slowest query.


Best Practices

A few guidelines will keep your Turbo Frame usage clean as your application grows:

  • Keep frames focused. A frame should represent one coherent unit of UI, not an entire page. If a frame is doing too much, consider breaking it into smaller, independently updatable frames.
  • Avoid deeply nested frames. Nesting is supported, but more than one or two levels tends to make navigation behavior hard to reason about. Flatten your frame structure where you can.
  • Use meaningful IDs. Prefer turbo_frame_tag post (which generates a predictable dom_id) over hardcoded strings, so IDs stay consistent across views without manual bookkeeping.
  • Return matching frame responses. Every controller action that can be reached from inside a frame should render a view containing a frame with the same ID, including error and validation paths.
  • Combine Turbo Frames with Turbo Streams when appropriate. Use a frame to scope the form UI, and a stream to fan out the resulting update to other parts of the page, like a list or a counter.
  • Keep controllers simple. Resist the temptation to add Turbo-specific branching logic to your controllers. In most cases, your existing RESTful actions work as-is; the view layer is where frame IDs matter.

Real-World Example

Let's put these ideas together in a small blog feature: an article list with inline editing, comments, and category filtering, all built with Turbo Frames.

Article list with category filtering:

<%# app/views/articles/index.html.erb %>
<h1>Articles</h1>

<%= form_with url: articles_path, method: :get, data: { turbo_frame: "articles_list" } do |form| %>
<%= form.select :category_id, Category.pluck(:name, :id), include_blank: "All Categories" %>
<%= form.submit "Filter" %>
<% end %>

<%= turbo_frame_tag "articles_list" do %>
<%= render @articles %>
<% end %>
# app/controllers/articles_controller.rb
def index
@articles = params[:category_id].present? ? Article.where(category_id: params[:category_id]) : Article.all
end

The filter form targets the "articles_list" frame explicitly with data: { turbo_frame: "articles_list" }, so submitting it only updates the list, leaving the filter form itself untouched.

Article details and inline editing:

<%# app/views/articles/_article.html.erb %>
<%= turbo_frame_tag article do %>
<h2><%= link_to article.title, article_path(article) %></h2>
<p><%= article.excerpt %></p>
<%= link_to "Edit", edit_article_path(article) %>
<% end %>
<%# app/views/articles/edit.html.erb %>
<%= turbo_frame_tag @article do %>
<%= render "form", article: @article %>
<% end %>

Clicking "Edit" on any article in the list swaps that single article's row into an edit form, without affecting the rest of the list or the filter controls above it.

Comments, using Turbo Streams for the append behavior:

# app/controllers/comments_controller.rb
def create
@comment = @article.comments.build(comment_params)

if @comment.save
respond_to do |format|
format.turbo_stream
format.html { redirect_to @article }
end
else
render "articles/show", status: :unprocessable_entity
end
end
<%# app/views/comments/create.turbo_stream.erb %>
<%= turbo_stream.append "comments", partial: "comments/comment", locals: { comment: @comment } %>
<%= turbo_stream.replace "new_comment_form", partial: "comments/form", locals: { comment: @article.comments.build } %>

Here, the comment form lives inside a Turbo Frame for scoped submission, while the actual response is a Turbo Stream that appends the new comment to the list and resets the form, demonstrating exactly the kind of complementary usage discussed earlier.

From request to response, the flow is: a user submits the comment form, Turbo intercepts it, the server saves the comment and renders a Turbo Stream response, and Turbo applies each stream action in order, appending the new comment and swapping in a fresh, empty form, all without a full-page reload.


Frequently Asked Questions

What are Turbo Frames? Turbo Frames are a Hotwire feature that let you scope page updates to a specific, named region of the page, identified by a matching id on a <turbo-frame> element.

Do Turbo Frames replace JavaScript? They replace the JavaScript you would otherwise write for many common interactions, like inline editing or scoped form submissions. You can still use Stimulus alongside Turbo Frames for behavior that genuinely needs client-side logic.

What is the difference between Turbo Frames and Turbo Streams? Turbo Frames scope a single request/response to one region of the page. Turbo Streams can update multiple regions in one response and support actions like append, prepend, and remove, and can be broadcast over ActionCable independent of a direct user request.

Can Turbo Frames work with Stimulus? Yes. Stimulus controllers can live inside or outside Turbo Frames, and Stimulus is commonly used for behavior Turbo Frames do not cover, such as client-side interactivity that does not need a server round trip.

Why is my frame reloading the whole page? This usually means the frame ID in the response did not match the requesting frame, or a link/form inside the frame has data-turbo-frame="_top" set, either directly or inherited from a parent frame.

Can I nest Turbo Frames? Yes, but keep nesting shallow. By default, links and forms inside a nested frame only update the nearest enclosing frame.

Are Turbo Frames SEO friendly? Yes. Because Turbo Frames rely on standard server-rendered HTML and standard URLs, search engine crawlers can access the same content a browser would see, since JavaScript is not required to render the underlying page.

When should I use Turbo Frames instead of React? Turbo Frames are a strong fit when your application is primarily server-rendered and your interactivity needs are scoped to specific regions, like forms, lists, and detail views. React tends to make more sense for highly stateful, client-heavy interfaces where a lot of logic needs to run without server round trips. For a fuller comparison, see the CodeCurious article on Hotwire vs React.


Conclusion

Turbo Frames give Rails developers a way to build fast, dynamic interfaces without reaching for a heavy JavaScript framework or hand-writing AJAX handlers for every interaction. By scoping updates to a named region of the page and relying on server-rendered HTML, Turbo Frames keep your codebase simple: the views and controllers you already know how to write mostly just work, as long as your frame IDs line up.

Combined with Turbo Streams for multi-target updates and Stimulus for the occasional bit of client-side behavior, Hotwire gives you a genuinely productive path to building highly interactive applications while staying close to Rails' server-rendered roots. If you have not tried Turbo Frames in your own projects yet, start small: wrap a single list item or a single form in a turbo_frame_tag, and see how much interactivity you get without writing a line of custom JavaScript.

💌 Don’t miss out! Join my newsletter for web development tips, tutorials, and insights delivered straight to your inbox.

Thanks for reading & Happy coding! 🚀

Follow me on:

Code. Learn. Grow.

A friendly newsletter sharing dev tips, lessons, and wins from my journey.

    Enter valid email address

    Services Tailored to Your Needs


    coding

    Web & Mobile Development

    Custom websites and mobile apps built to be fast, modern, and user-friendly. From sleek landing pages to full-scale applications, I deliver solutions that engage your audience and grow your business.

    API development

    Seamlessly connect your systems with secure, scalable APIs. I design and integrate APIs that improve efficiency, reliability, and flexibility for your business processes.

    Database design and management

    Reliable database solutions tailored to your needs. I design, optimize, and maintain databases that ensure performance, security, and scalability for your applications.

    You might also like…

    Ruby on Rails Service Objects Explained (2026 Guide)
    Ruby On Rails

    Ruby On Rails Service Objects Explained (2026 Guide)

    By Jean Emmanuel Cadet
    Published on: Jul 29, 2026
    Understanding Strong Parameters in Rails
    Ruby On Rails

    Understanding Strong Parameters In Rails

    By Jean Emmanuel Cadet
    Published on: Jul 27, 2026
    How to Optimize Active Record Queries in Rails
    Ruby On Rails

    How To Optimize Active Record Queries In Rails

    By Jean Emmanuel Cadet
    Published on: Jul 24, 2026
    CodeCurious

    Designed for those who view software as architecture and code as literature.

    Legal

    Terms & Conditions Privacy Policy Disclaimer

    CodeCurious © 2025 - 2026. All rights reserved. | Made with ♥ by @jecode93