Examples

# Example: Team inbox (the flagship)

One believable UI that composes the whole toolkit: reactive_collection rows, an optimistic archive that reverts on failure, disable_with:, cross-tab broadcasts, an on_client row menu, and error_flash — with no Stimulus controller and no hand-picked Turbo target.

## Try it

A real Team inbox. **Simulate incoming** pushes a new message (and broadcasts it to teammates). Open a row's **⋯** menu (a pure client op) to **Mark read** or **Archive**. Archive hides the row instantly and confirms with a self-dismissing toast. Try archiving the **locked** message — the server refuses, so the optimistic hide **reverts** and an error flash explains why. Turn on the latency toggle to watch the optimistic hide and the `disable_with:` guard.

Simulate network latency

Delay every action by 700 ms so the loading states are visible.

Team inbox

1

Simulate incoming

- Deploy finishedfrom ci⋯Mark readArchive

```ruby
# frozen_string_literal: true

# The FLAGSHIP example (issue #149): a believable Team inbox that composes the
# whole reactive toolkit in one UI —
#   * reactive_collection rows (archive/mark-read append/remove + count + empty);
#   * an optimistic: archive that hides the row in the same gesture, reverting if
#     the server denies (a "locked" message);
#   * disable_with: guarding the archive button against a double-click;
#   * cross-tab broadcast_*_to so a teammate's tab stays in sync;
#   * an on_client kebab menu on each row (pure client op, zero round trips);
#   * error_flash + a self-dismissing dismiss_after: toast on the reply.
#
# State-backed container (no single record); the size resolver reads the live DB
# count. Rows are tokenless and dispatch these actions keyed by message id.
class TeamInboxComponent < Phlex::HTML
  include Phlex::Reactive::Streamable
  include Phlex::Reactive::Component
  include Phlex::Rails::Helpers::TurboStreamFrom

  # A locked message refuses to archive — registered in the initializer so the
  # denial is a clean 403 the client reverts the optimistic hide on.
  class Locked < StandardError; end

  SUBJECTS = ['Deploy finished', 'New signup', 'Payment received', 'Support ticket #4821',
              'Weekly digest'].freeze
  SENDERS = %w[ci billing support system].freeze

  reactive_collection :messages,
                      item: InboxMessageComponent,
                      container: 'inbox-messages',
                      count: 'inbox-count',
                      empty: InboxEmptyComponent,
                      size: -> { InboxMessage.count }

  action :archive, params: { id: :integer }
  action :mark_read, params: { id: :integer }
  action :simulate_incoming

  def id = 'team-inbox'

  # Archive (+ count + empty), broadcast the removal to teammates, and confirm with
  # a self-dismissing flash. A locked message denies → 403 → the client reverts the
  # optimistic hide (the row snaps back) and error_flash surfaces the reason.
  def archive(id:)
    message = InboxMessage.find(id)
    raise Locked, 'This message is locked and cannot be archived.' if message.sender == 'locked'

    message.destroy!
    InboxMessageComponent.broadcast_remove_to(*InboxMessage.stream_key, model: message,
                                                                        exclude: reactive_connection_id)
    reply.remove(:messages, message).flash(:notice, 'Archived', dismiss_after: 2000)
  end

  # Mark read: persist, re-render the row for the actor, and broadcast the updated
  # row to teammates (a replace is id-deduped, so exclude: just spares a redundant echo).
  def mark_read(id:)
    message = InboxMessage.find(id)
    message.update!(read: true)
    InboxMessageComponent.broadcast_replace_to(*InboxMessage.stream_key, model: message,
                                                                         exclude: reactive_connection_id)
    reply.replace
  end

  # Stand in for a teammate/job pushing a new message: create + broadcast_append to
  # every subscribed tab. reply.append updates the actor's own list + count + empty.
  def simulate_incoming
    message = InboxMessage.create!(subject: sample_subject, sender: sample_sender)
    InboxMessageComponent.broadcast_append_to(*InboxMessage.stream_key, target: 'inbox-messages',
                                                                        model: message,
                                                                        exclude: reactive_connection_id)
    reply.append(:messages, message)
  end

  def view_template
    div(**reactive_root(class: 'flex flex-col gap-3')) do
      turbo_stream_from(*InboxMessage.stream_key) # cross-tab sync

      header
      ul(id: 'inbox-messages', class: 'flex flex-col rounded-box border border-base-300') do
        if InboxMessage.exists?
          InboxMessage.active.each { render InboxMessageComponent.new(message: it) }
        else
          render InboxEmptyComponent.new
        end
      end

      div(id: 'flash', class: 'flex flex-col gap-1', data: { testid: 'flash' })
    end
  end

  private

  def header
    div(class: 'flex items-center justify-between') do
      div(class: 'flex items-center gap-2') do
        span(class: 'font-semibold') { 'Team inbox' }
        span(id: 'inbox-count', class: 'badge badge-neutral badge-sm',
             data: { testid: 'inbox-count' }) { InboxMessage.count.to_s }
      end
      button(**mix(on(:simulate_incoming, disable_with: '…'),
                   class: 'btn btn-sm btn-primary', data: { testid: 'simulate-incoming' })) do
        'Simulate incoming'
      end
    end
  end

  # Deterministic-ish rotation off the DB count (no Random needed at render time).
  def sample_subject = SUBJECTS[InboxMessage.count % SUBJECTS.size]
  def sample_sender  = SENDERS[InboxMessage.count % SENDERS.size]
end
```

## Every feature, in one component

| Feature | Where |
| --- | --- |
| **`reactive_collection`** — rows + running count + empty-state declared once | the container |
| **Optimistic archive** — `optimistic: { hide: true }` hides the row in the same gesture | the row's Archive |
| **Revert on failure** — a denied archive snaps the row back + shows an error flash | the "locked" message |
| **`disable_with:`** — the Archive / Simulate buttons dedupe a double-click | container + row |
| **Cross-tab broadcast** — `broadcast_append_to` / `broadcast_remove_to` / `broadcast_replace_to` | every mutating action |
| **`on_client` menu** — the ⋯ kebab toggles with zero round trips | the row |
| **`error_flash` + `dismiss_after:`** — a self-dismissing confirmation / error toast | the reply |

## The container

`TeamInboxComponent` declares the collection once and drives three actions. `archive` removes the row + count + empty-state in one `reply.remove`, broadcasts the removal to teammates, and chains a `.flash(dismiss_after: 2000)`. `mark_read` persists + broadcasts the updated row. `simulate_incoming` creates a message and `broadcast_append_to`s it (excluding the actor, who gets it from their own `reply.append`).

```ruby
# frozen_string_literal: true

# The FLAGSHIP example (issue #149): a believable Team inbox that composes the
# whole reactive toolkit in one UI —
#   * reactive_collection rows (archive/mark-read append/remove + count + empty);
#   * an optimistic: archive that hides the row in the same gesture, reverting if
#     the server denies (a "locked" message);
#   * disable_with: guarding the archive button against a double-click;
#   * cross-tab broadcast_*_to so a teammate's tab stays in sync;
#   * an on_client kebab menu on each row (pure client op, zero round trips);
#   * error_flash + a self-dismissing dismiss_after: toast on the reply.
#
# State-backed container (no single record); the size resolver reads the live DB
# count. Rows are tokenless and dispatch these actions keyed by message id.
class TeamInboxComponent < Phlex::HTML
  include Phlex::Reactive::Streamable
  include Phlex::Reactive::Component
  include Phlex::Rails::Helpers::TurboStreamFrom

  # A locked message refuses to archive — registered in the initializer so the
  # denial is a clean 403 the client reverts the optimistic hide on.
  class Locked < StandardError; end

  SUBJECTS = ['Deploy finished', 'New signup', 'Payment received', 'Support ticket #4821',
              'Weekly digest'].freeze
  SENDERS = %w[ci billing support system].freeze

  reactive_collection :messages,
                      item: InboxMessageComponent,
                      container: 'inbox-messages',
                      count: 'inbox-count',
                      empty: InboxEmptyComponent,
                      size: -> { InboxMessage.count }

  action :archive, params: { id: :integer }
  action :mark_read, params: { id: :integer }
  action :simulate_incoming

  def id = 'team-inbox'

  # Archive (+ count + empty), broadcast the removal to teammates, and confirm with
  # a self-dismissing flash. A locked message denies → 403 → the client reverts the
  # optimistic hide (the row snaps back) and error_flash surfaces the reason.
  def archive(id:)
    message = InboxMessage.find(id)
    raise Locked, 'This message is locked and cannot be archived.' if message.sender == 'locked'

    message.destroy!
    InboxMessageComponent.broadcast_remove_to(*InboxMessage.stream_key, model: message,
                                                                        exclude: reactive_connection_id)
    reply.remove(:messages, message).flash(:notice, 'Archived', dismiss_after: 2000)
  end

  # Mark read: persist, re-render the row for the actor, and broadcast the updated
  # row to teammates (a replace is id-deduped, so exclude: just spares a redundant echo).
  def mark_read(id:)
    message = InboxMessage.find(id)
    message.update!(read: true)
    InboxMessageComponent.broadcast_replace_to(*InboxMessage.stream_key, model: message,
                                                                         exclude: reactive_connection_id)
    reply.replace
  end

  # Stand in for a teammate/job pushing a new message: create + broadcast_append to
  # every subscribed tab. reply.append updates the actor's own list + count + empty.
  def simulate_incoming
    message = InboxMessage.create!(subject: sample_subject, sender: sample_sender)
    InboxMessageComponent.broadcast_append_to(*InboxMessage.stream_key, target: 'inbox-messages',
                                                                        model: message,
                                                                        exclude: reactive_connection_id)
    reply.append(:messages, message)
  end

  def view_template
    div(**reactive_root(class: 'flex flex-col gap-3')) do
      turbo_stream_from(*InboxMessage.stream_key) # cross-tab sync

      header
      ul(id: 'inbox-messages', class: 'flex flex-col rounded-box border border-base-300') do
        if InboxMessage.exists?
          InboxMessage.active.each { render InboxMessageComponent.new(message: it) }
        else
          render InboxEmptyComponent.new
        end
      end

      div(id: 'flash', class: 'flex flex-col gap-1', data: { testid: 'flash' })
    end
  end

  private

  def header
    div(class: 'flex items-center justify-between') do
      div(class: 'flex items-center gap-2') do
        span(class: 'font-semibold') { 'Team inbox' }
        span(id: 'inbox-count', class: 'badge badge-neutral badge-sm',
             data: { testid: 'inbox-count' }) { InboxMessage.count.to_s }
      end
      button(**mix(on(:simulate_incoming, disable_with: '…'),
                   class: 'btn btn-sm btn-primary', data: { testid: 'simulate-incoming' })) do
        'Simulate incoming'
      end
    end
  end

  # Deterministic-ish rotation off the DB count (no Random needed at render time).
  def sample_subject = SUBJECTS[InboxMessage.count % SUBJECTS.size]
  def sample_sender  = SENDERS[InboxMessage.count % SENDERS.size]
end
```

## The row (tokenless — client menu + container dispatch)

`InboxMessageComponent` carries **no token of its own**. Its ⋯ kebab is a pure `on_client(:click, js.toggle(...))` — zero round trips. The Archive / Mark-read buttons inside dispatch the *container's* actions keyed by message id. Archive's `optimistic:` hint targets **this row's** own id (`to: "#" + dom_id`) — not `:root`, because a tokenless row's `:root` would hide the whole inbox — so only the archived row hides.

```ruby
# frozen_string_literal: true

# One Team-inbox row. Keyed off an InboxMessage so its #id is a stable dom_id —
# the append/remove target for the collection helper and the broadcast.
#
# The row carries NO token of its own (no reactive_attrs): its archive/mark-read
# buttons dispatch the CONTAINER's actions (keyed by id) via the generic reactive
# controller it sits inside. The kebab menu is a pure on_client toggle — zero
# round trips. It includes Component only to build those dispatches + the client op.
class InboxMessageComponent < Phlex::HTML
  include Phlex::Reactive::Streamable
  include Phlex::Reactive::Component

  def self.model_param_name = :message

  def initialize(message:)
    @message = message
  end

  def id = dom_id(@message)

  def view_template
    li(id:, class: row_classes, data: { testid: 'inbox-row', read: @message.read?.to_s }) do
      unread_dot
      div(class: 'min-w-0 flex-1') do
        div(class: 'truncate text-sm font-medium', data: { testid: 'subject' }) { @message.subject }
        div(class: 'text-xs opacity-60') { "from #{@message.sender}" }
      end
      kebab_menu
    end
  end

  private

  def row_classes
    ['flex items-center gap-3 border-b border-base-200 px-3 py-2',
     ('opacity-60' if @message.read?)].compact
  end

  def unread_dot
    span(class: ['inline-block size-2 rounded-full',
                 (@message.read? ? 'bg-transparent' : 'bg-primary')].compact,
         data: { testid: 'unread-dot' })
  end

  # A pure on_client kebab: clicking toggles this row's menu; a click outside the
  # menu closes it. No token, no POST. The menu holds the reactive archive/mark-read
  # dispatches (the CONTAINER's actions, keyed by this row's id).
  def kebab_menu
    menu_id = "#{id}-menu"
    div(class: 'relative') do
      button(**mix(on_client(:click, js.toggle("##{menu_id}")),
                   class: 'btn btn-ghost btn-xs', data: { testid: 'kebab' })) { '⋯' }
      div(id: menu_id, hidden: true,
          class: 'absolute right-0 z-10 mt-1 flex w-40 flex-col rounded-box border ' \
                 'border-base-300 bg-base-100 p-1 shadow',
          data: { testid: 'kebab-menu' }) do
        mark_read_button unless @message.read?
        archive_button
      end
    end
  end

  def mark_read_button
    button(**mix(on(:mark_read, id: @message.id),
                 class: 'btn btn-ghost btn-xs justify-start', data: { testid: 'mark-read' })) { 'Mark read' }
  end

  # optimistic: { hide: true, to: "##{id}" } hides THIS row the instant you click
  # (the row is tokenless, so to: must target the row's own id, not :root — that
  # would hide the whole inbox). disable_with guards a double-click. reply.remove +
  # broadcast_remove drop it everywhere; a DENIED archive reverts the hide.
  def archive_button
    button(**mix(on(:archive, id: @message.id, disable_with: '…', optimistic: { hide: true, to: "##{id}" }),
                 class: 'btn btn-ghost btn-xs justify-start text-error', data: { testid: 'archive' })) { 'Archive' }
  end
end
```

## Optimistic revert on failure

The optimistic hint is **cosmetic and always reversible**. When the archive of the locked message denies (a registered authorization error → 403), the client replays the inverse: the row that was optimistically hidden **snaps back**, and `error_flash` surfaces the reason. Nothing was persisted, and the inbox is exactly as it was — the whole point of an optimistic hint that can't drift from server truth.

> **Tip:** This is the discipline that makes optimistic UI safe here: the hint is a *guess* the server confirms or reverts. You never ship state to the client and trust it back — the signed identity re-finds the record, the action authorizes, and the reply is the source of truth.

## Notes

Open this page in **two tabs** and archive or mark-read in one — the other updates live over the shared inbox stream (`exclude: reactive_connection_id` keeps the actor from doubling). On the deployed docs site this rides pgbus SSE; in dev/CI it's Action Cable. The component is identical either way — the transport is a config detail, not a code path.