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: truereplaces the totals with the component'sdeferred_placeholdershell 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.
# 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
endThe 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.
# 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
endWhat each reply emits#
log_set returns:
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 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:
/* 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#
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.
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.