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.
- Deploy finishedfrom ci
# 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]
endEvery 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_tos it (excluding the actor, who gets it from their own reply.append).
# 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]
endOptimistic 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.