DEV Community

Alex Aslam
Alex Aslam

Posted on

The Conductor's Baton: Orchestrating Real-Time UIs with Turbo Streams

The Conductor's Baton: Orchestrating Real-Time UIs with Turbo Streams

There is a profound silence in the space between a user's action and the application's response. For years, we've filled that silence with JavaScript. A click happens, we fetch(), we get JSON, we painstakingly .innerHTML, we update state, we handle errors. We have become carpenters, hammering individual pieces of the DOM into place, one event at a time.

But what if we've been speaking the wrong language? What if the browser doesn't need a detailed blueprint, but a simple, direct command?

"Update this."
"Append that."
"Remove this other thing."

Welcome to Turbo Streams. This isn't just another technique; it's a philosophical shift. It's the moment we stop being carpenters and become conductors. Our Rails backend is no longer just a data API; it is the composer, and the browser is the orchestra, responding instantly to the graceful sweep of the baton.

The Philosophy: The Language of Change

Turbo Streams is a protocol. It's a simple, HTML-based language for describing changes to the DOM. It speaks in seven fundamental verbs:

  1. append
  2. prepend
  3. replace
  4. update
  5. remove
  6. before
  7. after

These verbs are delivered to the client wrapped in a <turbo-stream> element. The browser, equipped with the Turbo library, understands this language natively. It listens for these messages and executes them without any custom JavaScript on your part.

This is the crucial insight: We are not sending data; we are sending instructions. We've moved from shipping raw lumber to shipping pre-fabricated, installation-ready components.

The Art of the Stream: Your Baton and Score

Let's explore the three primary ways we conduct our UI: in response to a form submission, from a controller action, and in real-time over a WebSocket.

Movement I: The Form Response — A Solo Performance

The most immediate magic happens after a form submission. Imagine a classic "Add a comment" feature.

The View (app/views/posts/show.html.erb):

<!-- The list of comments, waiting to be conducted -->
<ul id="comments">
  <%= render @post.comments %>
</ul>

<!-- The form, poised to play its part -->
<%= form_with model: [@post, @post.comments.new], 
              id: "new_comment",
              data: { turbo_frame: :comments } do |form| %>
  <%= form.text_area :content %>
  <%= form.submit "Post Comment" %>
<% end %>
Enter fullscreen mode Exit fullscreen mode

The Controller (app/controllers/comments_controller.rb):

def create
  @comment = @post.comments.build(comment_params)

  respond_to do |format|
    if @comment.save
      # The magic: We respond with a Turbo Stream instruction
      format.turbo_stream do
        # This will render `app/views/comments/create.turbo_stream.erb`
      end
      format.html { redirect_to @post }
    else
      # We can even handle errors with a stream action
      format.turbo_stream do
        render :error
      end
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

The Score (app/views/comments/create.turbo_stream.erb):

<!-- A simple, powerful command: "Append this HTML to the element with the ID 'comments'." -->
<%= turbo_stream.append "comments" do %>
  <%= render @comment %>
<% end %>

<!-- A follow-up command: "Replace the HTML of the element with the ID 'new_comment' with this form." -->
<%= turbo_stream.replace "new_comment" do %>
  <%= render "form", post: @post, comment: @post.comments.new %>
<% end %>
Enter fullscreen mode Exit fullscreen mode

The user clicks "Post Comment." The form submits. The page doesn't reload. The new comment slides into the list, and the form resets itself, pristine and ready for the next thought. No event.preventDefault(), no FormData parsing, no document.createElement(). Just pure, declarative harmony.

Movement II: The Broadcast — A Symphony for the Whole Audience

This is where we move from a solo performance to a full symphony. We can broadcast these stream messages to every user connected via a WebSocket, creating real-time, collaborative experiences.

The Model (app/models/comment.rb):

class Comment < ApplicationRecord
  belongs_to :post
  belongs_to :user

  # The magic hook: after creation, broadcast to the world.
  after_create_commit :broadcast_to_post

  private

  def broadcast_to_post
    # Render a Turbo Stream template to a string, targeting this post's stream.
    turbo_stream_content = CommentsController.render(
      partial: "comments/broadcast",
      locals: { comment: self }
    )

    # Broadcast the instruction to everyone subscribed to this post's channel.
    Turbo::StreamsChannel.broadcast_append_to(
      post,
      target: "comments",
      html: turbo_stream_content
    )
  end
end
Enter fullscreen mode Exit fullscreen mode

The Partial (app/views/comments/_broadcast.html.erb):

<!-- The HTML that will be appended to everyone's 'comments' list -->
<li id="<%= dom_id(comment) %>">
  <strong><%= comment.user.name %>:</strong>
  <%= comment.content %>
</li>
Enter fullscreen mode Exit fullscreen mode

Now, when User A posts a comment on a blog post, the after_create_commit callback fires. It broadcasts a turbo-stream.append message to a channel unique to that specific post. Every other user viewing that same post—User B, User C, anyone—instantly sees the new comment appear in their list, as if by magic. The conductor has signaled the entire orchestra, and they have all played the note in perfect unison.

The Masterpiece: A Live Auction

Let's compose a full, real-world scenario: a live auction.

The View (app/views/auctions/show.html.erb):

<div id="auction_lot_<%= @lot.id %>" class="lot">
  <h2><%= @lot.name %></h2>
  <p>Current Bid: <span id="current_bid"><%= number_to_currency(@lot.current_bid) %></span></p>
  <p>High Bidder: <span id="high_bidder"><%= @lot.high_bidder&.name || "None" %></span></p>

  <!-- The bidding form, only visible if the auction is active -->
  <% if @lot.active? %>
    <%= form_with model: [@lot, Bid.new], data: { turbo_frame: "_top" } do |form| %>
      <%= form.number_field :amount %>
      <%= form.submit "Place Bid" %>
    <% end %>
  <% end %>
</div>

<div id="bid_history">
  <h3>Bid History</h3>
  <%= render @lot.bids.order(created_at: :desc) %>
</div>
Enter fullscreen mode Exit fullscreen mode

The Bid Model (app/models/bid.rb):

class Bid < ApplicationRecord
  belongs_to :lot
  belongs_to :user

  after_create_commit :broadcast_bid

  private

  def broadcast_bid
    # Broadcast THREE simultaneous updates to the lot's channel.

    # 1. Update the current bid amount for everyone.
    Turbo::StreamsChannel.broadcast_update_to(
      lot,
      target: "current_bid",
      html: number_to_currency(amount)
    )

    # 2. Update the high bidder's name.
    Turbo::StreamsChannel.broadcast_update_to(
      lot,
      target: "high_bidder",
      html: user.name
    )

    # 3. Prepend the new bid to the history log.
    turbo_stream_content = ApplicationController.render(
      partial: "bids/bid",
      locals: { bid: self }
    )

    Turbo::StreamsChannel.broadcast_prepend_to(
      lot,
      target: "bid_history",
      html: turbo_stream_content
    )
  end
end
Enter fullscreen mode Exit fullscreen mode

A user places a bid. The form submits, the bid is saved, and instantly, across every screen watching this auction:

  • The "Current Bid" amount updates.
  • The "High Bidder" name changes.
  • The new bid is prepended to the history log.

The entire UI is alive, synchronized, and responsive. The feeling is electric. And you, the developer, wrote no channel subscription logic, no JSON parsers, no DOM diffing algorithms. You simply wrote a series of declarative commands.

The Payoff: The Conductor's Serenity

Embracing Turbo Streams changes everything.

  1. Eliminated Glue Code: You delete the brittle JavaScript that tethers your frontend to your backend. The connection is now formalized, robust, and built-in.
  2. Consistent State: Your server is the single source of truth. It doesn't just manage data; it manages the UI state for all connected clients. Race conditions and client-side state mismatches become a thing of the past.
  3. Blazing Performance: The user perceives instant feedback. The UI updates are surgical, targeting only the parts of the DOM that need to change.
  4. Developer Nirvana: You stay in the flow. You think in terms of user interactions and server-side reactions, not in terms of AJAX calls and promise chains. You are composing a symphony, not untangling a knot of wires.

The Conductor's Call to Action

Pick one interaction in your application that feels like it "should" be real-time. A notification counter. A live score. A collaborative to-do list.

Now, imagine it. A user takes an action. Your model callback fires. It broadcasts a simple, HTML command: append, update, remove.

Watch as that command ripples out, touching every connected browser, transforming their UIs in perfect, silent harmony. You are no longer a carpenter. You are a conductor, and with the gentle sweep of your Rails backend, you can make the web sing.

Now, go raise your baton.

Top comments (0)