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 whitelist — show/hide/toggle, add_class/ remove_class/toggle_class, set_attr/toggle_attr/remove_attr (toggle_attr also flips between two values, and show/hide/toggle take expanded: to keep a trigger's aria-expanded in sync — #271), focus/focus_first, show_modal/close_dialog, copy_media, text, set_value, dispatch, submit, paste_into, preview_file — each a local DOM mutation, with three 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. preview_file (#375) reads the chosen File from the input's files and paints it through an object URL it makes itself — nothing is uploaded. 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.

Accessible disclosure menu#

The WAI-ARIA menu button pattern, still with no actions and no custom JavaScript (#271). Click the trigger, or focus it and press Arrow Down: the menu opens and focus lands on the first item. Arrow Up/Down rove focus (wrapping), Home/End jump to the edges, Escape closes and returns focus to the trigger, and an outside click closes it too. aria-expanded on the trigger stays honest the whole way.

  • toggle_attr(to, name, on, off) flips an attribute between two values — js.toggle("#dm-menu").toggle_attr("#dm-trigger", "aria-expanded", "true", "false"). An absent attribute becomes on.
  • expanded: on show/hide/toggle mirrors the op's intended state into the trigger's aria-expanded — js.hide("#dm-menu", expanded: "#dm-trigger") writes "false", before any transition.
  • reactive_listnav(focus: true) on the role="menu" list gives roving focus among [role=menuitem].
  • Two on_client calls on one element compose through mix: the trigger binds click and keydown.down, the root binds the outside click and keydown.esc.

Picked: nothing

app/components/disclosure_menu_component.rb
# frozen_string_literal: true

# Issue #271: an accessible disclosure menu (the WAI-ARIA menu-button pattern)
# with NO actions and NO custom JS — every behavior is a declared on_client op:
#
# * the trigger keeps aria-expanded honest: a two-value toggle_attr on click,
#   expanded: on ArrowDown (open + focus the first item);
# * the menu roves focus among role=menuitem with Arrow/Home/End
#   (reactive_listnav(focus: true));
# * the root closes the menu on an outside click AND on Escape (focus back to
#   the trigger) — two on_client bindings on one element, composed by mix.
class DisclosureMenuComponent < Phlex::HTML
  include Phlex::Reactive::Component

  ITEMS = %w[Rename Duplicate Archive].freeze

  def id = 'disclosure-menu'

  def view_template
    close = js.hide('#dm-menu', expanded: '#dm-trigger')

    div(**mix(reactive_root,
              on_client(:click, close, outside: true),
              on_client('keydown.esc', close.focus('#dm-trigger')),
              class: 'flex flex-col gap-3')) do
      div(class: 'w-fit') do
        trigger
        menu
      end
      p(class: 'text-sm') do
        plain 'Picked: '
        span(id: 'dm-status', class: 'font-semibold') { 'nothing' }
      end
    end
  end

  private

  def trigger
    # aria_expanded is the STRING "false": Phlex drops a boolean false entirely.
    button(id: 'dm-trigger', aria_haspopup: 'menu', aria_expanded: 'false', aria_controls: 'dm-menu',
           **mix(on_client(:click, js.toggle('#dm-menu').toggle_attr('#dm-trigger', 'aria-expanded', 'true', 'false').focus_first('#dm-menu')),
                 on_client('keydown.down', js.show('#dm-menu', expanded: '#dm-trigger').focus_first('#dm-menu')),
                 class: 'btn btn-sm')) { 'Actions ▾' }
  end

  def menu
    ul(id: 'dm-menu', role: 'menu', hidden: true, aria_labelledby: 'dm-trigger',
       **mix(reactive_listnav(focus: true),
             class: 'menu mt-1 w-40 rounded-box border border-base-300 bg-base-100 p-1 shadow')) do
      ITEMS.each do |label|
        li(role: 'none') do
          button(role: 'menuitem', tabindex: '-1',
                 **on_client(:click, js.hide('#dm-menu', expanded: '#dm-trigger').text('#dm-status', label).focus('#dm-trigger'))) { label }
        end
      end
    end
  end
end

Media previews (copy_media, reactive_media)#

set_attr refuses src (#96): a URL in an op could carry a javascript: payload. To point a preview at an image, render every candidate <img> on the server and copy one. No op or binding takes a URL (#373).

  • js.copy_media(to, from:) copies src, srcset and alt from the from <img> onto the to <img>. Both are :root or a selector, resolved with the op's own scoping (global: true reaches the whole document). The browser reads the URL from the source element itself; it is never an argument. A missing source or a source that is not an <img> clears the target's src, srcset and alt. An <img> source without src is a hit with no image (#381): the target loses src and srcset and takes the source's alt. A target that is not an <img> (an iframe, script, object, embed, frame, …) is warned about and skipped. Copying the URL the target already shows writes nothing, so the image does not reload. A broadcast may carry it: it only reads elements that are already on each subscriber's page.
  • reactive_media(from:, pool:, take: nil, match: nil, pending: :clear, label: nil, blank_label: nil) is a binding, read the way reactive_show reads a field: a text field gives its value, a radio group its checked value, reactive_scope applies. take: n (a positive Integer) keeps the first n characters as the key; omit it to use the whole value. The key looks up pool, whose values are selectors for <img> elements inside the root, never URLs. A key, selector or element that misses clears the preview. A pool <img> with an alt and no src (an entry with no logo file) is a hit: the preview drops its image and label: still gets the name. label: names a node whose textContent becomes the chosen image's alt, or blank_label (empty when omitted) on a miss.
  • Pending (#380). A value shorter than take: characters is not a miss yet: the binding is pending and never writes blank_label. match: (a Regexp, matched against the whole key) makes a key it rejects pending too, so "ab1" waits instead of reading "Unknown". pending: :clear (the default) clears the preview and empties the label; pending: :keep leaves both as they are. The preview carries data-reactive-media-state="pending|hit|miss", so a row can hide itself with CSS until there is an answer (the demo below uses Tailwind's has-[[data-reactive-media-state=pending]]:invisible). match: is sent to the browser as a JavaScript pattern: its \A and \z anchors are dropped (the client anchors it), and what JavaScript reads differently raises at render: options (i, m, x), escapes such as \h, \p{…}, \s and \u{…}, POSIX [[:digit:]], nested classes, &&, (?i)/(?>…) groups and characters above U+FFFF. The state is written once the client connects; to hide the row on first paint too, render the preview with data: { reactive_media_state: "pending" }.
input(name: "code")
div(id: "logo-row") do
  img(id: "logo", alt: "", **reactive_media(
    from: :code, take: 3, match: /\A\d{3}\z/,
    pool: { "007" => "#logo-007", "011" => "#logo-011" },
    label: "#caption", blank_label: "Unknown",
    data: { reactive_media_state: "pending" } # hidden on first paint, too
  ))
  span(id: "caption")
end
img(id: "logo-007", src: "/logos/007.svg", alt: "North", hidden: true)
img(id: "logo-011", src: "/logos/011.svg", alt: "South", hidden: true)

button(**on_client(:click, js.copy_media("#logo", from: "#logo-007"))) { "Use north" }
/* hide the row until three digits are typed */
#logo-row:has([data-reactive-media-state="pending"]) { display: none; }

js.copy_media("#logo") without from:, reactive_media without pool:, and a take: that is not a positive Integer raise at render. When the field value is the whole key and the images differ in more than the picture, reactive_show over pre-rendered blocks is the simpler tool; reactive_media is for a key that is a prefix of a longer value.

North
app/components/media_preview_component.rb
# frozen_string_literal: true

# Issue #373: a preview image that follows a field, and buttons that copy an
# image — with no URL in any op or binding. The server renders every candidate
# <img> (hidden); reactive_media copies the one whose key is the first three
# characters of the code, and js.copy_media copies a named one. Central (050)
# has no logo file: its pool <img> carries an alt and no src, so the preview
# drops its image and keeps the name (issue #381).
class MediaPreviewComponent < Phlex::HTML
  include Phlex::Reactive::Component

  REGIONS = {
    '007' => ['#mpv-north', 'North'],
    '011' => ['#mpv-south', 'South'],
    '042' => ['#mpv-east', 'East'],
    '050' => ['#mpv-central', 'Central']
  }.freeze
  NO_LOGO = %w[Central].freeze

  def id = 'media-preview'

  def view_template
    div(**mix(reactive_root, class: 'flex flex-wrap items-start gap-6')) do
      field
      preview
      buttons
      pool
    end
  end

  private

  def field
    label(class: 'form-control w-56') do
      span(class: 'label-text') { 'Branch code (007, 011, 042, 050…)' }
      input(name: 'code', value: '007-1234', class: 'input input-bordered input-sm', autocomplete: 'off')
    end
  end

  # The first paint matches the field's initial value (007), so the preview
  # shows without JavaScript; the binding then writes nothing until it changes.
  # Until three digits are typed the binding is pending (issue #380): no
  # "Unknown branch", and the figure hides itself on data-reactive-media-state.
  def preview
    figure(class: 'flex flex-col items-center gap-1 has-[[data-reactive-media-state=pending]]:invisible') do
      img(id: 'mpv-logo', src: '/media-demo/north.svg', alt: 'North', width: 64, height: 64, class: 'rounded',
          **reactive_media(from: :code, take: 3, match: /\A\d{3}\z/, pool: REGIONS.transform_values(&:first),
                           label: '#mpv-caption', blank_label: 'Unknown branch'))
      figcaption(id: 'mpv-caption', class: 'text-sm') { 'North' }
    end
  end

  def buttons
    div(class: 'flex flex-col items-center gap-2') do
      div(class: 'flex gap-2') do
        REGIONS.each_value do |selector, name|
          button(**mix(on_client(:click, js.copy_media('#mpv-copied', from: selector)), class: 'btn btn-xs')) { name }
        end
      end
      img(id: 'mpv-copied', alt: '', width: 32, height: 32, class: 'rounded')
    end
  end

  def pool
    REGIONS.each_value do |selector, name|
      src = "/media-demo/#{name.downcase}.svg" unless NO_LOGO.include?(name)
      img(id: selector.delete_prefix('#'), src:, alt: name, hidden: true)
    end
  end
end

Preview a chosen image#

Choosing a file should show it before the upload. js.preview_file (#375) does that on the file input's change, with no round trip and no Stimulus controller:

input(type: "file", id: "photo-file", accept: "image/*",
  **on_client(:change, js.preview_file("#photo-file", into: "#photo", name: "#photo-name")))
img(id: "photo", alt: "")
span(id: "photo-name")
  • The client takes the input's first image/* file, makes an object URL from it (URL.createObjectURL), sets it as the img's src, and puts file.name in the name: node (optional) as textContent.
  • An empty selection or a non-image file removes the src and clears the name.
  • The runtime keeps the last URL per img and revokes it on the next choice, on a clear, and when the img leaves the page or a morph strips its src — no blob URL leaks.
  • The op carries no URL. There is no parameter for one, the client never reads a url key, and set_attr of src (and srcset, #373) stays refused (#96). Only an <img> ever gets a src: an into: that is anything else, or a to that is not an input[type=file], warns and is skipped.
  • to, into: and name: resolve inside the component's root by default (global: false). global: true resolves all three document-wide, for an img outside the root.
  • Actor-only like paste_into: on_client and reply.js may use it, broadcast_to(js:) refuses it.

When the app rejects the file. An upload validator (size, type) often rejects a file by clearing the input itself. input.value = "" fires no change, so the op never hears about it and the rejected image stays on screen. Two pieces close that gap (#382):

op = js.preview_file("#photo-file", into: "#photo", name: "#photo-name", valid: true)
input(type: "file", id: "photo-file", accept: "image/*",
  **mix(on_client(:change, op), on_client("reactive:file-changed", op)))
  • valid: true treats an input whose validity.valid is false as empty. A validator that calls input.setCustomValidity("Too big") before the op runs means the file never gets a preview, and a preview already shown is cleared and its URL revoked.
  • reactive:file-changed is the event for "the app changed this input's files without a change event". Bind the same op to it, and have the validator dispatch it after it clears or invalidates the field:
input.value = ""
input.dispatchEvent(new CustomEvent("reactive:file-changed", { bubbles: true }))

The event works whichever change listener runs first, the validator's or the op's. valid: true on its own needs the validator to have run before the op on the same change; when that order is not certain, dispatch the event. Without valid: the op previews whatever file the input holds, as before.

The preview is client state: a server re-render of the root renders the img without the preview's src and resets it, and the runtime revokes the URL. keep: (#369) does not cover it — it keeps classes, hidden and allowlisted attributes, and src is not one of them.

Preview of the chosen photo
app/reactive_components/photo_preview_component.rb
# frozen_string_literal: true

# Issue #375: preview a chosen image before any upload, with no custom
# JavaScript. js.preview_file paints the input's first image file onto the img
# through an object URL the client makes from the File itself (and revokes on
# the next choice); the op carries no URL at all.
class PhotoPreviewComponent < Phlex::HTML
  include Phlex::Reactive::Streamable
  include Phlex::Reactive::Component

  def id = 'photo-preview'

  def view_template
    div(**mix(reactive_root, class: 'flex flex-wrap items-center gap-4')) do
      label(class: 'form-control') do
        span(class: 'label-text text-sm') { 'Choose a photo' }
        input(type: 'file', id: 'pp-file', accept: 'image/*',
              **mix(on_client(:change, js.preview_file('#pp-file', into: '#pp-photo', name: '#pp-name')),
                    class: 'file-input file-input-bordered file-input-sm'))
      end
      figure(class: 'flex flex-col items-center gap-1') do
        img(id: 'pp-photo', alt: 'Preview of the chosen photo',
            class: 'h-24 w-24 rounded-box border border-base-300 bg-base-200 object-cover')
        figcaption(id: 'pp-name', class: 'text-xs opacity-70')
      end
    end
  end
end

An image that falls back, and recovers#

A preview that copy_media, reactive_media or preview_file points at a broken URL should not show a broken-image icon, and the next good URL should show it again. reactive_image_fallback (#383) does both, with no Stimulus controller to reset by hand:

input(name: "rib")
img(id: "logo", alt: "", **reactive_media(
  from: :rib, take: 3,
  pool: { "001" => "#bank-001", "007" => "#bank-007" },
  **reactive_image_fallback(hide: true)
))
img(id: "bank-001", src: "/banks/missing.svg", alt: "Broken Bank", hidden: true)
img(id: "bank-007", src: "/banks/007.svg", alt: "Attijariwafa", hidden: true)

# Or swap the image for its alt text in a node inside the root:
img(id: "copied", alt: "Bank", **reactive_image_fallback(show_alt: "#bank-name"))
span(id: "bank-name", hidden: true)
  • On error the img gets data-reactive-image-state="error". With hide: true (the default) it is hidden; with show_alt: its alt is written (as textContent) into that node, which is shown. hide: false only sets the state, for CSS: img[data-reactive-image-state=error].
  • On load the state is "loaded".
  • A later src tries again. copy_media, reactive_media and preview_file clear the error as they write the src; any src that loads clears it on load; a morph that changes the src lets the browser try the new one. A morph that keeps the same src keeps the state.
  • No src is not an error. Removing it (a reactive_media miss, a copy_media from a missing source) clears the state and shows the img.
  • Only the hidden the fallback added is removed: an img the server rendered hidden stays hidden.
  • show_alt: is a selector resolved inside the img's own reactive root, never a URL. hide: that is not true/false, or a show_alt: that is not a non-blank String (or is @root), raises at render. A String that is not a valid CSS selector, or a node that holds the img, is ignored by the client.
  • The client listens once on the document (error and load do not bubble, so it listens in the capture phase): nothing runs per root at connect.

Keeping client state through a morph#

A morph — reply.morph, broadcast_to(…, morph: true), a page-refresh morph — writes class, hidden and every attribute back from the server render. Client ops record nothing, so a selection made with add_class is gone the moment another viewer's change morphs the list. keep: true (#369) marks an op's result as the client's own:

class ItemList < Phlex::HTML
  include Phlex::Reactive::Component

  reactive_record :list
  def id = "item-list"

  def view_template
    div(**reactive_root) do
      @list.items.each do |item|
        div(**mix(on_client(:click, select(item)), id: "item-#{item.id}", class: "item")) do
          plain item.name
          div(id: "details-#{item.id}", class: "details", hidden: true) { item.notes }
        end
      end
    end
  end

  private

  # Clear all, then select one — every op kept.
  def select(item)
    js.remove_class(".item", "selected", keep: true)
      .hide(".details", keep: true)
      .add_class("#item-#{item.id}", "selected", keep: true)
      .show("#details-#{item.id}", keep: true)
  end
end

# Another viewer renames item 3; everyone's list is morphed —
# and the item each viewer selected stays selected in their tab.
ItemList.broadcast_to(list, :items, replace: list, morph: true)
  • What is kept is the op's result on each target: the class on or off, hidden, the attribute's value or its absence. A toggle_class or toggle puts back the state it left; it does not flip again.
  • The latest kept op wins per element and class or attribute, so the kept remove_class above cancels the previous selection's kept add_class — one item stays selected, not every item ever clicked. An op without keep: leaves an earlier kept record alone: a panel shown with show(…, keep: true) and closed with a plain hide opens again on the next morph. Close it with hide(…, keep: true).
  • The render still lands. A class or attribute no kept op touched follows the server, so the renamed item's own changes appear.
  • A replaced element starts clean. keep: holds through a morph, which keeps the element; a stream that replaces it brings a new element with no kept state.
  • keep: takes the same ops in the client ops builder: ops.add_class("#item-2", "selected", { keep: true }). With expanded:, the trigger's aria-expanded is kept too.

broadcast_to(js:) refuses keep: true (an ArgumentError naming keep): kept state belongs to one tab, like focus. When the server must know the selection — to filter, to show other viewers — keep it on the server (an action or presence) instead.

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/ show_modal/close_dialog/submit/paste_into/preview_file/set_value are allowed here (an actor's own gesture) but rejected from a broadcast — stealing focus in every subscriber's tab, opening a modal on every subscriber's screen, force-submitting every subscriber's form, reading every subscriber's clipboard, or overwriting every subscriber's field, 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).

set_value(to, value) (#374) fills a field — an input, textarea or select — from text already on the page, with no clipboard and no round trip. It assigns the live .value (never the attribute, never parsed as HTML; stringified, nil and "" clear), then dispatches one bubbling input when the value changed, so compute reducers and reactive_show react as if the user typed. It never dispatches change and never moves focus: chain .focus("#notes") for that. A select selects the matching option; a file input accepts "" only, which clears the chosen files. Anything else (a contenteditable, which reactive_editable writes; a non-field; a non-empty value for a file input) warns and is skipped. :root raises at render: the root is not a field. On a checkbox or radio it sets the submitted value, not the checked state: tick boxes with check_group or a click.

button(**on_client(:click,
  js.set_value("#notes", "Thanks for your order.").set_value("#template_key", "thanks")
)) { "Thanks" }

Why not set_attr("#notes", "value", …)? That writes the attribute: a field the user has typed in keeps its live value, and no input fires, so reducers and reactive_show stay stale. set_value is actor-only (refused by broadcast_to(js:); render the value instead) and is on the reducer builder too: $ops: ops.set_value("#notes", "…"). Its input re-runs the reducer, so never derive the value from the field it writes (the chain changes every pass and fires again each time); return a field the reducer owns as an output instead.

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

submitter: (#319) submits through a named submit control — form.requestSubmit(submitter) — so the request carries its name=value: js.submit("#bulk", submitter: "#delete-submit") posts bulk_action=delete. The submitter resolves with the op's own scoping; one that is not a submit control of that form warns and falls back to a plain submit. (A selector target resolves inside the root, so when the form is the root, keep the default target: js.submit(submitter: "#delete-submit").)

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), { checked: … } counts a checkbox group's ticked boxes (#319, see the bulk-selection section below), 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

Bulk selection (reactive_select_all, reactive_count, reactive_enable)#

A list with a checkbox per row, a "select all" header, a "Delete (2)" count, and actions that stay disabled until something is ticked — the table every admin screen has — without a per-list JavaScript controller (#319):

def reactive_values = { "ids[]" => [] }   # first paint: nothing ticked

def view_template
  div(**reactive_root) do
    form(id: "bulk", action: "/posts/bulk", method: "post") do
      input(type: "checkbox", **reactive_select_all("ids[]"))   # header
      @posts.each { |post| input(type: "checkbox", name: "ids[]", value: post.id) }

      span(**reactive_count("ids[]")) { "0" }                   # ticked count
      fieldset(**reactive_enable(if: { "ids[]" => { checked: 1.. } })) { bulk_fields }
      button(type: "button",
             **mix(reactive_enable(if: { "ids[]" => { checked: 1.. } }),
                   on_client(:click, js.submit("#bulk", submitter: "#delete-submit"),
                             confirm: "Delete the selected posts?"))) { "Delete" }
      button(type: "submit", name: "bulk_action", value: "delete", hidden: true, id: "delete-submit")
    end
  end
end
  • reactive_select_all(group) — the header box. Its edit ticks or unticks every owned box of the group and dispatches input + change on each one it flips (computes, shows and completions re-run). Its own state follows the group: checked when all are ticked, indeterminate when some are, unchecked when none are. Give it no name.
  • reactive_count(group) — the ticked count via textContent, change-guarded. Seed first paint in its block.
  • { checked: n } / { checked: 1.. } — a term in the shared conditions language: how many owned boxes of the group are ticked, with length:'s Integer and Range shapes. It works in reactive_show, reactive_enable, reactive_on_complete, reactive_show_targets and a conditional confirm:, and first paint evaluates it from reactive_values (an Array of checked values is counted; an Integer is the count).
  • reactive_enable(if:/if_any:/unless:) — reactive_show's sibling: the same conditions and first paint, but it flips the element's own disabled (a button, a fieldset).
  • Rows added later by a Turbo stream append — or removed — re-sync the header, the count, reactive_enable and reactive_show: a root with a group binding watches its subtree for added or removed checkboxes.
  • A nested reactive root's boxes are never counted or flipped by the outer root (#15 ownership).
  • The Delete button confirms, then submits this form through the hidden submitter, so the POST carries bulk_action=delete and the ticked ids[]. A cancelled confirm submits nothing.
  • js.check_group(group, checked = true) (#342) — "Clear selection ✕" from any control: it unticks (false) or ticks every owned box of the group the way the header does, then re-syncs the header, the count, the shows and the enables once. global: true (a trigger outside the owning root) flips the group in every reactive root that owns boxes of it. Actor-only: a broadcast refuses it. button(type: "button", **on_client(:click, js.check_group("ids[]", false))) { "✕" }
  • A form reset inside the root re-syncs the group bindings, so a type: "reset" button never leaves a stale count.

A bulk bar outside the list's root. When the list is its own reactive root (an action filters or paginates it), the page's bindings cannot see its boxes. The owning root declares the outside ids it drives instead, next to reactive_show_targets (#343):

div(**mix(reactive_root,
  reactive_show_targets("#bulk-bar" => { if: { "ids[]" => { checked: 1.. } } }),
  reactive_group_targets("ids[]",
    count:  "#bulk-count",                    # textContent = ticked count
    enable: { "#bulk-archive" => 1.. })))     # disabled = !(count in range)

# outside, first paint from the page's reactive_values:
span(id: "bulk-count") { reactive_group_target_attrs("ids[]", :count).to_s }
button(id: "bulk-archive", **reactive_group_target_attrs("ids[]", :enable, 1..)) { "Archive" }

Targets are id selectors only, resolved document-wide; an enable: value is an Integer, a Range or a conditions Hash. They re-sync on every change, a morph of the owning root, and rows added or removed. One call per root (the hash form takes several groups).

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, and a nested root's controls. Rich editors (#241) — a named lexxy-editor, trix-editor or bare [contenteditable] — are drafted and restored through their own value surface (the editor's value setter, a contenteditable's textContent; never innerHTML), and restore: :blank asks the editor whether it is empty. 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.