Skip to content

Decompose with Turbo Frames

Turbo Frames allow predefined parts of a page to be updated on request. Any links and forms inside a frame are captured, and the frame contents automatically updated after receiving a response. Regardless of whether the server provides a full document, or just a fragment containing an updated version of the requested frame, only that particular frame will be extracted from the response to replace the existing content.

Frames are created by wrapping a segment of the page in a <turbo-frame> element. Each element must have a unique ID, which is used to match the content being replaced when requesting new pages from the server. A single page can have multiple frames, each establishing their own context:

<body>
<div id="navigation">Links targeting the entire page</div>

<turbo-frame id="message_1">
<h1>My message title</h1>
<p>My message content</p>
<a href="/messages/1/edit">Edit this message</a>
</turbo-frame>

<turbo-frame id="comments">
<div id="comment_1">One comment</div>
<div id="comment_2">Two comments</div>

<form action="/messages/comments">...</form>
</turbo-frame>
</body>

This page has two frames: One to display the message itself, with a link to edit it. One to list all the comments, with a form to add another. Each create their own context for navigation, capturing both links and submitting forms.

When the link to edit the message is clicked, the response provided by /messages/1/edit has its <turbo-frame id="message_1"> segment extracted, and the content replaces the frame from where the click originated. The edit response might look like this:

<body>
<h1>Editing message</h1>

<turbo-frame id="message_1">
<form action="/messages/1">
<input name="message[name]" type="text" value="My message title">
<textarea name="message[content]">My message content</textarea>
<input type="submit">
</form>
</turbo-frame>
</body>

Notice how the <h1> isn’t inside the <turbo-frame>. This means it’ll be ignored when the form replaces the display of the message upon editing. Only content inside a matching <turbo-frame> is used when the frame is updated.

Thus your page can easily play dual purposes: Make edits in place within a frame or edits outside of a frame where the entire page is dedicated to the action.

Lazily Loading Frames

Frames don’t have to be populated when the page that contains them is loaded. If a src attribute is present on the turbo-frame tag, the referenced URL will automatically be loaded as soon as the tag appears on the page:

<body>
<h1>Imbox</h1>

<div id="emails">
...
</div>

<turbo-frame id="set_aside_tray" src="/emails/set_aside">
</turbo-frame>

<turbo-frame id="reply_later_tray" src="/emails/reply_later">
</turbo-frame>
</body>

This page lists all the emails available in your imbox immediately upon loading the page, but then makes two subsequent requests to present small trays at the bottom of the page for emails that have been set aside or are waiting for a later reply. These trays are created out of separate HTTP requests made to the URLs referenced in the src.

In the example above, the trays start empty, but it’s also possible to populate the lazy-loading frames with initial content, which is then overwritten when the content is fetched from the src:

<turbo-frame id="set_aside_tray" src="/emails/set_aside">
<img src="/icons/spinner.gif">
</turbo-frame>

Upon loading the imbox page, the set-aside tray is loaded from /emails/set_aside, and the response must contain a corresponding <turbo-frame id="set_aside_tray"> element as in the original example:

<body>
<h1>Set Aside Emails</h1>

<p>These are emails you've set aside</p>

<turbo-frame id="set_aside_tray">
<div id="emails">
<div id="email_1">
<a href="/emails/1">My important email</a>
</div>
</div>
</turbo-frame>
</body>

This page now works in both its minimized form, where only the div with the individual emails are loaded into the tray frame on the imbox page, but also as a direct destination where a header and a description is provided. Just like in the example with the edit message form.

Note that the <turbo-frame> on /emails/set_aside does not contain a src attribute. That attribute is only added to the frame that needs to lazily load the content, not to the rendered frame that provides the content.

Cache Benefits to Lazily Loading Frames

Turning page segments into frames can help make the page simpler to implement, but an equally important reason for doing this is to improve cache dynamics. Complex pages with many segments are hard to cache efficiently, especially if they mix content shared by many with content specialized for an individual user. The more segments, the more dependent keys required for the cache look-up, the more frequently the cache will churn.

Frames are ideal for separating segments that change on different timescales and for different audiences. Sometimes it makes sense to turn the per-user element of a page into a frame, if the bulk of the rest of the page is then easily shared across all users. Other times, it makes sense to do the opposite, where a heavily personalized page turns the one shared segment into a frame to serve it from a shared cache.

While the overhead of fetching lazy-loaded frames is generally very low, you should still be judicious in just how many you load, especially if these frames would create load-in jitter on the page. Frames are, however, essentially free if the content isn’t immediately visible upon loading the page. Either because they’re hidden behind modals or below the fold.

Targeting Navigation Into or Out of a Frame

By default, navigation within a frame will target just that frame. This is true for both following links and submitting forms. But navigation can drive the entire page instead of the enclosing frame by setting the target to _top. Or it can drive another named frame by setting the target to the ID of that frame.

In the example with the set-aside tray, the links within the tray point to individual emails. You don’t want those links to look for frame tags that match the set_aside_tray ID. You want to navigate directly to that email. This is done by marking the tray frames with the target attribute:

<body>
<h1>Imbox</h1>
...
<turbo-frame id="set_aside_tray" src="/emails/set_aside" target="_top">
</turbo-frame>
</body>

<body>
<h1>Set Aside Emails</h1>
...
<turbo-frame id="set_aside_tray" target="_top">
...
</turbo-frame>
</body>

Sometimes you want most links to operate within the frame context, but not others. This is also true of forms. You can add the data-turbo-frame attribute on non-frame elements to control this:

<body>
<turbo-frame id="message_1">
...
<a href="/messages/1/edit">
Edit this message (within the current frame)
</a>

<a href="/messages/1/permission" data-turbo-frame="_top">
Change permissions (replace the whole page)
</a>
</turbo-frame>

<form action="/messages/1/delete" data-turbo-frame="message_1">
<input type="submit" value="Delete this message">
(with a confirmation shown in a specific frame)
</form>
</body>

Next: Come Alive with Turbo Streams