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: { 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
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, 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, 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. 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 }
end

Binding 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 length

Extra 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])))  # membership

A "#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
end

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

app/components/conditional_fieldset_component.rb
# 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
end

Client-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" }
end

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

app/components/persist_form_component.rb
# 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
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.