Notifications / live badges (pure broadcast, no client action)
Not every reactive component needs a client action — sometimes the server just pushes a re-render, using only Streamable broadcasts with no Component mixin.
Try it#
A real live bell. Click "Simulate a background event" — it stands in for a job finishing. The count bumps and the bell re-renders via a broadcast_replace_to, so every tab you have open on this page updates (open a second tab to see it). It also fires a broadcast_js_to pulse — a whitelisted DOM op, not HTML — so the bell on the other tabs bounces to grab attention, with no re-render at all.
# frozen_string_literal: true
# A live notification bell (issue #35 broadcasts). It subscribes to a shared
# stream and shows an unread count. "Simulate a background event" stands in for a
# job finishing: it bumps the count and BROADCASTS the re-rendered bell to every
# subscribed tab (so a second window updates with no action of its own), then
# fires a `broadcast_js_to` nudge — a pure client-side pulse animation applied on
# the OTHER tabs with no re-render at all.
#
# This is the "the server just pushes" shape: the count update rides a normal
# broadcast_replace, and the attention-grab (the pulse) rides broadcast_js_to,
# which ships a whitelisted DOM op — not HTML — to every subscriber.
class NotificationBellComponent < Phlex::HTML
include Phlex::Reactive::Streamable
include Phlex::Reactive::Component
include Phlex::Rails::Helpers::TurboStreamFrom
STREAM = %w[notification-bell all].freeze
reactive_state :unread
action :simulate_event
def initialize(unread: 0)
@unread = unread.to_i
end
def id = 'notification-bell'
# Bump the count, then push the update to EVERY subscribed tab (the actor
# included — a replace is id-deduped, so the actor's own HTTP reply and the
# broadcast reconcile to the same DOM). broadcast_replace_to rebuilds the bell
# from the given state (unread:). The broadcast_js_to nudge pulses the bell on
# the OTHER tabs only (exclude: the actor, who already saw the bump).
def simulate_event
@unread += 1
NotificationBellComponent.broadcast_replace_to(*STREAM, unread: @unread)
NotificationBellComponent.broadcast_js_to(
*STREAM,
js.add_class('#bell-icon', 'animate-bounce'),
exclude: reactive_connection_id
)
end
def view_template
div(**reactive_root(class: 'flex items-center gap-3')) do
turbo_stream_from(*STREAM) # subscribe to the pushed updates
div(id: 'bell-icon', class: 'relative inline-flex') do
span(class: 'text-2xl') { '🔔' }
if @unread.positive?
span(class: 'badge badge-error badge-sm absolute -right-2 -top-2',
data: { testid: 'bell-count' }) { @unread.to_s }
end
end
button(**mix(on(:simulate_event),
class: 'btn btn-sm btn-primary', data: { testid: 'simulate' })) do
'Simulate a background event'
end
end
end
endWhen the server just pushes#
Not every reactive component needs a client action. Sometimes the server just needs to push a re-render — a notification badge that updates when a job finishes, a live metric, a “new items available” pill. This uses only Phlex::Reactive::Streamable (broadcasts), no Component mixin.
A notification badge#
class NotificationsBadge < ApplicationComponent
include Phlex::Reactive::Streamable
def initialize(user:) = @user = user
def id = dom_id(@user, :notifications_badge)
def self.model_param_name = :user
def view_template
span(id:, class: "badge") do
count = @user.notifications.unread.count
plain(count.zero? ? "" : count.to_s)
end
end
endSubscribe on the page:
# in a layout or header component
turbo_stream_from current_user, :notificationsPush an update from anywhere — a model callback, a job, a service:
class Notification < ApplicationRecord
belongs_to :user
after_create_commit do
NotificationsBadge.broadcast_replace_to(user, :notifications, model: user)
end
endNow creating a notification re-renders the badge in every tab the user has open. No client action, no Stimulus, no Action Cable.
Who receives the broadcast (visible_to:)#
A notifications stream is per-user by construction — you broadcast current_user's unread badge to that user. But when several connections share one stream (a shared team channel, a per-account stream with multiple members), you need to say which subscribers a given broadcast may reach. Pass visible_to: — the transport-level authorization forwarded to pgbus — so a badge meant for one member never lights up in another's tab:
# Only connections whose id is in this set receive the broadcast.
NotificationsBadge.broadcast_replace_to(
account, :notifications, model: user,
visible_to: [user.reactive_connection_id] # per-subscriber authorization
)visible_to: is the security-relevant knob for a shared notifications stream — it decides who receives the push. Broadcasting a user's unread count to the wrong subscribers leaks one person's activity into another's UI. Scope every shared-stream broadcast; a per-user stream (broadcast_*_to(user, …)) is already scoped by its key. visible_to: is honored on pgbus; on Action Cable it is inert (every subscriber of the stream receives it), so on Action Cable rely on a per-user stream key instead.
Suppress the actor's own echo (exclude:)#
When the person who triggered the change is also subscribed to the stream (they marked a notification read, and their own badge is live), the broadcast would echo back to the tab that already updated. Pass exclude: reactive_connection_id so the actor is skipped — they got their update from the action's own HTTP reply, not the broadcast:
# Inside a reactive action on the badge (mark-all-read), or any
# component method with access to reactive_connection_id:
def mark_all_read
@user.notifications.unread.update_all(read_at: Time.current)
# Live-update every OTHER tab; this tab already re-rendered via the reply.
NotificationsBadge.broadcast_replace_to(
@user, :notifications, model: @user,
exclude: reactive_connection_id
)
reply.replace
endreactive_connection_id is the current action's connection id, from the Phlex::Reactive::Component mixin. exclude: and visible_to: are honored on pgbus; on the async Action Cable adapter this docs site runs, they are inert (the actor sees a harmless double-render).
Light up the bell without a re-render (broadcast_js_to)#
The classic notification nudge doesn't need a re-render at all — you just want a has-unread class on the bell in every viewer's tab. broadcast_js_to pushes declared client DOM ops (the same js builder as on_client / reply.js) over the stream, so the server lights the bell with no HTML swap:
# In a model callback or job — light the bell in every viewer's tab,
# minus the actor's own (who already knows).
Notifications::Badge.broadcast_js_to(
user, :alerts,
js.add_class("#bell", "has-unread"),
exclude: reactive_connection_id
)broadcast_js_to refuses focus ops (focus / focus_first raise ArgumentError) — broadcasting focus would steal it in every subscriber's tab, so focus stays an actor-reply concern (reply.js). Class and attribute toggles and dispatch broadcast fine. Like all client ops, these are ephemeral: the next full re-render of the component resets whatever they toggled, so use a signed action for state that must survive a re-render.
A “new items” pill driven by a background job#
class ImportJob < ApplicationJob
def perform(import)
import.run!
# Re-render a status component for everyone watching this import.
Imports::Status.broadcast_replace_to(import, model: import)
end
endclass Imports::Status < ApplicationComponent
include Phlex::Reactive::Streamable
def initialize(import:) = @import = import
def id = dom_id(@import, :status)
def view_template
div(id:, class: "import-status #{@import.status}") do
plain @import.status.titleize
plain " — #{@import.processed_count}/#{@import.total_count}" if @import.running?
end
end
endSurfacing a failed push to the user#
A notification the user should see is exactly where a silent failure hurts most. phlex-reactive has two built-in ways to surface one, and a way to make a flash clean itself up.
Self-dismissing flashes (dismiss_after:). A notification flash that never clears piles up. Pass dismiss_after: (ms) and it removes itself after the timeout — driven by a document-level handler, so it self-cleans both reply-delivered and broadcast-delivered flashes:
# In a reactive action — a flash the user sees for 4s, then it's gone.
reply.replace.flash(:notice, "Marked all read", dismiss_after: 4000)
# A broadcast flash self-cleans too (the container is a plain host div).
NotificationsFlash.broadcast_append_to(
user, :notifications, target: "flash",
model: notification, exclude: reactive_connection_id
)Phlex::Reactive.error_flash — server-rendered flashes on endpoint failures. For the failures the endpoint catches (bad token, default-deny, authorization, missing record — the 400/403/404 rescue paths behind a reactive action), set a lambda once and every one renders a turbo-stream flash the user sees, at the same status it already returns:
Phlex::Reactive.error_flash = ->(kind) { "Something went wrong (#{kind})." }The failing component's root also gets data-reactive-error="<kind>", so you can outline it in pure CSS with zero JS — and the next successful action clears it:
[data-reactive-error] { outline: 2px solid var(--danger); }For a failure your action knows about (a validation error, a business rule), return reply.replace.flash(:error, "…") directly — it renders at 200, a normal reply, not an error. error_flash is for the failures the endpoint catches for you.
When to use this vs a full reactive component#
- Server pushes a re-render (job, callback, another user) →
Streamable+broadcast_*_to. - User clicks/types and the same component updates → add
Component+action. - Both → add
Component; broadcast from inside the action.
Because both paths target the component by its id, you can start with a pure-broadcast badge and later add a dismiss action without changing the target or the subscription.
A notifications list — rows plus a count badge and an empty-state that toggle as it grows and shrinks — is the canonical reactive_collection case. Declare that whole contract once on the container and each action becomes a single reply.append / reply.remove (row + count + empty-state in one reply, the count re-counted server-side so it can't drift):
reactive_collection :notifications,
item: NotificationRow, # the per-row Streamable component
container: "notifications", # the DOM id rows live in
count: "notifications-count", # optional companion id (the badge)
empty: NotificationsEmpty, # optional empty-state component
size: -> { current_user.notifications.count }
def dismiss(id:)
current_user.notifications.find(id).destroy!
reply.remove(:notifications, id) # row + count + empty-state at 0
endSee Reactive collections for the full add/remove-row contract.
Transactional safety#
If you broadcast from an after_create_commit / after_update_commit callback (or from inside a transaction with pgbus), the broadcast only fires once the change commits — a rolled-back notification never flashes a phantom badge.