Pitfalls & Burns

Turbo Frames

Out of Frame

Inline-edit sections that work perfectly inside their frames, while the completeness meter, checklist, and go-live banner, derived from the same fields outside every frame, go stale on every save.

app/views/accounts/update.turbo_stream.erb
1
2
3
4
5
6
7
8
9
10
11
12
<%= turbo_stream.replace dom_id(@account, @section.to_sym),
      partial: "accounts/section",
      locals: { account: @account, section: @section } %>

<%= turbo_stream.replace dom_id(@account, :meter),
      partial: "accounts/meter", locals: { account: @account } %>

<%= turbo_stream.replace dom_id(@account, :checklist),
      partial: "accounts/checklist", locals: { account: @account } %>

<%= turbo_stream.replace dom_id(@account, :banner),
      partial: "accounts/banner", locals: { account: @account } %>
The original account setup page, before the meter and checklist. Each section edits in place inside its own frame, exactly as intended.
The account setup page, pitfall version. Sections save, but the meter, checklist, and banner only catch up on reload.
The account setup page, refactored version. Every save updates the section, the meter, the checklist, and the banner together.

The scenario

Inline editing is the classic Turbo Frames feature. We're building an account setup page where people fill in their profile before it goes public: basics, an about section, and links. Each section is wrapped in a frame, and its Edit link swaps that region to a form. Save, and it swaps back. No full page reloads and no custom JavaScript.

Under the hood, each frame in show.html.erb has a matching frame around the form in edit.html.erb. When the form is saved, update redirects back to the show page, and Turbo pulls the matching frame out of it. On day one, this is exactly the right tool.

The burn

Then the requirements grow. Most people aren't finishing their setup, so their profiles stay hidden, and we're asked to add a completion meter along the top of the page, a checklist in the sidebar, and a banner that announces when the profile is live. All three are derived from the same fields the sections edit, in _meter.html.erb, _checklist.html.erb, and _banner.html.erb.

Now every save half-works. The section swaps back with the new values, but the meter, checklist, and banner don't move. Reload the page and they're correct: the server knew all along, but only one frame's worth of the response ever reaches the page.

This is the dead end of frames. A frame declares, at render time, that requests from inside this box update exactly this box. That holds as long as each action only changes one region. Once an action needs to change several parts of the page that no single frame can wrap, we're stuck. Wrapping everything in one big frame would throw away whatever someone was typing in another section.

The fix

It's tempting to patch around it: embed streams inside the frame response, or have a Stimulus controller listen for frame loads and fetch the other regions. Both add indirection. Instead we switch to the tool built for this: Turbo Streams.

The sections become plain elements with dom_id ids in _section.html.erb and _section_form.html.erb. The Edit and Cancel links ask for a stream, and edit.turbo_stream.erb and show.turbo_stream.erb each replace one section. update.turbo_stream.erb is the honest list of what a save touches: the section, the meter, the checklist, and the banner. accounts_controller.rb stays the same length; the update action doesn't even redirect.

Why it matters

Frames make us decide what an action updates before we know. Streams let the action decide. "What should this update?" is a question whose answer belongs to the action, and product requirements have a way of making that answer bigger than any region we drew.

That doesn't make frames wrong. A self-contained region with its own navigation, like a modal or a wizard step, is a fine fit. But a frame is a bet that the feature stays one box, and it's worth reaching for streams first when an action is likely to touch more than one part of the page.

Read More