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.
# 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
enddisable_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#
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. Production stays untouched.