Real-Time UI Without React: A Turbo Streams + Stimulus Pattern That Holds Up in Production

The reflex on most teams, when a product manager asks for “live updates,” is to reach for a front-end framework and a JSON API. You stand up React, build a serializer layer, wire WebSockets to a client store, and now you maintain two models of every record: the one in Postgres and the one in the browser. They drift. The bugs that result are the worst kind — intermittent, environment-specific, impossible to reproduce in a test.

Hotwire’s bet is that you do not need any of that for the large majority of real-time UI. The server already knows how to render the record; let it render the record and ship the HTML over the wire. This is not a toy claim. Below is the pattern we reach for on Rails engagements when the requirement is “this list should update when someone else changes it” — a live activity feed, a kanban board, a notifications tray, an order-status panel.

The three primitives, briefly

Hotwire is three things working together. Turbo Frames scope a piece of the page so that links and forms inside it replace only that region. Turbo Streams deliver fragments of HTML with an instruction — append, prepend, replace, update, remove — that the browser applies to a target element by DOM id. Stimulus is a small controller layer for the behavior HTML cannot express on its own: focus management, copy-to-clipboard, debounced inputs.

The key insight is that a Turbo Stream is the same fragment whether it arrives as the response to a form submission or pushed down a WebSocket. One template, two delivery channels. You write the rendering once.

The model: broadcast on commit

Start at the model. broadcasts_to wires Active Record callbacks to Action Cable so that any change to the record pushes a stream to a subscribed channel.

class Message < ApplicationRecord
  belongs_to :room
  belongs_to :author, class_name: "User"

  broadcasts_to ->(message) { [message.room, :messages] },
    inserts_by: :prepend,
    target: "messages"
end

One caveat worth internalizing: those callbacks fire after commit, which means they run inside the request that triggered the write. For a chat message that is fine. For a model written in a tight loop — a bulk import, a batch job touching thousands of rows — you do not want a broadcast per row hammering Action Cable. In those cases drop the declarative macro and broadcast explicitly from the place that owns the batch, once, after the loop. The macro is a convenience, not a law.

The view: subscribe and target

The list view subscribes to the same stream name and declares the target the broadcasts will land in.

<%= turbo_stream_from @room, :messages %>

<div id="messages">
  <%= render @messages %>
</div>

The partial _message.html.erb must render an element whose DOM id matches what the broadcast targets. dom_id is the contract here — use it on both ends and the wiring is automatic.

<div id="<%= dom_id(message) %>" class="message">
  <strong><%= message.author.name %></strong>
  <%= message.body %>
</div>

That is the entire real-time path. A user in another browser posts a message; the after_create_commit callback prepends the rendered partial to #messages in every subscribed session. No JSON, no serializer, no client store, no reconciliation logic. The HTML the second user sees is byte-for-byte the HTML the server would have rendered on a full page load.

The form: the same response, synchronously

The submitter should not wait for the broadcast to round-trip through Action Cable. The controller answers the form submission directly with a Turbo Stream, so the author sees their message instantly while everyone else gets it over the socket.

def create
  @message = @room.messages.create!(message_params.merge(author: current_user))
  respond_to do |format|
    format.turbo_stream do
      render turbo_stream: turbo_stream.replace(
        "new_message_form",
        partial: "messages/form", locals: { message: @room.messages.build }
      )
    end
    format.html { redirect_to @room }
  end
end

Note what the controller does not do: it does not append the message itself. The broadcast already handles insertion into #messages for everyone, including the author. The controller only resets the form. This avoids the classic double-render bug where the author sees their message twice — once from the controller response, once from the echo of their own broadcast. Let the broadcast own the list; let the controller own the form.

Where Stimulus earns its place

Turbo handles structure. Stimulus handles the things that are genuinely client-side: scrolling the new message into view, clearing and refocusing the input, disabling the submit button while in flight. A small controller is all it takes.

// app/javascript/controllers/messages_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["list", "input"]

  // fires whenever Turbo updates the stream target
  listTargetConnected() {
    this.listTarget.scrollTop = 0
  }

  reset() {
    this.inputTarget.value = ""
    this.inputTarget.focus()
  }
}

The discipline that keeps Stimulus controllers maintainable: they should never own data. If a controller is stashing model fields in JavaScript variables, that state will drift from the server. Controllers manipulate the DOM and respond to events. The source of truth stays in Postgres, rendered by Rails.

Authorization is not free — the gap people miss

The one place this pattern bites teams is stream authorization. turbo_stream_from @room, :messages generates a signed stream name, so a client cannot forge a subscription to an arbitrary room. Good. But the signed name only proves the server told this session that stream exists — it does not re-check, at broadcast time, whether the user is still allowed to see it. If a user is removed from a private room mid-session, their open socket keeps receiving messages until they reload.

The fix is to make membership part of the channel, not just the page. Subscribe through a custom channel that verifies authorization on subscribed and rejects when membership is revoked, rather than relying solely on the signed-name convenience. For low-stakes feeds the default is fine; for anything where a stale subscription is a data-leak, do the explicit check. This is exactly the kind of detail that does not show up in a tutorial and does show up in a security review.

When to reach for React anyway

This pattern is not a universal answer. If the interaction is a canvas editor, a spreadsheet with cell-level formulas, a drag-and-drop interface with continuous visual feedback, or anything with genuinely complex client-side state that must stay responsive without a server round-trip, a real front-end framework is the right tool. Turbo Streams shine for collaborative list-and-record UI: feeds, dashboards, status panels, inboxes, boards. That covers a surprising share of what gets built — and for that share, shipping HTML beats shipping JSON and a second copy of your domain model.

The takeaway

Real-time UI does not require a single-page app. For the common case — “this view should update when the data changes” — Turbo Streams let the server render once and deliver the same fragment over both HTTP and WebSocket. You delete an entire category of bug (client/server state drift) and a whole layer of code (the JSON API and its serializers) in the process. Reserve the heavy client framework for the interactions that genuinely need it, and let Rails do what Rails is good at for everything else.

We build and audit Hotwire-based UIs on Rails engagements regularly. If you are weighing a React rebuild against staying server-rendered, that is a conversation worth having before you commit.