Examples

# Example: loading states

Declarative pending affordances — `disable_with:` swaps a button label and disables it in flight, `busy_on` reveals a scoped spinner, and the always-on `aria-busy` / `data-reactive-busy` hooks let you style the wait with pure CSS. No Stimulus, no bespoke JavaScript.

## Try it

A localhost round trip is ~5 ms — the pending window flashes by before you can see it. **Flip the toggle on** and every reactive action is delayed client-side, so the affordances below become observable. It's a pure client concern: no token, no POST.

Simulate network latency

Delay every action by 500 ms so the loading states are visible.

Save

Saved 

0

 times

```ruby
# frozen_string_literal: true

# Declarative loading states (issue #99) — no Stimulus, no bespoke JS.
#
#   * on(:save, disable_with: "Saving…") — the button DISABLES and swaps its text
#     to "Saving…" the instant the request is enqueued, reverting when the round
#     trip settles. Because a disabled button fires no further clicks, a rapid
#     double-click enqueues exactly ONE POST — so @count bumps by one, not two.
#     The disable IS the dedup (the client queue serializes, it doesn't dedupe).
#   * busy_on(:save) — a spinner element that carries data-reactive-busy only
#     while `save` is in flight, styled with pure CSS (daisyUI loading-spinner).
#
# The action is intentionally FAST server-side — flip the latency toggle above it
# to actually SEE the disabled + "Saving…" + spinner window on localhost.
class LoadingButtonComponent < Phlex::HTML
  include Phlex::Reactive::Streamable
  include Phlex::Reactive::Component

  reactive_state :count
  action :save

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

  def id = 'loading-button'

  def save = @count += 1

  def view_template
    # The spinner is present in the DOM always; this scoped rule reveals it ONLY
    # while busy_on marks it with data-reactive-busy — pure CSS, no Ruby toggle.
    style do
      # Static CSS, no user input — Phlex safe(), not Rails html_safe.
      css = '[data-testid="spinner"]{display:none} ' \
            '[data-testid="spinner"][data-reactive-busy]{display:inline-block}'
      raw(safe(css)) # rubocop:disable Rails/OutputSafety
    end
    div(**reactive_root(class: 'flex items-center gap-3')) do
      button(**mix(on(:save, disable_with: 'Saving…'),
                   class: 'btn btn-sm btn-primary', data: { testid: 'save' })) { 'Save' }
      # A scoped busy indicator: data-reactive-busy toggles ONLY while save is in
      # flight, so daisyUI's spinner shows with zero Ruby.
      span(**mix(busy_on(:save),
                 class: 'loading loading-spinner loading-sm', data: { testid: 'spinner' }))
      span(class: 'text-sm opacity-70', data: { testid: 'saved-count' }) do
        plain 'Saved '
        span { @count.to_s }
        plain ' times'
      end
    end
  end
end
```

## disable_with: — swap the label, disable in flight

`on(:save, disable_with: "Saving…")` swaps the button's text and disables it the instant the request is enqueued, reverting when the round trip settles. It's the shorthand for `loading: { disable: true, text: "Saving…" }` — Livewire's `wire:loading` + `phx-disable-with` without a Stimulus controller.

## busy_on — a scoped spinner, styled with CSS

`busy_on(:save)` puts `data-reactive-busy` on an element ONLY while `save` is in flight. Reveal it with pure CSS — here daisyUI's `loading loading-spinner` — no Ruby toggles it. Every reactive root also always carries `aria-busy="true"` during any in-flight action, so you can style the whole component's wait state with an attribute selector.

## No double-submit — the disable IS the dedup

With the latency simulator on, click **Save** twice quickly: the count still advances by **one**. A disabled button fires no further clicks, so the second press lands on a dead control and never enqueues a second POST. The client queue serializes requests per component; `disable_with:` is what dedupes them.

## Notes

> **Tip:** The latency toggle is a docs convenience, not a gem feature you ship. In your own app the simulator is exposed on `window.PhlexReactive.enableLatencySim(ms)` when you author `<meta name="phlex-reactive-env" content="development">` — see [Installation](https://phlex-reactive.zoolutions.llc/docs/installation). Production stays untouched.