Cross-tab chat (the showcase)
A live chat where a message typed in one tab appears in every other subscribed tab — a record-backed action plus one broadcast, no Action Cable channel and no JavaScript you write.
Try it#
A real chat room, rendered right here. Type a message and Send — it creates a ChatMessage, appends it to the list, and broadcasts to every subscriber over Turbo Streams (open a second tab to watch it arrive live). The sender is excluded from the broadcast (exclude: reactive_connection_id), so Send appends exactly once. No Stimulus, no hand-picked Turbo target.
# frozen_string_literal: true
# The chat room: subscribes to the room's Turbo Stream and renders the existing
# messages + the composer. New messages arrive via broadcast (see
# ChatComposerComponent#send_message), appended live to #chat-messages.
#
# NOTE: this demo runs on the in-process async Action Cable adapter, so live
# updates are same-process. True cross-tab/cross-client delivery needs a real
# pub/sub backend (Redis or pgbus) — out of scope for the demo site.
class ChatRoomComponent < Phlex::HTML
include Phlex::Rails::Helpers::TurboStreamFrom
def initialize(room: 'lobby', messages: [])
@room = room
@messages = messages
end
def view_template
div(class: 'flex flex-col gap-3') do
turbo_stream_from(*ChatMessage.stream_key(@room))
div(id: "chat-messages-#{@room}",
class: 'flex flex-col gap-1 min-h-32 max-h-80 overflow-y-auto',
data: { testid: 'chat-messages' }) do
@messages.each { render ChatMessageComponent.new(chat_message: it) }
end
render ChatComposerComponent.new(room: @room)
end
end
endThe example that proves the model#
This is the example that proves the whole model: a client action (send) and a server broadcast (fan-out) both reduce to "render this component into the DOM by its id." About 60 lines of Ruby, end to end.
The one broadcasting detail that makes it correct: the fan-out passes exclude: reactive_connection_id so the sender doesn't get a doubled message — the actor already saw it via the action's own HTTP reply. That single argument, not any DOM-level dedup, is why a Send appends exactly once for the person who sent it.
1. Model#
class ChatMessage < ApplicationRecord
validates :body, presence: true
scope :for_room, ->(room) { where(room:).order(:created_at, :id) }
# The RAW stream key parts both subscribers and broadcasters agree on.
# Return the parts, not a pre-built "chat:lobby" string — both sides
# splat this so turbo_stream_from and broadcast_append_to build the
# same key. (Double-keying a pre-built string trips pgbus's separator guard.)
def self.stream_key(room) = ["chat", room]
endcreate_table :chat_messages do |t|
t.string :room, null: false, default: "lobby"
t.string :author, null: false, default: "anon"
t.text :body, null: false
t.timestamps
end
add_index :chat_messages, %i[room created_at]2. One message (record-backed, self-targeting)#
class Chat::Message < ApplicationComponent
include Phlex::Reactive::Streamable
def initialize(chat_message:) = @message = chat_message
def id = dom_id(@message) # stable id == broadcast target
def self.model_param_name = :chat_message
def view_template
div(id:, class: "msg") do
div(class: "author") { @message.author }
div(class: "body") { @message.body }
div(class: "time") { @message.created_at.strftime("%H:%M:%S") }
end
end
endThe message is broadcast-only, so it includes just Phlex::Reactive::Streamable and hand-writes #id and .model_param_name. A full Phlex::Reactive::Component (the composer below) would get dom_id(record) as its #id for free (issue #81) — here we spell it out because a Streamable-only class has no reactive_record to derive it from.
3. The composer (a reactive action that creates + broadcasts)#
class Chat::Composer < ApplicationComponent
include Phlex::Reactive::Component # pulls in Streamable too
reactive_state :room, :author # record-less: signed state is the room/author
action :send_message, params: { body: :string }
def initialize(room: "lobby", author: nil)
@room = room
@author = author.presence || "anon-#{rand(1000)}"
end
def id = "chat-composer-#{@room}"
def send_message(body:)
body = body.to_s.strip
return if body.blank?
message = ChatMessage.create!(room: @room, author: @author, body:)
# Fan-out: append the rendered message to #chat-messages-<room> in
# every subscribed tab. Splat the RAW key parts so subscribe and
# broadcast agree; exclude the actor's own connection so the SENDER
# doesn't get a doubled message (they already got the action's reply).
Chat::Message.broadcast_append_to(
*ChatMessage.stream_key(@room),
target: "chat-messages-#{@room}",
model: message,
exclude: reactive_connection_id # suppress the actor's own echo
)
end
def view_template
div(**reactive_root(class: "composer")) do
input(type: "text", name: "body", placeholder: "Message as #{@author}…", autocomplete: "off")
button(**on(:send_message)) { "Send" }
end
end
endThe Stimulus runtime auto-collects the name="body" field on the button click, so the action receives body: with no <form> and no data-* plumbing.
exclude: reactive_connection_id is the whole point of this example. An append inserts a new child into the messages container — it is not id-deduped the way a replace/morph of an existing element is. Without exclude:, the sender's own subscribed tab would receive the broadcast and append the message a second time, on top of the copy the action reply already rendered. exclude: names the actor's connection so the broadcast reaches everyone but them. It's a transport option (exclude: / visible_to:) forwarded to pgbus — with the plain Action Cable adapter it's ignored (see the room's note).
4. The room (subscribe + list + composer)#
class Chat::Room < ApplicationComponent
def initialize(room: "lobby", messages: [])
@room = room
@messages = messages
end
def view_template
div(class: "chat") do
# Splat the RAW key parts — the SAME parts the composer broadcasts to.
turbo_stream_from(*ChatMessage.stream_key(@room))
div(id: "chat-messages-#{@room}", class: "messages") do
@messages.each { |m| render Chat::Message.new(chat_message: m) }
end
render Chat::Composer.new(room: @room)
end
end
endThis docs site's live demo runs on the in-process async Action Cable adapter, not pgbus. So the transactional guarantee and exclude: / visible_to: scoping are inert on the running demo, and true cross-browser delivery needs a real pub/sub backend (Redis or pgbus). Wire pgbus in a real app and the same code above becomes transactional, reconnect-safe, and honors exclude:. See Broadcasting & live updates.
5. Controller + route (no auth, for the demo)#
get "chat" => "chats#show"class ChatsController < ApplicationController
def show
room = params[:room].presence || "lobby"
render Chat::Room.new(room:, messages: ChatMessage.for_room(room).last(50))
end
endWhat happens when you click Send#
- The
reactivecontroller serializes{ token, act: "send_message", params: { body } }and POSTs to/reactive/actions. - The endpoint verifies the signed token, rebuilds the composer, and runs
send_messageinside a transaction. send_messagecreates theChatMessageand callsbroadcast_append_to(..., exclude: reactive_connection_id).- The broadcast fans the rendered
Chat::Messageout over the stream transport to every subscribed room — except the sender's own connection, whichexclude:filters out. - Each other tab's Turbo appends it to
#chat-messages-<room>. - The action's own HTTP reply re-renders the composer (clearing the input) for the sender — that reply, not the broadcast, is how the sender sees their own message, so they see it exactly once.
Why this is better than the Hotwire version#
- No Stimulus controller, no Action Cable channel, no Redis.
- Transactional (with pgbus): if
create!or the broadcast raised, the transaction rolls back — no half-sent message, no ghost broadcast. - Reconnect-safe (with pgbus): a tab that was briefly offline replays the messages it missed (
Last-Event-ID+ the PGMQ archive). - Actor-correct out of the box:
exclude: reactive_connection_idsuppresses the sender's own echo, so no client-side dedup logic is needed. - The new code is less than the Stimulus controller alone would have been.
Going further#
- Authenticated chat: drop
reactive_state :author, set@authorfromcurrent_user, and authorize insend_message. - Per-user rooms:
turbo_stream_from(*ChatMessage.stream_key(current_user))and broadcast to the same raw parts. - Scope who sees a broadcast: pass
visible_to:alongsideexclude:— the transport-level companion for delivering a message to only some connections (honored on pgbus). See Broadcasting & live updates. - Typing indicators / presence: use pgbus presence (
Pgbus.stream(...).presence.join/leave).