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.

🔔

Simulate a background event

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

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

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

Push an update from anywhere — a model callback, a job, a service:

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

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

> **Warning:** `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:

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

> **Note:** `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**:

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

> **Note:** `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

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

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

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

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

```css
[data-reactive-error] { outline: 2px solid var(--danger); }
```

> **Note:** 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
> endSee [Reactive collections](https://phlex-reactive.zoolutions.llc/docs/example-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.