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: %w[ct-fade ct-fade-from 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 — each a pure, local DOM mutation. Nothing is read back, nothing is sent anywhere.
- 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: [...])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. focus/focus_first are allowed here (an actor's own gesture) but rejected from a broadcast, where stealing focus in every subscriber's tab 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).
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 (#161) covers exactly that gap, the Alpine x-show / Datastar data-show / Livewire wire:show case: spread it onto the element to show/hide, name the controlling field, declare one literal predicate — and the generic controller toggles the hidden attribute from the field's current value on every input/change. Still client-only: no token, no POST.
div(**reactive_show(:mode, not: "off")) { "shipping details" }
div(**reactive_show(:gift, equals: true)) { "gift message" } # checkbox: checked
div(**reactive_show(:delivery, equals: "ship")) { "address fields" } # radio: checked value
div(**reactive_show(:size, in: %w[l xl])) { "surcharge note" }The predicate is a declared literal match — equals:, not:, or in: (a list) — never an expression, so there is no eval surface. Exactly one predicate is enforced loudly at render; extra attrs deep-merge through the helper. Render the initial hidden: yourself from the same server state that renders the field, so the first paint doesn't flash.
Compound & numeric predicates (#176). Two common form shapes fall just outside a single literal match: visibility that depends on more than one field, and visibility gated on a threshold. Both stay inside the eval-free contract.
all: / any: fold a list of per-field terms with one fixed connective — still no expression surface, just AND vs OR over the same literal vocabulary. One flat binding replaces the wrapper-div nesting AND is the only way to express OR:
# visible while type == "individual" AND country != "domestic"
div(**reactive_show(all: [
{ field: :type, equals: "individual" },
{ field: :country, not: "domestic" }
]))
# visible while director OR shareholder is checked
div(**reactive_show(any: [
{ field: :director, equals: true },
{ field: :shareholder, equals: true }
]))gte: / gt: / lte: / lt: compare the field value coerced to a Number against a literal number baked into the binding (the RHS must be a real Numeric in Ruby — a typo fails at render). A non-numeric field value is NaN → hidden, the safe reveal-on-threshold default. Numeric predicates work standalone and as a compound term:
div(**reactive_show(:quantity, gte: 10)) # visible while qty >= 10
div(**reactive_show(all: [
{ field: :type, equals: "order" },
{ field: :amount, gte: 5000 }
]))A malformed compound term folds false (fail-closed: a broken AND term can't pass, a broken OR term can't reveal) — the same default-deny posture as the rest of the vocabulary. Numeric predicates also carry into reactive_show_targets maps.
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 visibility parallel of the cross-root text mirror: the component that owns the field declares which outside ids it governs, spread on the root. Id selectors only (raise at render, warn-and-skip on the client — two-sided default-deny), same literal predicates, hidden only; a target id not on the page is silently skipped.
div(**mix(reactive_root, reactive_show_targets(:mode,
"#advanced-tab" => { equals: "advanced" },
"#basic-note" => { not: "advanced" })))# frozen_string_literal: true
# Issue #161: value-conditional visibility — reactive_show, the x-show /
# data-show equivalent. Each dependent section declares which field controls it
# plus ONE literal predicate, and the generic controller toggles `hidden` from
# the field's CURRENT value with NO round trip. Like ClientTabsComponent it
# declares NO actions: there is no token-bearing trigger anywhere. Every section
# renders its initial `hidden:` from the same server state that renders its
# field, so first paint needs no client reconcile (no flash).
class ConditionalFieldsetComponent < Phlex::HTML
include Phlex::Reactive::Component
def id = 'conditional-fieldset'
def view_template
div(**reactive_root(class: 'flex flex-col gap-4')) do
shipping_mode
gift_option
delivery_choice
compound_address
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(:mode, not: 'off', hidden: true,
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 — equals: 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(:gift, equals: true, hidden: 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(:delivery, equals: 'ship', hidden: true,
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 #176 part A: a COMPOUND all: 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(hidden: true,
class: 'rounded-box border border-base-300 p-3',
data: { testid: 'intl-address' }, all: [
{ field: :type, equals: 'individual' },
{ field: :country, not: 'domestic' }
])) do
'International address — visible while Individual AND not Domestic.'
end
end
end
# Issue #176 part B: a NUMERIC threshold — reveal while quantity >= 10.
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(:quantity, gte: 10, hidden: true,
class: 'rounded-box border border-base-300 p-3',
data: { testid: 'surcharge' })) do
'Bulk surcharge applies — visible while quantity ≥ 10.'
end
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.