Example: failure surface
Reactive actions don’t only succeed. This shows what an adopter gets for free when one fails: a server-rendered flash (`error_flash`), a `data-reactive-error` hook you style with CSS, and self-dismissing toasts (`dismiss_after:`).
Try it#
Click Trigger a failure — the action denies server-side (a declared authorization error → 403). The endpoint renders a flash the browser shows, and the root gets data-reactive-error="http", which reveals the error banner via pure CSS. Succeed clears it. Self-dismissing flash shows a toast that removes itself after dismiss_after:.
# frozen_string_literal: true
# The user-visible failure surface (issue #100). Reactive actions don't only
# succeed — this shows what an adopter gets for free when one fails:
# * `boom` denies inside a DECLARED action (a registered authorization error) →
# the endpoint returns 403. With Phlex::Reactive.error_flash configured, the
# rescue renders a turbo-stream flash the browser SHOWS, and the client sets
# data-reactive-error="http" on this root (style it with CSS).
# * `succeed` is a normal action whose re-render CLEARS data-reactive-error.
# * `flash_now` emits a self-dismissing flash (dismiss_after:) the document-level
# handler removes after the timeout.
class FailureSurfaceComponent < Phlex::HTML
include Phlex::Reactive::Streamable
include Phlex::Reactive::Component
# Registered in config/initializers/phlex_reactive.rb so `boom`'s denial maps to
# a clean 403 (client kind=http) instead of a 500.
class Denied < StandardError; end
reactive_state :count
action :succeed
action :flash_now
action :boom
def initialize(count: 0)
@count = count
end
def id = 'failure-surface'
def succeed = @count += 1
# Denies inside the action → 403 (client kind=http), the failure the demo shows.
# Declared so the page renders under the render-time undeclared-action guard.
def boom = raise(Denied, 'boom')
# A short-lived flash so the demo can watch it appear then disappear.
def flash_now
reply.replace.flash(:notice, 'Saved — this toast self-dismisses', dismiss_after: 2500)
end
def view_template
# Reveal the error banner ONLY while the root carries data-reactive-error —
# pure CSS, no Ruby toggle.
style do
css = '[data-testid="error-banner"]{display:none} ' \
'[data-reactive-error] [data-testid="error-banner"]{display:block}'
raw(safe(css)) # rubocop:disable Rails/OutputSafety
end
div(**reactive_root(class: 'flex flex-col gap-3')) do
div(class: 'alert alert-error', role: 'alert', data: { testid: 'error-banner' }) do
'The last action failed — data-reactive-error is set on the root.'
end
div(class: 'flex items-center gap-3') do
span(class: 'text-sm opacity-70') do
plain 'Succeeded '
span(data: { testid: 'count' }) { @count.to_s }
plain ' times'
end
end
div(class: 'flex gap-2') do
button(**mix(on(:succeed), class: 'btn btn-sm btn-success', data: { testid: 'succeed' })) { 'Succeed' }
button(**mix(on(:boom), class: 'btn btn-sm btn-error', data: { testid: 'boom' })) { 'Trigger a failure' }
button(**mix(on(:flash_now), class: 'btn btn-sm', data: { testid: 'flash-now' })) { 'Self-dismissing flash' }
end
# The flash target for the error_flash + dismiss_after: toasts.
div(id: 'flash', class: 'flex flex-col gap-1', data: { testid: 'flash' })
end
end
endServer-rendered failure flashes (error_flash)#
Set Phlex::Reactive.error_flash = ->(kind) { "…" } and the action endpoint renders a turbo-stream flash into your flash_target whenever an action fails — a denied action (403, kind: "http"), a timeout (kind: "timeout"), or a network error (kind: "offline"). You write the copy; the framework surfaces it. It's opt-in: with error_flash unset, failures are silent (the client still sets data-reactive-error).
The data-reactive-error hook#
On any failed action the client sets data-reactive-error="<kind>" on the component root; a successful re-render clears it. Style the wait/ failure state with a plain attribute selector — here the error banner is display:none until [data-reactive-error] is present. No Ruby toggles it, and it survives a morph because it's driven off the attribute, not the HTML.
Timeouts and offline#
The client aborts a request that exceeds phlex-reactive-timeout (default 30 s; set the meta lower to demo it) with kind: "timeout", and a fetch that fails offline with kind: "offline" — both flow through the same error_flash + data-reactive-error surface as a 403. Crucially, the per-component request queue does not wedge: after a timeout, the next action still round-trips. Flip the latency simulator on to make the in-flight window observable.
A live timeout needs a page-level phlex-reactive-timeout meta set below the simulated delay, which would affect every demo on the page. The timeout/offline behavior is covered end-to-end in the gem's own browser suite; here we demo the 403 → error_flash path, which shares the exact same client surface.