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.

Send

```ruby
# 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

```ruby
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
```

```ruby
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)

```ruby
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)

```ruby
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)

```ruby
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
```

> **Note:** **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](https://phlex-reactive.zoolutions.llc/docs/broadcasting).

## 5. Controller + route (no auth, for the demo)

```ruby
get "chat" => "chats#show"
```

```ruby
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](https://phlex-reactive.zoolutions.llc/docs/broadcasting).
- **Typing indicators / presence:** use pgbus presence (`Pgbus.stream(...).presence.join/leave`).

> **Tip:** 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.