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.

Simulate network latency

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

Notifications

0

- All caught up — no notifications.

Add

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

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

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

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