Turbo Frames: Declarative Partial-Page Replacement Without Custom JavaScript
Turbo Frames let you declare self-contained navigation contexts in HTML — no custom fetch logic, no manual DOM swaps. This post shows how lazy-loaded frames, cross-frame targeting, and event hooks replace boilerplate with a few attributes.
06 Jul 2025, 13:32 UTC

The Problem: Partial Updates Without the Boilerplate
You need to refresh a comments section, a sidebar widget, or a modal dialog without reloading the entire page. The traditional path involves writing fetch calls, managing loading states, swapping DOM nodes, and reinitializing any third‑party widgets — every single time. Turbo Frames eliminates that boilerplate by letting you declare a self‑contained navigation context directly in HTML.
How Turbo Frames Work
A <turbo-frame id="unique-id"> wraps a DOM segment. Any link click or form submission inside that frame targets the same frame by default. The server responds with HTML that contains a matching <turbo-frame id="unique-id"> wrapper; Turbo discards everything outside it and replaces only the frame's inner content. No custom JavaScript required.
Frames also support lazy loading via a src attribute. The frame fetches its content on first render or when scrolled into view, which is ideal for heavy sections like dashboards or infinite‑scroll feeds.
Cross‑Frame Navigation
Elements outside a frame can target a specific frame using data-turbo-frame="target-id". This lets a button in the header refresh a sidebar frame, or a pagination link in one frame update a results frame elsewhere on the page — all without a full reload.
Worked Example: Lazy‑Loaded Comments Section
Assume a Rails 7+ app with the turbo-rails gem. In your view:
<turbo-frame id="comments" src="<%= comments_path(post) %>>
<p>Loading comments…</p>
</turbo-frame>
The src attribute tells Turbo to fetch /posts/1/comments when the frame enters the viewport. The controller action renders a partial that must wrap its output in a matching frame:
# app/views/comments/index.html.erb
<turbo-frame id="comments">
<%= render @comments %>
<%= paginate @comments %>
</turbo-frame>
Where to verify: Open the browser's Network tab, scroll to the comments area, and confirm a single HTML request fires for /posts/1/comments with a 200 response containing the matching <turbo-frame id="comments">.
Permissions & risks: No special permissions; the request uses the same session/cookies as the parent page. Risk: if the response omits the matching frame id, Turbo falls back to full-page replacement (Turbo Drive behavior), causing a visible flash.
Trade‑offs and Limitations
- No URL/history update by default. Deep‑linking to a frame's state requires
data-turbo-action="advance"on the triggering element or manualhistory.pushState. - Globally unique ids are mandatory. Nested frames with the same id produce undefined replacement behavior.
- Lazy frames don't execute
<script>tags in their response. Load scripts separately or usedata-turbo-eval="false"with manual initialization. - Redirects on form submissions stay inside the frame; the final response must still contain the matching frame wrapper.
- Turbo 8 default form submission uses
fetchwithFormData; ensure your backend responds withContent-Type: text/htmland the frame wrapper.
Quick Diagnostic Check
In the browser console, listen for frame events to verify wiring:
document.addEventListener('turbo:frame-render', (e) => {
console.log('Frame:', e.detail.frame.id);
console.log('Response element:', e.detail.responseElement);
});
Trigger a frame navigation and confirm the logged frame.id matches your declared id and responseElement contains the new markup.
Next Steps
Add a single <turbo-frame> to a page section that currently uses a custom fetch + DOM swap. Replace the JavaScript with the declarative frame and lazy-load it via src. Verify the network request, the frame replacement, and that any third‑party widgets inside the frame reinitialize (listen for turbo:frame-load if needed). That one change often removes dozens of lines of fragile glue code.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.