Examples

Example: reactive collections

An add/remove-row list — line items, attachments, tags, a notifications feed — declares its append/remove + running count + empty-state contract ONCE on the container, so each action is a single `reply.append` / `reply.remove`. Plus optimistic dismiss and a self-dismissing flash.

Try it#

Add a notification and dismiss it. Each add appends the row, bumps the count badge, and clears the empty-state in ONE reply; each dismiss removes the row (optimistically — it hides the instant you click), restores the empty-state when the last one goes, and shows a self-dismissing toast. Turn on the latency toggle to watch the optimistic hide before the round trip lands.

Notifications0
  • All caught up — no notifications.
app/components/notifications_list_component.rb
# frozen_string_literal: true

# The reactive_collection demo container (issue #35). Declares the list contract
# ONCE — the row component, the container id, a companion count badge, an
# empty-state, and a size resolver — so add/dismiss append/remove a row + bump the
# count + toggle the empty-state with ONE reply call each, no per-action
# bookkeeping.
#
# State-backed (no single record), so it rebuilds from a signed token on each
# action. The size resolver reads the live DB count — always correct, never an
# off-by-one client increment. Dismiss also emits a self-dismissing flash
# (dismiss_after:) so the "Dismissed" toast clears itself after a beat.
class NotificationsListComponent < Phlex::HTML
  include Phlex::Reactive::Streamable
  include Phlex::Reactive::Component
  include Phlex::Rails::Helpers::TurboStreamFrom

  reactive_collection :notifications,
                      item: NotificationRowComponent,
                      container: 'notifications',
                      count: 'notifications-count',
                      empty: NotificationsEmptyComponent,
                      size: -> { Notification.count }

  action :add, params: { title: :string }
  action :dismiss, params: { id: :integer }

  def id = 'notifications-list'

  # Append the new row + bump the count + clear the empty-state in ONE reply, and
  # broadcast the row to the OTHER tabs (append inserts a child, so exclude: the
  # actor or their own tab doubles it).
  def add(title:)
    title = title.to_s.strip
    return reply.replace if title.blank?

    notification = Notification.create!(title:)
    NotificationRowComponent.broadcast_append_to(*Notification.stream_key, target: 'notifications',
                                                                           model: notification,
                                                                           exclude: reactive_connection_id)
    reply.append(:notifications, notification)
  end

  # Remove the row + bump the count + restore the empty-state in ONE reply, plus a
  # self-dismissing flash. The broadcast_remove keeps other tabs in sync.
  def dismiss(id:)
    notification = Notification.find(id)
    notification.destroy!
    NotificationRowComponent.broadcast_remove_to(*Notification.stream_key, model: notification,
                                                                           exclude: reactive_connection_id)
    reply.remove(:notifications, notification).flash(:notice, 'Notification dismissed', dismiss_after: 3000)
  end

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

      div(class: 'flex items-center gap-2') do
        span(class: 'font-medium') { 'Notifications' }
        span(id: 'notifications-count', class: 'badge badge-neutral badge-sm',
             data: { testid: 'count' }) { Notification.count.to_s }
      end

      ul(id: 'notifications', class: 'flex flex-col') do
        if Notification.exists?
          Notification.order(:created_at, :id).each { render NotificationRowComponent.new(notification: it) }
        else
          render NotificationsEmptyComponent.new
        end
      end

      div(class: 'flex gap-2') do
        input(name: 'title', placeholder: 'New notification…', autocomplete: 'off',
              class: 'input input-bordered input-sm flex-1', data: { testid: 'new-notification' })
        button(**mix(on(:add), class: 'btn btn-sm btn-primary', data: { testid: 'add' })) { 'Add' }
      end

      # The flash target the dismiss reply appends its self-dismissing toast into.
      div(id: 'flash', class: 'flex flex-col gap-1', data: { testid: 'flash' })
    end
  end
end

Declare the list contract once#

reactive_collection names the row component, the container DOM id, an optional count-badge id, an optional empty-state component, and a size resolver (a proc that reads the live count). After that, an action governs the reply with a single call — no hand-deriving the container id, the count, and the empty-state in every action.

  • reply.append(:notifications, record) → row stream + count + clears the empty-state.
  • reply.remove(:notifications, record) → row remove + count + restores the empty-state.

The size resolver reads Notification.count server-side, so the badge is always correct — never an off-by-one client increment.

The row (tokenless — it dispatches the container)#

NotificationRowComponent carries no token of its own: its dismiss button dispatches the container's dismiss action (keyed by the record id) via the generic controller it sits inside. That's why a streamed-in row (via reply.append) is fully interactive without any re-wiring. optimistic: { hide: true } hides the row the instant you click; reply.remove then drops it, so it never flashes back.

app/components/notification_row_component.rb
# frozen_string_literal: true

# A single notification row in the reactive_collection demo (issue #35). Keyed
# off a Notification record so its #id is a stable dom_id — the append/remove
# target for the collection helper.
#
# The row carries NO token of its own (no reactive_attrs): its dismiss button
# dispatches the CONTAINER's `dismiss` action via the generic reactive controller
# it sits inside, so the client sends the container's token. It includes Component
# only to build that dispatch — the same attrs whether the row is rendered on
# first paint or streamed in by reply.append.
class NotificationRowComponent < Phlex::HTML
  include Phlex::Reactive::Streamable
  include Phlex::Reactive::Component

  def self.model_param_name = :notification

  def initialize(notification:)
    @notification = notification
  end

  def id = dom_id(@notification)

  def view_template
    li(id:, class: 'flex items-center justify-between gap-3 border-b border-base-200 py-2',
       data: { testid: 'notification' }) do
      span(class: 'text-sm') { @notification.title }
      # optimistic: { hide: true } hides the row the instant you click, before the
      # round trip; reply.remove then drops it so it never flashes back. dismiss is
      # the CONTAINER's action (the row has no token of its own) — so `to:` targets
      # THIS row by its own id, NOT :root (which would be the whole list). Keyed by id.
      button(**mix(on(:dismiss, id: @notification.id, optimistic: { hide: true, to: "##{id}" }),
                   class: 'btn btn-xs btn-ghost', data: { testid: 'dismiss' })) { '×' }
    end
  end
end

The empty-state#

A static Streamable component with a stable #id. The collection helper removes it by that id when the first row is added and appends it back when the last row is dismissed — you never toggle it by hand.

app/components/notifications_empty_component.rb
# frozen_string_literal: true

# The empty-state shown when the notifications list is empty (issue #35). The
# reactive_collection helper removes it by its stable #id when the first row is
# added, and appends it back into the container when the last row is dismissed.
# A static view (Streamable only) — built argument-free.
class NotificationsEmptyComponent < Phlex::HTML
  include Phlex::Reactive::Streamable

  def id = 'notifications-empty'

  def view_template
    li(id:, class: 'py-3 text-sm opacity-60', data: { testid: 'empty-state' }) do
      'All caught up — no notifications.'
    end
  end
end

What each reply emits#

One reply.append / reply.remove expands into a multi-stream turbo reply: the row (append/remove), the count companion (replace), and the empty-state (remove/append). dismiss chains a .flash(:notice, "…", dismiss_after: 3000) so a toast appears in the #flash region and clears itself after 3 s — no timer in your Ruby. Every mutation also broadcasts to the other tabs with exclude: reactive_connection_id, so a second window stays in sync.

Notes#

count: and empty: are optional — a list with just rows omits them and those streams simply aren't emitted. The row component owns its own actions; the collection is only about the container-level add/remove reply. See Broadcasting & live updates.