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.
- All caught up — no notifications.
# 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
endDeclare 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.
# 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
endThe 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.
# 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
endWhat 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.