Examples

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

Succeeded 0 times
app/components/failure_surface_component.rb
# 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
end

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