DEV Community

Alex Aslam
Alex Aslam

Posted on

The Stained-Glass Artisan: Composing UIs with Turbo Frames

There is a quiet moment, just after a feature is "done," when you click a link and witness the browser's brutal ritual. The entire page—the meticulously crafted header, the complex sidebar, the persistent player—blinks out of existence. For a heart-stopping second, there is nothing. Then, it all rushes back, reassembling itself just to update a small counter in the corner.

We've accepted this as the cost of doing business on the web. Our server renders a complete, holistic page, a single, indivisible block of HTML. But our users don't experience the page as a monolith. They experience it as a collection of independent components: a shopping cart, a live feed, a search result list.

What if our technology reflected that reality? What if, instead of sculpting from marble—a single, heavy block—we worked like artisans crafting stained glass?

Welcome to the world of Turbo Frames. This is the story of decomposing the monolith, not in your backend, but in your UI. It's a journey from full-page reloads to a seamless, composed experience.

The Philosophy: The Stained-Glass Window

Imagine a grand stained-glass window. It's a single, magnificent image, but it's constructed from dozens of individual panes of glass. Each pane is independent. If one cracks, you don't smash the entire window; you replace just that single pane.

A Turbo Frame is a single pane of glass in your web application.

It is a self-contained container, a <turbo-frame> element, that can:

  • Lazily load its own content when the page loads.
  • Intercept clicks on links within it, fetching only the content for that frame.
  • Process form submissions within it, updating only the frame with the server's response.
  • Be targeted by other parts of your application for updates.

The browser is no longer a passive recipient of a full-page HTML blob. It becomes an active composer, orchestrating these independent frames into a seamless whole. The result is an application that feels alive, responsive, and surprisingly simple to build.

The Art of the Frame: Your Chisel and Glass

Let's move from philosophy to practice. We'll start with the most fundamental tool: the frame itself.

The Lazy-Loading Frame: A Pane of its Own

You have a complex "Daily Summary" dashboard widget. It's expensive to calculate, so you don't want it blocking the initial page render. A Turbo Frame makes this trivial.

In your view (app/views/dashboard/show.html.erb):

<!-- The rest of your fast-loading dashboard -->
<div class="grid">
  <div class="col">
    <h2>Recent Activity</h2>
    <!-- ... -->
  </div>

  <div class="col">
    <!-- This frame will fetch its content independently, after the page loads -->
    <turbo-frame id="daily_summary" src="<%= daily_summary_dashboard_path %>" loading="lazy">
      <div class="loading-spinner">Calculating your summary...</div>
    </turbo-frame>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

A dedicated controller action (app/controllers/dashboard_controller.rb):

def daily_summary
  # This can be a slow query; it doesn't hold up the main page.
  @metrics = calculate_complex_metrics

  # The magic: Rails automatically looks for a turbo_frame with the matching ID.
  # It renders `daily_summary.html.erb`, NOT `show.html.erb`.
  render partial: 'daily_summary' # Or let it implicitly render `app/views/dashboard/_daily_summary.html.erb`
end
Enter fullscreen mode Exit fullscreen mode

The partial (app/views/dashboard/_daily_summary.html.erb):

<turbo-frame id="daily_summary">
  <div class="summary-card">
    <h3>Your Day in Review</h3>
    <p>You completed <%= @metrics[:tasks] %> tasks.</p>
    <p>You earned $<%= @metrics[:revenue] %>.</p>
  </div>
</turbo-frame>
Enter fullscreen mode Exit fullscreen mode

The server returns only the HTML for the turbo-frame#daily_summary. Turbo intelligently plucks this out of the response and swaps it into the placeholder in the DOM. The user sees a loading spinner, then a smooth update, all without a single line of JavaScript.

The Interactive Frame: Isolated Navigation

This is where the magic truly sings. Imagine a product list with pagination and a detail view. In the old world, paginating would reload the entire page. With Turbo Frames, only the list updates.

The main view (app/views/products/index.html.erb):

<div class="product-layout">
  <!-- Frame for the list -->
  <turbo-frame id="products_list" class="list">
    <% @products.each do |product| %>
      <div class="product-item">
        <!-- Links inside a frame are automatically intercepted -->
        <%= link_to product.name, product_path(product), data: { turbo_frame: "product_detail" } %>
      </div>
    <% end %>

    <!-- Pagination lives inside the same frame -->
    <%= paginate @products %>
  </turbo-frame>

  <!-- Frame for the detail view -->
  <turbo-frame id="product_detail" class="detail">
    <!-- Initially empty, or you can load a default product -->
    <p>Select a product to see its details.</p>
  </turbo-frame>
</div>
Enter fullscreen mode Exit fullscreen mode

The show action (app/views/products/show.html.erb):

<!-- The key: This view ONLY renders the frame that needs updating. -->
<turbo-frame id="product_detail">
  <h1><%= @product.name %></h1>
  <p><%= @product.description %></p>
  <p>Price: <%= number_to_currency(@product.price) %></p>
  <!-- A form to add to cart, which could also target a 'cart' frame! -->
</turbo-frame>
Enter fullscreen mode Exit fullscreen mode

When a user clicks a product link, Turbo intercepts the click. It fetches the /products/1 page, but instead of rendering it, it extracts the #product_detail frame from the response and uses it to replace the current #product_detail frame. The list frame remains perfectly still. The browser doesn't even flinch.

The Masterpiece: A Dynamic Shopping Cart

Let's compose a classic example: adding an item to a cart without a page reload.

The Product view (from above) adds a form:

<turbo-frame id="product_detail">
  <h1><%= @product.name %></h1>
  <!-- This form submits *within* the frame, but we want to update a different frame (the cart). -->
  <%= form_with model: @order_line, url: cart_lines_path, method: :post,
                data: { turbo_frame: "cart" } do |form| %>
    <%= form.hidden_field :product_id %>
    <%= form.number_field :quantity, value: 1 %>
    <%= form.submit "Add to Cart" %>
  <% end %>
</turbo-frame>
Enter fullscreen mode Exit fullscreen mode

The Cart in the layout (app/views/layouts/application.html.erb):

<body>
  <header>
    <turbo-frame id="cart">
      <%= render partial: 'cart/cart' %>
    </turbo-frame>
  </header>
  <main>
    <%= yield %>
  </main>
</body>
Enter fullscreen mode Exit fullscreen mode

The Cart Controller (app/controllers/cart/lines_controller.rb):

def create
  @order_line = current_cart.add_product(product_id: params[:product_id], quantity: params[:quantity])

  if @order_line.save
    # Re-render the entire cart partial, which is wrapped in the <turbo-frame id="cart">
    render partial: 'cart/cart'
  else
    # You can even handle errors by re-rendering the form within the *product_detail* frame.
    render :new, status: :unprocessable_entity
  end
end
Enter fullscreen mode Exit fullscreen mode

The data: { turbo_frame: "cart" } on the form is the conductor's baton. It tells Turbo: "When this form submits, take the response and look for a <turbo-frame id="cart"> inside it. Then, update the existing cart frame on the page with that new content."

The user clicks "Add to Cart." The form submits. The cart count updates instantly. The product detail view remains, undisturbed. It feels like a client-side SPA, but it's built with the simple, reliable request-response cycle we know and love.

The Payoff: A Composed Experience

Working with Turbo Frames is a paradigm shift. It forces you to think in components, to design your backend endpoints not as "pages" but as "content providers" for specific parts of the UI.

  1. Radical Simplification: You delete reams of custom JavaScript for simple AJAX updates. The behavior is declarative, defined right in your HTML.
  2. Resilient UX: A failure in one frame (a slow-loading summary, an error in a form) doesn't bring down the entire page. The rest of the application remains fully functional.
  3. Progressive Enhancement: The foundation is solid HTML. If JavaScript fails, the links and forms still work; they just fall back to full-page reloads. The user's core task is never broken.
  4. Developer Joy: You spend less time wiring up event listeners and managing state, and more time designing the flow of your application. It brings the fun back to front-end development in Rails.

The Artisan's Call to Action

Look at your application. Find one small interaction—a tabbed interface, a "like" button that updates a counter, a filterable list. Now, imagine it as a piece of stained glass.

Wrap it in a <turbo-frame> tag. Give it an id. Point a link or a form at it. Witness the quiet miracle as that single component begins to live its own independent life, seamlessly recomposing itself within the greater whole.

You are no longer just building pages. You are composing experiences. You are an artisan of the web, and Turbo Frames are your medium. Now, go build something beautiful.

Top comments (0)