Examples

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.

The Overview panel — shown by default.
app/components/client_tabs_component.rb
# 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
end

How 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 whitelistshow/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, and focus_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 matchequals:, 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" })))
app/components/conditional_fieldset_component.rb
# 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
end

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