Example: client-only ops
Some interactions never need the server — tabs, a menu that closes on an outside click, an accessible drawer with a transition + focus. `on_client` runs a whitelist of DOM ops locally, with ZERO round trips and ZERO custom JavaScript.
Try it — nothing here hits the server#
Switch tabs, open the menu (then click anywhere outside to close it), open the drawer (it fades in and focus lands on its first button). Every one of these is a client op — no token, no POST, ever. Open the network tab: you will see nothing. The component declares no actions at all.
# frozen_string_literal: true
# Issue #95: a component whose ENTIRE interactivity is client-side on_client ops —
# tabs that switch panels, a menu that closes on any outside click, and an
# accessible drawer with a transition + focus op — with ZERO server round trips.
# It deliberately declares NO actions: there is no token-bearing trigger anywhere,
# and the system spec's fetch spy proves nothing is ever posted. The root carries
# the window-bound outside-close trigger permanently (a client op costs nothing per
# stray page click, unlike a server action).
class ClientTabsComponent < Phlex::HTML
include Phlex::Reactive::Component
def id = 'client-tabs'
def view_template
# The fade transition classes for the drawer's js.toggle(transition:). Scoped
# inline so the demo is self-contained (no global CSS needed).
style do
css = '.ct-fade{transition:opacity .2s ease} .ct-fade-from{opacity:0} .ct-fade-to{opacity:1} ' \
'#ct-panel-1,#ct-panel-2{padding:.75rem 0} .ct-tab.active{font-weight:600;color:var(--color-primary)}'
raw(safe(css)) # rubocop:disable Rails/OutputSafety
end
div(**mix(reactive_root, on_client(:click, js.hide('#ct-menu'), outside: true),
class: 'flex flex-col gap-4')) do
tabs
panels
menu
drawer
end
end
private
def tabs
div(role: 'tablist', class: 'flex gap-2 border-b border-base-300') do
tab_button(1, 'Overview', active: true)
tab_button(2, 'Details', active: false)
end
end
# One op chain per tab: hide every panel, show the picked one, restyle the tab
# buttons — the canonical "I had to write a Stimulus controller" case, now one
# line of declared client ops.
def tab_button(index, label, active:)
ops = js.hide('.ct-panel').show("#ct-panel-#{index}")
.remove_class('.ct-tab', 'active').add_class("#ct-tab-#{index}", 'active')
button(**mix(on_client(:click, ops),
id: "ct-tab-#{index}", class: ['ct-tab px-3 py-1', ('active' if active)].compact,
data: { testid: "tab-#{index}" })) { label }
end
def panels
div(id: 'ct-panel-1', class: 'ct-panel', data: { testid: 'panel-1' }) do
'The Overview panel — shown by default.'
end
div(id: 'ct-panel-2', class: 'ct-panel', hidden: true, data: { testid: 'panel-2' }) do
'The Details panel — revealed by a pure client op.'
end
end
def menu
div(class: 'flex flex-col gap-2') do
button(**mix(on_client(:click, js.show('#ct-menu')),
class: 'btn btn-sm w-fit', data: { testid: 'menu-open' })) { 'Open menu' }
div(id: 'ct-menu', hidden: true, class: 'rounded-box border border-base-300 p-3',
data: { testid: 'menu' }) { 'Menu content — click anywhere outside to close.' }
end
end
# Issue #96: a drawer opened with a TRANSITION (animated fade), an aria-expanded
# attr op on the trigger, and a FOCUS op landing on the first focusable control
# inside the drawer — the exact accessible-disclosure pattern, one op chain.
def drawer
open = js
.toggle('#ct-drawer', transition: { during: 'ct-fade', from: 'ct-fade-from', to: 'ct-fade-to' })
.set_attr(:root, 'aria-expanded', 'true')
.focus_first('#ct-drawer')
div(class: 'flex flex-col gap-2') do
button(**mix(on_client(:click, open),
id: 'ct-drawer-trigger', class: 'btn btn-sm w-fit',
data: { testid: 'drawer-open' })) { 'Open drawer' }
div(id: 'ct-drawer', hidden: true, class: 'rounded-box border border-base-300 p-3 flex gap-2',
data: { testid: 'drawer' }) do
button(class: 'btn btn-xs', data: { testid: 'drawer-first' }) { 'First action' }
button(class: 'btn btn-xs', data: { testid: 'drawer-second' }) { 'Second action' }
end
end
end
endHow it works#
on_client(:click, ops) binds a chain of DOM ops to an event and runs them locally through the one generic reactive controller. The ops are a frozen whitelist — show/hide/toggle, add_class/ remove_class/toggle_class, set_attr/toggle_attr/remove_attr, focus/focus_first, text, dispatch, submit, paste_into — each a local DOM mutation, with two deliberate exceptions. submit hands the form to its own native/intercepted submit path — the form may POST or navigate from there, but that is the form's contract, not the op's. paste_into (#228) reads the clipboard, behind the browser's own gesture and permission gates, and still only writes locally. Everything else sends nothing and reads nothing back.
- Tabs:
js.hide(".ct-panel").show("#ct-panel-1")plus class ops on the tab buttons — the "I had to write a Stimulus controller" case, now one line. - Outside-close menu:
on_client(:click, js.hide("#ct-menu"), outside: true)on the root fires on any click outside the menu. A client op costs nothing per stray page click (unlike a server action). - Accessible drawer:
js.toggle("#ct-drawer", transition: { during:, from:, to: })animates it,set_attr(:root, "aria-expanded", "true")updates the trigger, andfocus_first("#ct-drawer")moves focus to the first control inside — the exact accessible-disclosure pattern, one chain.
The op vocabulary#
Ops are built with the server-side js helper and serialized into a data-reactive-ops attribute the client interprets. An op name not on the whitelist is warn-and-skipped (client-side default-deny) — a stale or newer ops attribute can never break the page. In development and test (verbose_errors, #237) an op whose selector resolves to zero elements console-warns once per unique case, with a hint — to: :root / global: true — when the element exists but sits outside the op's scope (the root itself, a nested reactive root, or elsewhere in the document). Production stays silent. focus/focus_first/ submit/paste_into are allowed here (an actor's own gesture) but rejected from a broadcast — stealing focus in every subscriber's tab, force-submitting every subscriber's form, or reading every subscriber's clipboard, would be hostile.
text(to, value) (#159) sets the target's textContent — stringified, nil clears, never innerHTML — so a chain can paint a label or a derived number without a round trip. Pair it with global: true to reach a node outside the component's root (the cross-root text escape a read-only recap needs).
submit(to = :root) (#226) commits the target's own form via requestSubmit() — the target itself when it is a form, a control's form owner, else the nearest ancestor form. A real cancelable submit event fires, so a native/Turbo form navigates normally and an on(:save, event: "submit") interception turns it into a signed action. That makes the classic autosubmit filter one declared line, with Turbo Drive handling the visit:
form(action: "/products", method: "get") do
select(name: "sort", **on_client(:change, js.submit("form"))) { sort_options }
endBinding a submit op to the submit event itself raises at render — requestSubmit dispatches the very event the trigger would listen to. For "submit when a text value becomes complete", see the conditional forms below and the compute reducer's $ops output on the payment-split example.
paste_into(to) (#228) reads the clipboard into a field on a user gesture — built for fields whose real <input> is visually hidden (an OTP cell UI), where right-click → Paste can never reach the editable input. On click it starts navigator.clipboard.readText() fire-and-forget (the permission UX is the browser's own; chained sibling ops apply immediately, never waiting for the read) and, when the read resolves, feeds the text through the normal input pipeline: set .value, dispatch a bubbling input (compute reducers, reactive_show, reactive_on_complete run exactly as if typed), then focus the field so a partial paste continues from the caret. A denied read, empty text, or a missing API is a silent no-op. The trigger is marked data-reactive-clipboard and the controller sets hidden = !available on connect — author it hidden and a dead button never shows. The gate owns the trigger's hidden flag: render it unconditionally, and don't also bind reactive_show to the trigger element:
button(hidden: true,
**mix(on_client(:click, js.paste_into("[name=code]")),
class: "btn")) { "Paste code" }Reach for on_client whenever the interaction is purely presentational — toggling a disclosure, closing a popover, moving focus. Reach for a reactive action (a token + POST) only when the server must decide or persist something.
Value-conditional visibility (reactive_show)#
on_client ops are unconditional — they can't read the triggering field's value to decide show vs hide. reactive_show (#180) covers exactly that gap, the Alpine x-show / Datastar data-show / Livewire wire:show case, in ONE Ruby-native conditions language: if: / if_any: / unless:, with where-style values. The generic controller toggles the hidden attribute from the fields' current values on every input/change. Still client-only: no token, no POST.
A Hash is an AND, an Array is membership, a Range is a threshold, { length: … } compares the value's codepoint count (#226), and unless: negates — the whole value vocabulary:
div(**reactive_show(unless: { mode: "off" })) { "shipping details" }
div(**reactive_show(if: { gift: true })) { "gift message" } # checkbox checked
div(**reactive_show(if: { delivery: "ship" })) { "address fields" } # radio value
div(**reactive_show(if: { size: %w[l xl] })) { "surcharge note" } # membership
div(**reactive_show(if: { quantity: 10.. })) { "bulk note" } # threshold
div(**reactive_show(if: { code: { length: 6 } })) { "ready badge" } # exact lengthExtra HTML attrs pass through unambiguously (conditions live in if:), so div(**reactive_show(if: { size: %w[l xl] }, class: "note")) works.
OR-of-AND without the distributive law. if_any: takes an array of AND-hashes — one level of disjunction, which covers every boolean visibility rule. This is the case that used to force hand-applied distributive law encoded as nested wrapper divs:
# visible while director OR (shareholder AND role == "individual")
div(**reactive_show(if_any: [
{ director: true },
{ shareholder: true, role: "individual" }
]))
# AND across fields, with negation, in one binding:
div(**reactive_show(if: { type: "individual" }, unless: { country: "domestic" }))First paint is computed for you. Declare reactive_values once and every binding whose fields are all provided renders the correct initial hidden: server-side — no per-section mirror method, no flash:
def reactive_values = { director: @director, role: @role, quantity: @qty }
# …then every reactive_show computes hidden: from these, automatically.reactive_scope :form lets bindings use bare symbols while the client resolves [name="form[field]"], and disable: true disables a hidden section's own controls so a switched-away value never submits. A blank or non-numeric field fails a numeric term closed (stays hidden) — the safe default. There is no expression surface: every term is a declared literal, the same default-deny posture as before.
A plain reactive_show is root-scoped by design. When the dependents live outside the control's root — a nav tab, a panel in another tab pane, a sidebar note — reactive_show_targets (#164) is the declared escape: the component that owns the field declares which outside ids it governs, spread on the root, using the same where-style values. Id selectors only (raise at render, warn-and-skip on the client — two-sided default-deny); a target id not on the page is skipped.
div(**mix(reactive_root, reactive_show_targets(:mode,
"#advanced-tab" => "advanced", # equals
"#premium-note" => %w[gold platinum]))) # membershipA "#id" key takes a full if:/if_any:/unless: conditions Hash (#209), so a cross-root target can read a combination of owned fields — the case that used to force a bespoke two-field JS listener. The fold matches an in-root reactive_show exactly (each term reads its own field; a missing owned field reads as blank — fail-closed), and target-keyed entries mix with field-keyed ones in the one call per root:
reactive_show_targets(
"#bulk-alert" => { if: { type: "company", quantity: 10.. } },
mode: { "#advanced-tab" => "advanced" }
)Conditional OPS — reactive_on_complete (#226). reactive_show decides visibility from a condition; reactive_on_complete runs a client-op chain on the condition's rising edge — once, when it first becomes true; going false re-arms; the connect/morph pass arms without firing, so a re-render with already-satisfied conditions never self-fires. Same if:/if_any:/unless: kwargs, run: takes a js chain (class-level js is available in the declaration):
reactive_state :code
action :verify, params: { code: :string }
reactive_on_complete if: { code: { length: 6 } }, run: js.dispatch("code:complete")
def view_template
div(**mix(reactive_root, on(:verify, event: "code:complete"))) do
input(name: "code")
end
endThat is a complete auto-committing verification-code field with zero JavaScript — the dispatch bubbles to the root's own on(:verify, event: "code:complete"), which turns completion into one signed action POST. run: js.submit commits the surrounding form instead. When completion needs normalization first (strip separators, cap length), put the condition in the reducer and use its $ops output — see the compute example.
# frozen_string_literal: true
# Issue #180: value-conditional visibility — reactive_show, the x-show /
# data-show equivalent, via the ONE conditions language (if:/if_any:/unless:).
# A Hash is an AND, an Array is membership, a Range is a threshold, unless:
# negates; the generic controller toggles `hidden` from the fields' CURRENT
# values with NO round trip. reactive_values computes each section's first-paint
# `hidden:` server-side — no per-section mirror method, no flash. Like
# ClientTabsComponent it declares NO actions: no token-bearing trigger anywhere.
class ConditionalFieldsetComponent < Phlex::HTML
include Phlex::Reactive::Component
def id = 'conditional-fieldset'
# First-paint truth: every reactive_show whose fields are all here computes
# its own initial `hidden:`.
def reactive_values
{ mode: 'off', gift: false, delivery: 'pickup',
type: 'individual', country: 'domestic',
director: false, shareholder: false, role: 'individual', quantity: 1 }
end
def view_template
div(**reactive_root(class: 'flex flex-col gap-4')) do
shipping_mode
gift_option
delivery_choice
compound_address
or_of_and_names
quantity_surcharge
end
end
private
# A <select> driving a dependent panel: visible WHILE mode != "off".
def shipping_mode
div(class: 'flex flex-col gap-2') do
label(class: 'flex items-center gap-2') do
span { 'Shipping' }
select(name: 'mode', class: 'select select-sm w-fit', data: { testid: 'mode' }) do
option(value: 'off', selected: true) { 'No shipping' }
option(value: 'standard') { 'Standard' }
option(value: 'express') { 'Express' }
end
end
# Extra attrs ride THROUGH reactive_show (it deep-merges via mix) — a bare
# `data:` beside the spread would clobber the binding.
div(**reactive_show(unless: { mode: 'off' },
class: 'rounded-box border border-base-300 p-3',
data: { testid: 'mode-details' })) do
'Shipping details — visible while the select is not "No shipping".'
end
end
end
# A checkbox: its .value is the constant "on", so the binding compares the
# CHECKED state — `gift: true` is the checkbox form.
def gift_option
div(class: 'flex flex-col gap-2') do
label(class: 'flex items-center gap-2') do
input(type: 'checkbox', name: 'gift', class: 'checkbox checkbox-sm', data: { testid: 'gift' })
span { 'This is a gift' }
end
div(**reactive_show(if: { gift: true },
class: 'rounded-box border border-base-300 p-3',
data: { testid: 'gift-note' })) do
'Gift message — visible while the checkbox is checked.'
end
end
end
# A radio group: the binding reads the CHECKED radio's value.
def delivery_choice
div(class: 'flex flex-col gap-2') do
div(class: 'flex gap-4') do
label(class: 'flex items-center gap-2') do
input(type: 'radio', name: 'delivery', value: 'pickup', checked: true,
class: 'radio radio-sm', data: { testid: 'delivery-pickup' })
span { 'Pickup' }
end
label(class: 'flex items-center gap-2') do
input(type: 'radio', name: 'delivery', value: 'ship',
class: 'radio radio-sm', data: { testid: 'delivery-ship' })
span { 'Ship' }
end
end
div(**reactive_show(if: { delivery: 'ship' },
class: 'rounded-box border border-base-300 p-3',
data: { testid: 'address' })) do
'Shipping address — visible while the "Ship" radio is checked.'
end
end
end
# Issue #180: a COMPOUND if:/unless: across TWO fields — one flat binding,
# visible only while type == "individual" AND country != "domestic".
def compound_address
div(class: 'flex flex-col gap-2') do
div(class: 'flex gap-4') do
label(class: 'flex items-center gap-2') do
span { 'Type' }
select(name: 'type', class: 'select select-sm w-fit', data: { testid: 'type' }) do
option(value: 'individual', selected: true) { 'Individual' }
option(value: 'company') { 'Company' }
end
end
label(class: 'flex items-center gap-2') do
span { 'Country' }
select(name: 'country', class: 'select select-sm w-fit', data: { testid: 'country' }) do
option(value: 'domestic', selected: true) { 'Domestic' }
option(value: 'foreign') { 'Foreign' }
end
end
end
div(**reactive_show(if: { type: 'individual' }, unless: { country: 'domestic' },
class: 'rounded-box border border-base-300 p-3',
data: { testid: 'intl-address' })) do
'International address — visible while Individual AND not Domestic.'
end
end
end
# Issue #180: OR-of-AND (the distributive-law killer) — visible while
# director OR (shareholder AND role == "individual"). ONE if_any: binding, no
# nested wrapper divs.
def or_of_and_names
div(class: 'flex flex-col gap-2') do
div(class: 'flex gap-4 items-center') do
label(class: 'flex items-center gap-2') do
input(type: 'checkbox', name: 'director', class: 'checkbox checkbox-sm', data: { testid: 'director' })
span { 'Director' }
end
label(class: 'flex items-center gap-2') do
input(type: 'checkbox', name: 'shareholder', class: 'checkbox checkbox-sm', data: { testid: 'shareholder' })
span { 'Shareholder' }
end
select(name: 'role', class: 'select select-sm w-fit', data: { testid: 'role' }) do
option(value: 'individual', selected: true) { 'Individual' }
option(value: 'company') { 'Company' }
end
end
div(**reactive_show(if_any: [{ director: true }, { shareholder: true, role: 'individual' }],
class: 'rounded-box border border-base-300 p-3',
data: { testid: 'name-fields' })) do
'Name fields — visible while Director OR (Shareholder AND Individual).'
end
end
end
# Issue #180: a NUMERIC threshold — reveal while quantity >= 10 (a Range).
def quantity_surcharge
div(class: 'flex flex-col gap-2') do
label(class: 'flex items-center gap-2') do
span { 'Quantity' }
input(type: 'number', name: 'quantity', value: '1',
class: 'input input-sm w-24', data: { testid: 'quantity' })
end
div(**reactive_show(if: { quantity: 10.. },
class: 'rounded-box border border-base-300 p-3',
data: { testid: 'surcharge' })) do
'Bulk surcharge applies — visible while quantity ≥ 10.'
end
end
end
endClient-only drafts (reactive_persist)#
"Don't make me start over." A public application form, a wizard, a long comment box: the user types, navigates away, comes back, and expects the draft. Nothing the server needs until submit, no signed-in user to autosave for — so every app hand-rolls the same localStorage Stimulus controller (read, parse, TTL, restore, clear). reactive_persist (#239) is the reactive_show-shaped answer: a declared, client-only binding over the fields the root owns — no token, no POST, no expression surface.
div(**mix(reactive_root(id: "apply"), reactive_persist(key: "village-apply", ttl: 7.days))) do
input(**reactive_field(:name)) # persisted
input(name: "fuckery", **reactive_persist_skip) # honeypot — never
input(type: "hidden", name: "apply[tz]") # hidden — never (default)
button(**on_client(:click, js.persist_state(step: 2))) { "Next" }
button(**on_client(:click, js.persist_clear)) { "Discard draft" }
endSpread on the root, once. The controller writes a snapshot of every owned control on input (trailing-edge debounce, debounce: ms) and immediately on change, and flushes a pending write on disconnect so a fast Turbo visit never loses the last keystrokes. It restores on connect — first among the client bindings, so a reactive_show section, an armed reactive_on_complete, a filter and a compute root all read the restored values on first paint with no synthetic events — and never re-restores over a morph (server truth). By default a draft lands only in a control the server rendered blank (restore: :blank): a 422 re-render's submitted values beat an older draft; restore: :always lets the draft win. It clears on a successful turbo:submit-end of the containing form, on ttl expiry, or via js.persist_clear — never on a successful reactive action by itself (chain reply.js(js.persist_clear)).
Never persisted: hidden/file/password/submit controls, anything carrying reactive_persist_skip, a nested root's controls, and rich-text editors. autocomplete="off" is not an implicit skip — honeypots must opt out or sit outside the root. fields: narrows to declared names. js.persist_state(step: 2) merges a flat state bag into the draft; on restore the root carries data-reactive-persist-state and dispatches reactive:persist-restored (detail: { key, fields, state }) — the hook for a wizard to jump to its saved step. Storage failures (private window, quota, blocked) are silent; under Phlex::Reactive.debug one console.info names the failure.
Type something, reload the page — the draft comes back.
# frozen_string_literal: true
# Issue #239: reactive_persist — a client-only localStorage draft over the
# fields the root OWNS. A token-less ClientBindings form (no actions, no
# POST): typing is remembered across a reload and restored on the next
# connect, a reactive_show section re-evaluates from the restored value on
# first paint, the honeypot / hidden / password controls never reach storage,
# "Next step" writes a state bag via js.persist_state and "Discard" forgets
# the draft via js.persist_clear. (The docs demo has no form to submit — in a
# real form a successful Turbo submit clears the draft automatically.)
class PersistFormComponent < Phlex::HTML
include Phlex::Reactive::ClientBindings
reactive_scope :apply
def reactive_values = { size: nil }
def view_template
div(**mix(reactive_root(id: 'persist-form', class: 'space-y-3'),
reactive_persist(key: 'docs-apply', ttl: 1.hour, debounce: 100))) do
input(**reactive_field(:name, type: 'text', placeholder: 'Your name', class: 'input input-bordered w-full'))
textarea(**reactive_field(:bio, placeholder: 'Tell us about yourself', class: 'textarea textarea-bordered w-full'))
div(class: 'flex gap-4') do
label(class: 'label cursor-pointer gap-2') do
input(**reactive_field(:size, type: 'radio', value: 's', class: 'radio'))
plain 'Small'
end
label(class: 'label cursor-pointer gap-2') do
input(**reactive_field(:size, type: 'radio', value: 'l', class: 'radio'))
plain 'Large'
end
label(class: 'label cursor-pointer gap-2') do
input(**reactive_field(:gift, type: 'checkbox', class: 'checkbox'))
plain 'Gift'
end
end
div(**reactive_show(if: { size: 'l' }, class: 'alert alert-info')) { 'Large surcharge applies' }
# Never persisted: the honeypot (explicit skip), a hidden input, a password.
input(name: 'fuckery', type: 'text', hidden: true, tabindex: '-1', **reactive_persist_skip)
input(type: 'hidden', name: 'apply[tz]', value: 'UTC')
input(**reactive_field(:secret, type: 'password', placeholder: 'Password (never stored)',
class: 'input input-bordered w-full'))
div(class: 'flex gap-2') do
button(**mix(on_client(:click, js.persist_state(step: 2)), class: 'btn btn-primary btn-sm')) { 'Next step' }
button(**mix(on_client(:click, js.persist_clear), class: 'btn btn-ghost btn-sm')) { 'Discard draft' }
end
p(class: 'text-sm opacity-70') { 'Type something, reload the page — the draft comes back.' }
end
end
endThe zero-fetch contract#
Because the component declares no actions and every trigger is on_client, a click here never mints a token and never posts. That is the tested contract: the browser suite spies on fetch and asserts it is called zero times across every tab switch, menu toggle, and drawer open.