Examples

# Example: deferred totals (reply.defer)

A reps logger whose set count updates instantly while a deliberately slow session-totals rollup (~400 ms) streams in when ready — keep-content shimmer, skeleton, and the synchronous baseline to feel the difference. The totals also lazy-mount on page load.

## Try it

The totals below sleep **400 ms** in their template — a stand-in for a genuinely expensive cross-aggregate rollup. Click and compare:

- **Log set** — the set count bumps *instantly*; the stale totals stay visible, dimmed (`data-reactive-defer-pending`), until the real render streams in.
- **Log set (skeleton)** — `placeholder: true` replaces the totals with the component's `deferred_placeholder` shell immediately.
- **Log set (sync)** — the deliberate anti-example: the same rollup rendered synchronously (`also_replace`), so the **whole** reply — set count included — freezes for the 400 ms the deferred variants take off the critical path.

Click **Log set** rapidly: each new action *supersedes* the in-flight deferred render, so the totals paint once, with the final value — never a stale intermediate. And reload the page to watch the **lazy mount**: the totals are `reactive_lazy`, so the page ships a placeholder shell and the first render streams in on connect.

Log set

Log set (skeleton)

Log set (sync)

Sets: 

0

Crunching totals…

```ruby
# frozen_string_literal: true

# The reply.defer driver (issue #165) — a reps logger whose "Log set" action
# updates the cheap set count instantly and takes the expensive
# SessionTotalsComponent rollup off the actor's critical path with reply.defer.
#
#   * Log set             — keep-content default: the stale totals stay visible,
#     dimmed by the [data-reactive-defer-pending] CSS below, until the real
#     render streams in (~400 ms later).
#   * Log set (skeleton)  — placeholder: true replaces the totals with the
#     component's deferred_placeholder shell immediately.
#   * Log set (sync)      — the deliberate ANTI-example: the SAME rollup
#     rendered synchronously via also_replace, so the whole reply (set count
#     included) freezes for the 400 ms defer takes off the critical path.
class DeferDemoComponent < Phlex::HTML
  include Phlex::Reactive::Streamable
  include Phlex::Reactive::Component

  reactive_state :sets

  action :log_set
  action :log_set_skeleton
  action :log_set_sync

  def initialize(sets: 0)
    @sets = sets
  end

  def id = 'defer-demo'

  # Keep-content default: stale totals stay visible, marked pending.
  def log_set
    @sets += 1
    reply.streams(sets_stream).defer(SessionTotalsComponent.new(sets: @sets))
  end

  # Skeleton variant: the pending shell replaces the totals immediately.
  def log_set_skeleton
    @sets += 1
    reply.streams(sets_stream).defer(SessionTotalsComponent.new(sets: @sets), placeholder: true)
  end

  # The anti-example: the same rollup ON the critical path.
  def log_set_sync
    @sets += 1
    reply.streams(sets_stream).also_replace(SessionTotalsComponent.new(sets: @sets))
  end

  def view_template
    pending_css
    div(**reactive_root(class: 'flex flex-col gap-4')) do
      div(class: 'flex flex-wrap items-center gap-3') do
        button(**mix(on(:log_set, disable_with: 'Logging…'),
                     class: 'btn btn-sm btn-primary', data: { testid: 'log-set' })) { 'Log set' }
        button(**mix(on(:log_set_skeleton, disable_with: 'Logging…'),
                     class: 'btn btn-sm', data: { testid: 'log-set-skeleton' })) { 'Log set (skeleton)' }
        button(**mix(on(:log_set_sync, disable_with: 'Logging…'),
                     class: 'btn btn-sm btn-ghost', data: { testid: 'log-set-sync' })) { 'Log set (sync)' }
        span(class: 'text-sm opacity-70') do
          plain 'Sets: '
          span(id: 'defer-demo-sets', class: 'font-semibold tabular-nums',
               data: { testid: 'sets-count' }) { @sets.to_s }
        end
      end
      render SessionTotalsComponent.new(sets: @sets)
    end
  end

  private

  # The cheap companion stream — updates the set-count mirror instantly. The
  # reply.streams token refresh keeps this root's signed token rolling.
  def sets_stream
    %(<turbo-stream action="update" target="defer-demo-sets"><template>#{@sets}</template></turbo-stream>)
  end

  # Pure-CSS styling for the built-in pending hooks: dim stale content while a
  # deferred render is in flight; give the placeholder shell a pulsing
  # skeleton look. No Ruby toggles any of this — the markers do.
  def pending_css
    style do
      # Static CSS, no user input — Phlex safe(), not Rails html_safe.
      css = '[data-reactive-defer-pending]{opacity:.5;transition:opacity .15s ease}' \
            '.reactive-defer-placeholder{display:flex;align-items:center;min-height:3.5rem;' \
            'border-radius:var(--radius-box,.5rem);background:var(--color-base-200);' \
            'padding:.75rem 1rem;font-size:.875rem;' \
            'animation:defer-pulse 1.2s ease-in-out infinite}' \
            '@keyframes defer-pulse{50%{opacity:.35}}'
      raw(safe(css)) # rubocop:disable Rails/OutputSafety
    end
  end
end
```

## The slow rollup

The deferred component is an ordinary reactive component. Two things make it defer-friendly: `deferred_placeholder` (the skeleton content `placeholder: true` and the lazy shell pick up) and `reactive_lazy` (the first page-embedded render ships the shell instead of paying the 400 ms on page render — the client fetches the real totals on connect). Note the timestamp in its template: every arrival is a fresh server render, rebuilt from the **signed identity** in the defer token.

```ruby
# frozen_string_literal: true

# The deliberately slow rollup behind the defer example (issue #165) — the
# "session totals" from the reps-logger story that motivated reply.defer. The
# template sleeps ~400 ms to simulate a genuinely expensive cross-aggregate
# rollup, so the pending window is visible even on a localhost round trip.
#
# reactive_lazy: the FIRST (page-embedded) render ships a placeholder shell
# instead of paying the 400 ms on the docs page render; the client fetches the
# real totals on connect through the same defer machinery reply.defer uses.
# Every reactive-machinery render (a reply, the defer endpoint) is REAL, so an
# action on this component never costs a double fetch.
class SessionTotalsComponent < Phlex::HTML
  include Phlex::Reactive::Streamable
  include Phlex::Reactive::Component

  reactive_state :sets

  reactive_lazy

  def initialize(sets: 0)
    @sets = sets
  end

  def id = 'session-totals'

  # Rendered inside the pending shell by `placeholder: true` AND by the lazy
  # mount's shell. A plain String is escaped as text (data, not markup);
  # return a Phlex component instance or an html_safe String for real
  # skeleton markup.
  def deferred_placeholder = 'Crunching totals…'

  def view_template
    sleep(0.4) # the deliberately expensive rollup — the reason to defer it
    div(**reactive_root(class: 'flex items-center gap-4 rounded-box bg-base-200 px-4 py-3')) do
      stat('Sets', @sets.to_s, testid: 'totals-sets')
      stat('Volume', "#{@sets * 240} kg", testid: 'totals-volume')
      span(class: 'text-xs opacity-60', data: { testid: 'totals-computed' }) do
        plain "computed at #{Time.current.strftime('%H:%M:%S.%L')}"
      end
    end
  end

  private

  def stat(label, value, testid:)
    span(class: 'flex flex-col') do
      span(class: 'text-xs uppercase opacity-60') { label }
      span(class: 'text-lg font-semibold tabular-nums', data: { testid: }) { value }
    end
  end
end
```

## What each reply emits

`log_set` returns:

```ruby
reply.streams(sets_stream).defer(SessionTotalsComponent.new(sets: @sets))
```

The actor's HTTP reply carries the cheap count update, the driver's token refresh, and a tiny **defer directive** — rendered only after the action's transaction committed, so a rollback leaks nothing. The directive holds a purpose-scoped, short-TTL signed token; the client POSTs it to `/reactive/defer` **in parallel** (off the action queue), and the endpoint rebuilds the component from its verified identity, renders it (the 400 ms happens *here*, off the critical path), and returns the replace stream. The arrival carries a fresh action token, so the totals land interactive.

`placeholder: true` additionally emits a replace of the target with the pending shell *before* the directive, so the skeleton paints first. On a pgbus + ActiveJob stack the same directive can ride the push lane instead (a durable one-shot stream and a render job) — the Ruby above is identical either way. See the [Deferred rendering](https://phlex-reactive.zoolutions.llc/docs/deferred-rendering) guide for the lanes, failure handling, and the security model.

## Styling the pending window — pure CSS

The client marks the wait; CSS does the rest. No Stimulus, no bespoke JavaScript — the demo's entire shimmer is:

```css
/* keep-content default: dim the stale totals while pending */
[data-reactive-defer-pending] { opacity: .5; transition: opacity .15s ease; }

/* the placeholder shell (placeholder: true and the lazy mount) */
.reactive-defer-placeholder { animation: defer-pulse 1.2s ease-in-out infinite; }
@keyframes defer-pulse { 50% { opacity: .35; } }
```

Every pending target also carries `aria-busy="true"`, so assistive tech knows the region is loading. A failed fetch clears the pending markers and sets `data-reactive-error="defer"` — style that hook too if you defer anything critical.

## Notes

> **Warning:** The 400 ms sleep is the demo's stand-in for a rollup that is *genuinely* expensive. If your slow segment is slow because of an N+1 or a missing eager-load, **fix the query** — don't defer it. [Profile first](https://phlex-reactive.zoolutions.llc/docs/deferred-rendering).

> **Tip:** This docs site runs the pull (`fetch`) lane — the universal one that needs nothing but the gem's own endpoint. The demo behaves identically on the pgbus push lane; `defer_transport :auto` picks per capability at runtime.