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.

Team inbox1
  • Deploy finished
    from ci
app/components/team_inbox_component.rb
# 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#

FeatureWhere
reactive_collection — rows + running count + empty-state declared oncethe container
Optimistic archiveoptimistic: { hide: true } hides the row in the same gesturethe row's Archive
Revert on failure — a denied archive snaps the row back + shows an error flashthe "locked" message
disable_with: — the Archive / Simulate buttons dedupe a double-clickcontainer + row
Cross-tab broadcastbroadcast_append_to / broadcast_remove_to / broadcast_replace_toevery mutating action
on_client menu — the ⋯ kebab toggles with zero round tripsthe row
error_flash + dismiss_after: — a self-dismissing confirmation / error toastthe 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_tos it (excluding the actor, who gets it from their own reply.append).

app/components/team_inbox_component.rb
# 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.

app/components/inbox_message_component.rb
# 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.

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.