Examples

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.

ruby
Welcome to the phlex-reactive chat demo!
you
Send a message — it broadcasts with zero custom JS.
app/components/chat_room_component.rb
# 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
end

The 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#

app/models/chat_message.rb
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]
end
db/migrate/XXXX_create_chat_messages.rb
create_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)#

app/components/chat/message.rb
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
end

The 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)#

app/components/chat/composer.rb
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
end

The 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)#

app/components/chat/room.rb
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
end

This 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)#

config/routes.rb
get "chat" => "chats#show"
app/controllers/chats_controller.rb
class ChatsController < ApplicationController
  def show
    room = params[:room].presence || "lobby"
    render Chat::Room.new(room:, messages: ChatMessage.for_room(room).last(50))
  end
end

What happens when you click Send#

  1. The reactive controller serializes { token, act: "send_message", params: { body } } and POSTs to /reactive/actions.
  2. The endpoint verifies the signed token, rebuilds the composer, and runs send_message inside a transaction.
  3. send_message creates the ChatMessage and calls broadcast_append_to(..., exclude: reactive_connection_id).
  4. The broadcast fans the rendered Chat::Message out over the stream transport to every subscribed room — except the sender's own connection, which exclude: filters out.
  5. Each other tab's Turbo appends it to #chat-messages-<room>.
  6. 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_id suppresses 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 @author from current_user, and authorize in send_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: alongside exclude: — 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).
No login, no Action Cable channel, no Redis, no JavaScript you write — and the new code is less than the Stimulus controller alone would have been.