Examples

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.

🔔
app/components/notification_bell_component.rb
# 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
end

When 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#

app/components/notifications_badge.rb
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
end

Subscribe on the page:

# in a layout or header component
turbo_stream_from current_user, :notifications

Push 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
end

Now 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
end

reactive_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#

app/jobs/import_job.rb
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
end
app/components/imports/status.rb
class 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
end

Surfacing 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:

config/initializers/phlex_reactive.rb
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:

app/assets/stylesheets/reactive.css
[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.

For a notifications LIST, not just a badge

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
end

See 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.