Turbo Frames on Rails

Turbo, part of the Hotwire tripartite, gives you the tools to write dramatically less custom JavaScript than you would otherwise need to build modern, performant web applications.

Turbo is composed of Turbo Drive, Turbo Frames, Turbo Streams, and Turbo Native. Each is a valuable piece of the puzzle but today we’re going to focus on Turbo Frames.

Turbo Frames “allow predefined parts of a page to be updated on request.” Used wisely, frames allow developers to decompose their UI into independently updated pieces, quickly.

Why should I use Turbo Frames?

Turbo Frames unlock a huge amount of potential with minimal changes to existing code and they can be gradually introduced to existing projects without necessitating major architectural changes.

In greenfield projects designed with Turbo Frames in mind, a small team of developers can use frames to deliver fast, efficient user interfaces in dramatically less time than would be required when build a SPA-powered front end.

While Turbo Frames can be used with many different tech stacks, Rails developers will find the tight integration of frames into Rails (via the turbo-rails gem) makes using frames in Rails a breeze.

The Turbo documentation gap

As with many new technologies, one of the barriers to getting started with Turbo Frames is inconsistent/incomplete documentation. Along with problems in the official documentation, much of the tutorial content written about Turbo Frames is already out of date.

Turbo has evolved quickly since its release in December of 2020, and the documentation and supporting tutorials from content creators have struggled to keep pace.

As an example of the documentation gap, turbo-rails doesn’t have any dedicated usage documentation, instead just linking to the base Turbo docs and letting users figure out the rest.

This means that learning to use Turbo Frames in your Rails application today often requires reading the docs, then digging through source code, and then Googling your way through Github issues and forum posts. Turbo Frames can be difficult to approach.

In time the documentation will improve and the community will coalesce around best practices and standards that can be more easily communicated to new users.

In the meantime, here we are.

What is this article? Who is it for?

Today, I’m going to share what I’ve learned over nearly a year building applications with Turbo Frames, from small hobby projects and experiments to multiple large, legacy production applications.

By doing so, I’m hoping to address the gaps in documentation, to save you from spending too much time reading source code, trawling forum posts and Github issues.

I will cover content you can find in the documentation (if you know where to look), gotchas and tricks learned from real world experience and picked up from reading way too much about Turbo and Turbo Frames since Turbo’s initial release. I’ll also share patterns that illuminate some of the common web application needs that frames are perfect for.

This content is geared towards Rails developers interested in using Turbo. The code samples and concepts will be illustrated using Ruby and Rails code and conventions. If you aren’t working in Rails, you should still find plenty of value here, but know that you may not have access to convenience methods like <%= turbo_frame_tag %> in your chosen language and framework.

Let’s dive in!

What are Turbo Frames?

At a high level, Turbo Frames are pieces of a webpage that can be updated independently, without impacting the rest of the content on the page.

Links and forms within a frame will, by default, attempt to update only the content of the containing frame, whether the server sends a completely new HTML document or only a page fragment.

Turbo Frames allow developers to decompose a page into pieces of content that can be updated individually as new information is received from a server.

In practice, common use cases for Turbo Frames include:

  • Tabbed content
  • In-line editing
  • Searching, sorting, and filtering data

Let’s start looking at code.

Constructing a frame

A basic Turbo Frame, rendered with the built-in helper from Turbo Rails (and using erb), looks like this:

<%= turbo_frame_tag "some_id" do %>
  <div>
    Some framed content
  </div>
<% end %>

The only required argument for turbo_frame_tag is an id.

When the helper is processed, the final HTML is:

<turbo-frame id="some_id">
  <div>
    Some framed content
  </div>
</turbo-frame>

Instead of providing a static id, we can also pass in an active record object, which the helper will use to generate a unique id via dom_id(object.id).

<%= turbo_frame_tag Comment.first %>

Frames can also receive an src attribute. When src is supplied, the frame will be populated after the initial page load via a separate HTTP request to the frame’s src:

<%= turbo_frame_tag "comments", src: comments_path do %>
  <div>
    Placeholder content
  </div>
<% end %>

Here, the page will initially load the turbo frame with the placeholder div and then immediately a request to comments_path endpoint is made and the content of the Turbo Frame is replaced with the response from the server, provided our server returns HTML with a matching Turbo Frame tag.

What’s a matching Turbo Frame tag? Let’s look at that next.

Responding with frame content

The power of Turbo Frames is in replacing pieces of the page with content sent from the server — without touching the rest of the DOM.

Updating an existing frame just requires responding to a request with HTML that contains a Turbo Frame element with an id that matches the id of the frame target sent in the request.

Taking the comments example from above, when a request is made to /comments, the server should respond with HTML that contains a frame like this:

<turbo-frame id="comments">
  <!-- A list of comments, perhaps -->
</turbo-frame>

The content inside of the matched frame is replaced with the updated content from the server, without touching the rest of the page.

By default, links and forms within a Turbo Frame will perform the navigation within the frame, rather than performing a full page turn.

For a simple example, we can imagine that we have a list of comments on a page and that each comment is wrapped in a Turbo Frame tag, like this:

<% @comments do |comment| %>
  <%= turbo_frame_tag comment do %>
    <div>
      <%= link_to comment.body, edit_comment_path(comment) %>
    </div>
  <% end %>
<% end %>

This ERB results in rendered HTML:

<turbo-frame id="comment_1">
  <div>
    <a href="/comments/1/edit">Comment 1</a>
  </div>
</turbo-frame>
<turbo-frame id="comment_2">
  <div>
    <a href="/comments/2/edit">Comment 2</a>
  </div>
</turbo-frame>
<turbo-frame id="comment_3">
  <div>
    <a href="/comments/3/edit">Comment 3</a>
  </div>
</turbo-frame>

With this Turbo-powered HTML in place, clicks on the edit links will make a Turbo Frame request to /comments/:id/edit, retrieve some HTML, and return that HTML to the browser.

The Turbo Frame request is a normal HTML request with an additional Turbo-Frame header included in the request, with a value that matches the id of the target Turbo Frame.

In turbo-rails, the presence of this header can be used to identify a Turbo Frame request and respond with appropriate content, like this:

# This approach gets messy in controller actions. Read further on for a variant based approach that scales much more cleanly
if turbo_frame_request?
  render partial: "some_turbo_frame_partial"
else
  render partial: "some_other_partial"
end

When turbo-rails responds to a Turbo Frame request, it automatically removes the layout from the HTML response. Since Turbo will discard all of the HTML but the requested frame, Rails skips rendering content that it knows won’t be used.

When a response to a Turbo Frame request is received, Turbo Drive replaces the content of the target frame, leaving the rest of the page untouched.

Continuing with the above comments example, the edit view may look something like this:

<%= turbo_frame_tag @comment do %>
  <%= form_with(model: @comment) do |form| %>
    <div class="inline-field">
      <%= form.label :body %>
      <%= form.text_field :body %>
    </div>
    <div class="actions">
      <%= form.submit %>
    </div>
  <% end %>
<% end %>

After clicking to edit a comment then, the updated HTML after Turbo processes the response would look something like this:

<!-- Form content rendered from /edit -->
<turbo-frame id="comments_1" src="http://localhost:3000/comments/1/edit">
  <form action="/comments/1" accept-charset="UTF-8" method="post">
    <input type="hidden" name="_method" value="patch">
    <input type="hidden" name="authenticity_token" value="some_token">
    <div class="inline-field">
      <label for="comment_body">Body</label>
      <input type="text" value="Comment 1" name="comment[body]" id="comment_body">
    </div>
    <div class="actions">
      <input type="submit" name="commit" value="Update Comment">
    </div>
  </form>
</turbo-frame>
<!-- The other frames, untouched by the /edit request -->
<turbo-frame id="comment_2">
  <div>
    <a href="/comments/2/edit">Comment 2</a>
  </div>
</turbo-frame>
<turbo-frame id="comment_3">
  <div>
    <a href="/comments/3/edit">Comment 3</a>
  </div>
</turbo-frame>

Finishing up this example, what happens when the user submits the comment form?

After submit in a normal Rails action, we might redirect the user to the comment show page. When the request occurs within a Turbo Frame, a “redirect” still occurs, but rather than redirecting the page in the browser, the redirect is followed, the new HTML content is sent from the server, and that HTML is used to update the frame.

In our example, the comment#update controller action might look like this:

def update
  respond_to do |format|
    if @comment.update(comment_params)
      format.html { redirect_to @comment }
    else
      format.html { render :edit, status: :unprocessable_entity }
    end
  end
end

And the show view:

<%= turbo_frame_tag @comment do %>
  <div>
    <%= link_to @comment.body, edit_comment_path(@comment) %>
  </div>
<% end %>

This behavior allows us to build Turbo Frame-powered interfaces while making very few or no changes to a standard Rails controller, making it possible to prototype in Rails very quickly.

With just what we’ve learned so far, we’re already in position to start building simple interfaces with Turbo Frames that allow us to add significant amounts of interactivity to our application with very few changes to the Ruby and HTML we’d have in any Rails app.

Let’s keep exploring, there’s plenty more to see.

Breaking out of a frame

While navigating within a frame is very handy, sometimes, a form or link within a frame needs to break out of the frame and perform a normal page turn.

To add that behavior to a navigation element within a frame, simply add data-turbo-frame="_top" to the element, like this:

<%= link_to comment.body, edit_comment_path(comment), data: { turbo_frame: "_top" } %>

Targeting a frame from the outside

Eventually, you’ll find yourself needing to update the content of a frame using a link that isn’t wrapped in your target frame. This often comes up when building tabbed content; the navigation menu will be placed outside of the Turbo Frame that contains the actual content.

To handle situations like this, we can use the same turbo-frame data attribute to tell Turbo that the navigation should occur in a specific turbo frame.

Using our tabbed content example, we may have a page layout like this:

<ul>
  <li>
    <a href="user/1/profile" data-turbo-frame="main">
      Profile
    </a>
  </li>
  <li>
    <a href="user/1/favorites" data-turbo-frame="main">
      Favorites
    </a>
  </li>
</ul>
<turbo-frame id="main"></turbo-frame>

Clicks on either of the user links will generate a request with a Turbo-Frame: main header and the response will update the content of the main turbo frame, even though the links are not wrapped within the frame.

Lazy loading Frames

The src attribute on a frame can be combined with loading=lazy to mark a frame as lazy-loaded. Lazy-loaded frames will not fetch their content from the server until they’re visible on the page.

Lazy loading is especially helpful for modals, popovers, and other non-critical, below-the-fold content.

Lazy loaded frames can be combined with a spinner or other loading indicator, like this:

<%= turbo_frame_tag "lazy_frame", src: comments_path, loading: "lazy" do %>
  <div>I'm a loading spinner</div>
<% end %>

Note that loading="lazy" must be combined with a src attribute, otherwise lazy loading does nothing

Frame events

Turbo comes packed with lifecycle events, including two frame specific events:

  • turbo:frame-render
  • turbo:frame-load

These events (added in Turbo 7.0.0-rc2) fire each time a frame is loaded or reloaded. Developers can use these events to attach behavior to an element within a frame or animate the entry of a frame element, and they pair nicely with Stimulus.

In addition to these built in events, a useful complement to the emitted events is the busy attribute. The busy attribute is automatically applied to a frame when a request for the frame’s content begins and is removed when the request finishes.

// From Turbo's source code: turbo/src/util.ts
export function markAsBusy(...elements: Element[]) {
  for (const element of elements) {
    if (element.localName == "turbo-frame") {
      element.setAttribute("busy", "")
    }
    element.setAttribute("aria-busy", "true")
  }
}

This attribute can be useful for adding loading indicators to long running requests and otherwise tracking the state of frames between the beginning of a frame request and the turbo:frame-render and turbo:frame-load events firing.

Refreshing Frames

Sometimes, users need the ability to refresh the contents of a frame without refreshing the entire page.

In ideal circumstances, a Turbo Stream broadcast automatically updates the contents of the frame without needing a manual refresh, we don’t always get to work in ideal circumstances.

In earlier versions of Turbo, refreshing a frame required workarounds like appending a timestamp to the src attribute. Today, those workarounds are no longer necessary and Turbo will happily update a frame any number of times with an indentical src on each request.

We can build a refreshable frame like this:

<%# app/views/games/show.html.erb %>
<%= turbo_frame_tag @game do %>
  <div><%= @game.home_score %></div>
  <div><%= @game.away_score %></div>
  <%= link_to "Update score", game_path(@game) %>
<% end %>

Here, the first time a user clicks the update link, the turbo-frame will be updated with an src like this:

<turbo-frame id="game_10" reloadable="" src="http://localhost:3000/games/10">

Additional clicks on the update link will fetch updated scores from the server and reload the content of the frame.

Note that a currently open PR (as of the time this article was last updated, December 2021) proposes an alternative implementation of reloadable frames that will prevent the reloadable attribute from being exposed while keeping the behavior intact. In future versions of Turbo the reloadable attribute may not longer exist or be neccessary.

Conditional template rendering using variants

As you use Turbo Frames in Rails, you’ll eventually run into the desire to have a single controller action respond to both Turbo Frame requests and regular, full-page requests.

A common example of this is rendering a /new page as both a standalone page and as frame content inside of a modal. You could implement a conditional in your controller action to check for the Turbo Frame header, but that quickly starts to clutter up your controllers.

An alternative approach is to use variants to render different content based on the inbound request headers, to keep your controllers cleaner.

class ApplicationController < ActionController::Base
  before_action :turbo_frame_request_variant
  private

  def turbo_frame_request_variant
    request.variant = :turbo_frame if turbo_frame_request?
  end
end

This approach adds a before_action to the ApplicationController that checks if the inbound request is a Turbo Frame request using the turbo_frame_request? method provided by turbo-rails. When the request is a turbo frame, Rails will look for a Turbo Frame variant (new.html+turbo_frame.erb, for example) and render that variant when it exists. Otherwise, the default html.erb view will be used

This approach was originally described here. While there was a proposal to add this variant-based approach to turbo-rails, those proposals were rejected. The guidance from the maintainers is that the turbo_frame_request? to detect Turbo Frame requests is the only support that will be added because “If you’re branching all your responses for frames vs not, something isn’t right, and we should investigate those pressures in a different way.”.

In my experience, judicious use of variants to serve different content for Frame requests is sometimes the simplest and cleanest path to achieve a desired outcome but YMMV.

Limitations & Gotchas

Turbo Frames and page layouts

Turbo Frames in the DOM are implemented as custom HTML elements (the <turbo-frame> tag we’ve seen in this article). We can add classes to them (<%= turbo_frame_tag post, class: "classes" %>) and style them; however, because they exist in the DOM and need to wrap their content to be useful, inserting them into existing layouts can cause issues without some planning.

In particular, you can’t place a frame inside of a table row. While there are workarounds for this, it is worth knowing that tables and Turbo Frames don’t play well together out of the box.

In addition to table issues, you may sometimes run into layout issues with flex box and grid layouts when frames are the direct descendant of the container element. You’ll sometimes find that your nicely spaced three element flex layout now breaks because all three elements need to be wrapped in a single <turbo-frame> tag.

A route worth exploring in these situations is adding display: contents to the <turbo-frame> element, as mentioned here.

Responding to a Turbo Frame request without a matching Turbo Frame

As you build applications with Turbo Frames, you will inevitably run into an issue in development where you click a link within a frame and all of the content within the frame disappears. You’ll check the Rails logs and see the view you expected was rendered without errors.

Why did everything disappear?

The first thing to do when this happens is to check the JavaScript console for errors. Turbo’s JavaScript expects that the HTML response to a Turbo Frame request will contain a matching Turbo Frame element. If no match is found, Turbo will empty the frame of all content and raise an error like this in the JavaScript console:

Response has no matching <turbo-frame id="comments"> element

This can be a little confusing at first, since the content renders fine from the server but once you get used to Turbo processing the rendered HTML and updating the DOM, you’ll be able to quickly debug missing frame element errors like this one.

When in doubt, check for JavaScript errors.

Updating page URL when navigating within frame

When a user clicks on a link within a frame, the URL of the page won’t change. This makes sense most of the time — the user hasn’t navigated to a new page, they’ve simply updated some of the content on the existing page.

One use case where the URL not changing can be problematic is when the user is applying sorting and filtering options by clicking links within a frame. Imagine a table layout with links to sort the table by each column, for example.

In those cases, a developer may wish to update the URL of the page to reflect the currently applied filters so that the user can copy/paste the URL to share a specific view of the table.

As of Turbo’s 7.1 release, developers can now update the page URL from a frame navigation by adding a turbo-action data attribute to the form or link that triggers the frame navigation. Options for turbo-action are replace, to replace the current URL in the browser’s history, or advance, to push a new entry in the history. When building search forms, you’ll likely want to use advance to update the page URL with each new search. In Rails, using a standard form_with search form, your code to push frame navigation into the browser’s history might look like this:

<%= form_with url: customers_path, method: :get, data: { turbo_frame: "customers", turbo_action: "advance" } do |form| %>
  <%# Some form content goes here %>
<% end %>

Wrapping up

Today we looked in detail at the current state of Turbo Frames — what they are, how to use them in Rails, and the some of the ways frames can help you build modern, efficient applications.

Turbo Frames in Rails applications naturally pair very well with Stimulus and work exceptionally well when paired with Turbo Streams to manage broadcasted updates and more targeted content updates.

With Hotwire coming by default to new Rails 7 applications, I’m happy to report that after a year of use, building Ruby on Rails applications with the Hotwire stack is simple, straightforward, and adds very little overhead to your development experience. For many Rails applications, all of the reactivity you need can be achieved with the Hotwire stack, and Turbo Frames will play a key role in many applications in the years to come.

While you begin to experiment, keep in mind that Turbo is still in very active development. Throughout this article, I linked to open PRs that will improve Turbo Frames and make some of the techniques described in this article simpler to implement — as time goes on, expect Turbo and Turbo Frames to continue to improve and for turbo-rails to continue to add functionality to help Rails developers use frames effectively in their applications.

Ready to go deeper? (Some shameless self-promotion ahead, sorry!)

Finally, if you want to learn about implementing Turbo Frames in a complex Rails application, I wrote a book on building a modern Ruby on Rails application with my preferred stack, including Hotwire, StimulusReflex, CableReady, and friends. The book is written in this same, step-by-step tutorial style that you find throughout this blog and aims to offer solutions for a variety of common, real-world web application problems without reaching for the weight of a full frontend framework.

That’s all for today. As always, thanks for reading!