Example: client-only ops
Some interactions never need the server — tabs, a menu that closes on an outside click, an accessible drawer with a transition + focus. `on_client` runs a whitelist of DOM ops locally, with ZERO round trips and ZERO custom JavaScript.
Try it — nothing here hits the server#
Switch tabs, open the menu (then click anywhere outside to close it), open the drawer (it fades in and focus lands on its first button). Every one of these is a client op — no token, no POST, ever. Open the network tab: you will see nothing. The component declares no actions at all.
# frozen_string_literal: true
# Issue #95: a component whose ENTIRE interactivity is client-side on_client ops —
# tabs that switch panels, a menu that closes on any outside click, and an
# accessible drawer with a transition + focus op — with ZERO server round trips.
# It deliberately declares NO actions: there is no token-bearing trigger anywhere,
# and the system spec's fetch spy proves nothing is ever posted. The root carries
# the window-bound outside-close trigger permanently (a client op costs nothing per
# stray page click, unlike a server action).
class ClientTabsComponent < Phlex::HTML
include Phlex::Reactive::Component
def id = 'client-tabs'
def view_template
# The fade transition classes for the drawer's js.toggle(transition:). Scoped
# inline so the demo is self-contained (no global CSS needed).
style do
css = '.ct-fade{transition:opacity .2s ease} .ct-fade-from{opacity:0} .ct-fade-to{opacity:1} ' \
'#ct-panel-1,#ct-panel-2{padding:.75rem 0} .ct-tab.active{font-weight:600;color:var(--color-primary)}'
raw(safe(css)) # rubocop:disable Rails/OutputSafety
end
div(**mix(reactive_root, on_client(:click, js.hide('#ct-menu'), outside: true),
class: 'flex flex-col gap-4')) do
tabs
panels
menu
drawer
end
end
private
def tabs
div(role: 'tablist', class: 'flex gap-2 border-b border-base-300') do
tab_button(1, 'Overview', active: true)
tab_button(2, 'Details', active: false)
end
end
# One op chain per tab: hide every panel, show the picked one, restyle the tab
# buttons — the canonical "I had to write a Stimulus controller" case, now one
# line of declared client ops.
def tab_button(index, label, active:)
ops = js.hide('.ct-panel').show("#ct-panel-#{index}")
.remove_class('.ct-tab', 'active').add_class("#ct-tab-#{index}", 'active')
button(**mix(on_client(:click, ops),
id: "ct-tab-#{index}", class: ['ct-tab px-3 py-1', ('active' if active)].compact,
data: { testid: "tab-#{index}" })) { label }
end
def panels
div(id: 'ct-panel-1', class: 'ct-panel', data: { testid: 'panel-1' }) do
'The Overview panel — shown by default.'
end
div(id: 'ct-panel-2', class: 'ct-panel', hidden: true, data: { testid: 'panel-2' }) do
'The Details panel — revealed by a pure client op.'
end
end
def menu
div(class: 'flex flex-col gap-2') do
button(**mix(on_client(:click, js.show('#ct-menu')),
class: 'btn btn-sm w-fit', data: { testid: 'menu-open' })) { 'Open menu' }
div(id: 'ct-menu', hidden: true, class: 'rounded-box border border-base-300 p-3',
data: { testid: 'menu' }) { 'Menu content — click anywhere outside to close.' }
end
end
# Issue #96: a drawer opened with a TRANSITION (animated fade), an aria-expanded
# attr op on the trigger, and a FOCUS op landing on the first focusable control
# inside the drawer — the exact accessible-disclosure pattern, one op chain.
def drawer
open = js
.toggle('#ct-drawer', transition: { during: 'ct-fade', from: 'ct-fade-from', to: 'ct-fade-to' })
.set_attr(:root, 'aria-expanded', 'true')
.focus_first('#ct-drawer')
div(class: 'flex flex-col gap-2') do
button(**mix(on_client(:click, open),
id: 'ct-drawer-trigger', class: 'btn btn-sm w-fit',
data: { testid: 'drawer-open' })) { 'Open drawer' }
div(id: 'ct-drawer', hidden: true, class: 'rounded-box border border-base-300 p-3 flex gap-2',
data: { testid: 'drawer' }) do
button(class: 'btn btn-xs', data: { testid: 'drawer-first' }) { 'First action' }
button(class: 'btn btn-xs', data: { testid: 'drawer-second' }) { 'Second action' }
end
end
end
endHow it works#
on_client(:click, ops) binds a chain of DOM ops to an event and runs them locally through the one generic reactive controller. The ops are a frozen whitelist — show/hide/toggle, add_class/ remove_class/toggle_class, set_attr/toggle_attr/remove_attr (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, andfocus_first("#ct-drawer")moves focus to the first control inside — the exact accessible-disclosure pattern, one chain.
Modal dialogs#
A <dialog> opened with showModal() is a real modal: the browser puts it in the top layer, keeps Tab and Shift+Tab inside it, draws ::backdrop, closes it on Escape and puts focus back on whatever opened it. Two ops reach it (#365), and one marker covers a dialog the server renders open.
js.show_modal(to)callsshowModal(). A dialog already open non-modally is closed and reopened as a modal in the same task; one already modal is left alone; a target that is not a<dialog>is skipped (warned underverbose_errors/debug).js.close_dialog(to)callsclose(); a closed dialog is left alone.data: { reactive_modal: true }on adialog(open: …)the server renders — on a full page, in a reply, in a broadcast — makes it modal as soon as the client sees it: at connect, after a morph, and after a stream that brings a dialog. When a morph renders it withoutopen, the client closes it withclose(), so the page behind takes clicks and focus again.
button(**on_client(:click, js.show_modal("#picker"))) { "Choose…" }
dialog(id: "picker", aria_labelledby: "picker-title") do
h2(id: "picker-title") { "Choose an item" }
button(**on_client(:click, js.close_dialog("#picker"))) { "Close" }
end
# "Is it open?" as server state, rendered open by a reply or a broadcast:
# Escape fires `cancel`: bind it to an action so the server closes it too.
dialog(**mix(on(:cancel, event: "cancel"), id: "confirm", open: @confirming,
data: { reactive_modal: true })) { … }Both ops are actor-only, like focus: on_client, reply.js and a reducer's $ops may use them, broadcast_to(js:) refuses them — opening a modal on every subscriber's screen at once would steal every subscriber's focus. To show a dialog everywhere, broadcast a render with the dialog open and the marker.
A dialog opened by show_modal is client state, and a server re-render resets it like any other op: a morph of a root whose render has the dialog closed closes it. When an action inside the dialog re-renders its root, keep "open" on the server instead (open: @open plus the marker), and close it with an action — and bind the dialog's cancel event (Escape) to that action to keep the server in step.
Picked: nothing
# frozen_string_literal: true
# Issue #365: a real modal <dialog> with no custom JavaScript. The picker opens
# with js.show_modal and closes with js.close_dialog — the browser supplies the
# top layer, the focus trap, the backdrop, Escape and focus back on the opener.
# The confirmation keeps "is it open" as server state and renders the dialog
# open with data-reactive-modal, which the client upgrades to a modal one.
class ModalPickerComponent < Phlex::HTML
include Phlex::Reactive::Streamable
include Phlex::Reactive::Component
ITEMS = %w[Apple Banana Cherry].freeze
reactive_state :confirming
action :ask
action :cancel
def initialize(confirming: false)
@confirming = confirming
end
def id = 'modal-picker'
def ask
@confirming = true
reply.morph
end
def cancel
@confirming = false
reply.morph
end
def view_template
div(**mix(reactive_root, class: 'flex flex-wrap items-center gap-3')) do
button(**mix(on_client(:click, js.show_modal('#mp-picker')), class: 'btn btn-sm')) { 'Choose a fruit…' }
button(**mix(on(:ask), class: 'btn btn-sm btn-outline')) { 'Delete (asks the server)' }
p(class: 'text-sm') do
plain 'Picked: '
span(id: 'mp-status', class: 'font-semibold') { 'nothing' }
end
picker
confirmation
end
end
private
# The titles are not headings: a heading would land in the page's table of contents.
def picker
dialog(id: 'mp-picker', class: 'modal', aria_labelledby: 'mp-title') do
div(class: 'modal-box flex flex-col gap-3') do
p(id: 'mp-title', class: 'text-lg font-semibold') { 'Choose a fruit' }
ITEMS.each do |item|
button(**mix(on_client(:click, js.text('#mp-status', item).close_dialog('#mp-picker')),
class: 'btn btn-ghost justify-start')) { item }
end
button(**mix(on_client(:click, js.close_dialog('#mp-picker')), class: 'btn btn-sm')) { 'Close' }
end
end
end
# Escape fires the dialog's `cancel` event: bound to the cancel action, the
# server clears @confirming and its morph closes the dialog, so a later
# re-render never brings it back open.
def confirmation
attrs = { id: 'mp-confirm', class: 'modal', open: @confirming, aria_labelledby: 'mp-confirm-title',
data: { reactive_modal: true } }
dialog(**mix(on(:cancel, event: 'cancel'), attrs)) do
div(class: 'modal-box flex flex-col gap-3') do
p(id: 'mp-confirm-title', class: 'text-lg font-semibold') { 'Rendered open by the server' }
p { 'The reply morphed this dialog in with open and data-reactive-modal; the client made it modal.' }
button(**mix(on(:cancel), class: 'btn btn-sm')) { 'Cancel (the server closes it)' }
end
end
end
endMedia 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:)copiessrc,srcsetandaltfrom thefrom<img>onto theto<img>. Both are:rootor a selector, resolved with the op's own scoping (global: truereaches 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'ssrc,srcsetandalt. An<img>source withoutsrcis a hit with no image (#381): the target losessrcandsrcsetand takes the source'salt. A target that is not an<img>(aniframe,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 wayreactive_showreads a field: a text field gives its value, a radio group its checked value,reactive_scopeapplies.take: n(a positive Integer) keeps the first n characters as the key; omit it to use the whole value. The key looks uppool, 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 analtand nosrc(an entry with no logo file) is a hit: the preview drops its image andlabel:still gets the name.label:names a node whosetextContentbecomes the chosen image'salt, orblank_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 writesblank_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: :keepleaves both as they are. The preview carriesdata-reactive-media-state="pending|hit|miss", so a row can hide itself with CSS until there is an answer (the demo below uses Tailwind'shas-[[data-reactive-media-state=pending]]:invisible).match:is sent to the browser as a JavaScript pattern: its\Aand\zanchors are dropped (the client anchors it), and what JavaScript reads differently raises at render: options (i,m,x), escapes such as\h,\p{…},\sand\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 withdata: { 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.
# 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
endPreview 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 theimg'ssrc, and putsfile.namein thename:node (optional) astextContent. - An empty selection or a non-image file removes the
srcand clears the name. - The runtime keeps the last URL per
imgand revokes it on the next choice, on a clear, and when theimgleaves the page or a morph strips itssrc— no blob URL leaks. - The op carries no URL. There is no parameter for one, the client never reads a
urlkey, andset_attrofsrc(andsrcset, #373) stays refused (#96). Only an<img>ever gets asrc: aninto:that is anything else, or atothat is not aninput[type=file], warns and is skipped. to,into:andname:resolve inside the component's root by default (global: false).global: trueresolves all three document-wide, for animgoutside the root.- Actor-only like
paste_into:on_clientandreply.jsmay 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: truetreats an input whosevalidity.validis false as empty. A validator that callsinput.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-changedis the event for "the app changed this input's files without achangeevent". 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.
# 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
endAn 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
errortheimggetsdata-reactive-image-state="error". Withhide: true(the default) it is hidden; withshow_alt:itsaltis written (astextContent) into that node, which is shown.hide: falseonly sets the state, for CSS:img[data-reactive-image-state=error]. - On
loadthe state is"loaded". - A later
srctries again.copy_media,reactive_mediaandpreview_fileclear the error as they write thesrc; anysrcthat loads clears it onload; a morph that changes thesrclets the browser try the new one. A morph that keeps the samesrckeeps the state. - No
srcis not an error. Removing it (areactive_mediamiss, acopy_mediafrom a missing source) clears the state and shows theimg. - Only the
hiddenthe fallback added is removed: animgthe server renderedhiddenstays hidden. show_alt:is a selector resolved inside theimg's own reactive root, never a URL.hide:that is nottrue/false, or ashow_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 theimg, is ignored by the client.- The client listens once on the document (
errorandloaddo 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. Atoggle_classortoggleputs 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_classabove cancels the previous selection's keptadd_class— one item stays selected, not every item ever clicked. An op withoutkeep:leaves an earlier kept record alone: a panel shown withshow(…, keep: true)and closed with a plainhideopens again on the next morph. Close it withhide(…, 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 clientopsbuilder:ops.add_class("#item-2", "selected", { keep: true }). Withexpanded:, the trigger'saria-expandedis 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 }
endsubmitter: (#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 lengthExtra HTML attrs pass through unambiguously (conditions live in if:), so div(**reactive_show(if: { size: %w[l xl] }, class: "note")) works.
OR-of-AND without the distributive law. if_any: takes an array of AND-hashes — one level of disjunction, which covers every boolean visibility rule. This is the case that used to force hand-applied distributive law encoded as nested wrapper divs:
# visible while director OR (shareholder AND role == "individual")
div(**reactive_show(if_any: [
{ director: true },
{ shareholder: true, role: "individual" }
]))
# AND across fields, with negation, in one binding:
div(**reactive_show(if: { type: "individual" }, unless: { country: "domestic" }))First paint is computed for you. Declare reactive_values once and every binding whose fields are all provided renders the correct initial hidden: server-side — no per-section mirror method, no flash:
def reactive_values = { director: @director, role: @role, quantity: @qty }
# …then every reactive_show computes hidden: from these, automatically.reactive_scope :form lets bindings use bare symbols while the client resolves [name="form[field]"], and disable: true disables a hidden section's own controls so a switched-away value never submits. A blank or non-numeric field fails a numeric term closed (stays hidden) — the safe default. There is no expression surface: every term is a declared literal, the same default-deny posture as before.
A plain reactive_show is root-scoped by design. When the dependents live outside the control's root — a nav tab, a panel in another tab pane, a sidebar note — reactive_show_targets (#164) is the declared escape: the component that owns the field declares which outside ids it governs, spread on the root, using the same where-style values. Id selectors only (raise at render, warn-and-skip on the client — two-sided default-deny); a target id not on the page is skipped.
div(**mix(reactive_root, reactive_show_targets(:mode,
"#advanced-tab" => "advanced", # equals
"#premium-note" => %w[gold platinum]))) # membershipA "#id" key takes a full if:/if_any:/unless: conditions Hash (#209), so a cross-root target can read a combination of owned fields — the case that used to force a bespoke two-field JS listener. The fold matches an in-root reactive_show exactly (each term reads its own field; a missing owned field reads as blank — fail-closed), and target-keyed entries mix with field-keyed ones in the one call per root:
reactive_show_targets(
"#bulk-alert" => { if: { type: "company", quantity: 10.. } },
mode: { "#advanced-tab" => "advanced" }
)Conditional OPS — reactive_on_complete (#226). reactive_show decides visibility from a condition; reactive_on_complete runs a client-op chain on the condition's rising edge — once, when it first becomes true; going false re-arms; the connect/morph pass arms without firing, so a re-render with already-satisfied conditions never self-fires. Same if:/if_any:/unless: kwargs, run: takes a js chain (class-level js is available in the declaration):
reactive_state :code
action :verify, params: { code: :string }
reactive_on_complete if: { code: { length: 6 } }, run: js.dispatch("code:complete")
def view_template
div(**mix(reactive_root, on(:verify, event: "code:complete"))) do
input(name: "code")
end
endThat is a complete auto-committing verification-code field with zero JavaScript — the dispatch bubbles to the root's own on(:verify, event: "code:complete"), which turns completion into one signed action POST. run: js.submit commits the surrounding form instead. When completion needs normalization first (strip separators, cap length), put the condition in the reducer and use its $ops output — see the compute example.
# frozen_string_literal: true
# Issue #180: value-conditional visibility — reactive_show, the x-show /
# data-show equivalent, via the ONE conditions language (if:/if_any:/unless:).
# A Hash is an AND, an Array is membership, a Range is a threshold, unless:
# negates; the generic controller toggles `hidden` from the fields' CURRENT
# values with NO round trip. reactive_values computes each section's first-paint
# `hidden:` server-side — no per-section mirror method, no flash. Like
# ClientTabsComponent it declares NO actions: no token-bearing trigger anywhere.
class ConditionalFieldsetComponent < Phlex::HTML
include Phlex::Reactive::Component
def id = 'conditional-fieldset'
# First-paint truth: every reactive_show whose fields are all here computes
# its own initial `hidden:`.
def reactive_values
{ mode: 'off', gift: false, delivery: 'pickup',
type: 'individual', country: 'domestic',
director: false, shareholder: false, role: 'individual', quantity: 1 }
end
def view_template
div(**reactive_root(class: 'flex flex-col gap-4')) do
shipping_mode
gift_option
delivery_choice
compound_address
or_of_and_names
quantity_surcharge
end
end
private
# A <select> driving a dependent panel: visible WHILE mode != "off".
def shipping_mode
div(class: 'flex flex-col gap-2') do
label(class: 'flex items-center gap-2') do
span { 'Shipping' }
select(name: 'mode', class: 'select select-sm w-fit', data: { testid: 'mode' }) do
option(value: 'off', selected: true) { 'No shipping' }
option(value: 'standard') { 'Standard' }
option(value: 'express') { 'Express' }
end
end
# Extra attrs ride THROUGH reactive_show (it deep-merges via mix) — a bare
# `data:` beside the spread would clobber the binding.
div(**reactive_show(unless: { mode: 'off' },
class: 'rounded-box border border-base-300 p-3',
data: { testid: 'mode-details' })) do
'Shipping details — visible while the select is not "No shipping".'
end
end
end
# A checkbox: its .value is the constant "on", so the binding compares the
# CHECKED state — `gift: true` is the checkbox form.
def gift_option
div(class: 'flex flex-col gap-2') do
label(class: 'flex items-center gap-2') do
input(type: 'checkbox', name: 'gift', class: 'checkbox checkbox-sm', data: { testid: 'gift' })
span { 'This is a gift' }
end
div(**reactive_show(if: { gift: true },
class: 'rounded-box border border-base-300 p-3',
data: { testid: 'gift-note' })) do
'Gift message — visible while the checkbox is checked.'
end
end
end
# A radio group: the binding reads the CHECKED radio's value.
def delivery_choice
div(class: 'flex flex-col gap-2') do
div(class: 'flex gap-4') do
label(class: 'flex items-center gap-2') do
input(type: 'radio', name: 'delivery', value: 'pickup', checked: true,
class: 'radio radio-sm', data: { testid: 'delivery-pickup' })
span { 'Pickup' }
end
label(class: 'flex items-center gap-2') do
input(type: 'radio', name: 'delivery', value: 'ship',
class: 'radio radio-sm', data: { testid: 'delivery-ship' })
span { 'Ship' }
end
end
div(**reactive_show(if: { delivery: 'ship' },
class: 'rounded-box border border-base-300 p-3',
data: { testid: 'address' })) do
'Shipping address — visible while the "Ship" radio is checked.'
end
end
end
# Issue #180: a COMPOUND if:/unless: across TWO fields — one flat binding,
# visible only while type == "individual" AND country != "domestic".
def compound_address
div(class: 'flex flex-col gap-2') do
div(class: 'flex gap-4') do
label(class: 'flex items-center gap-2') do
span { 'Type' }
select(name: 'type', class: 'select select-sm w-fit', data: { testid: 'type' }) do
option(value: 'individual', selected: true) { 'Individual' }
option(value: 'company') { 'Company' }
end
end
label(class: 'flex items-center gap-2') do
span { 'Country' }
select(name: 'country', class: 'select select-sm w-fit', data: { testid: 'country' }) do
option(value: 'domestic', selected: true) { 'Domestic' }
option(value: 'foreign') { 'Foreign' }
end
end
end
div(**reactive_show(if: { type: 'individual' }, unless: { country: 'domestic' },
class: 'rounded-box border border-base-300 p-3',
data: { testid: 'intl-address' })) do
'International address — visible while Individual AND not Domestic.'
end
end
end
# Issue #180: OR-of-AND (the distributive-law killer) — visible while
# director OR (shareholder AND role == "individual"). ONE if_any: binding, no
# nested wrapper divs.
def or_of_and_names
div(class: 'flex flex-col gap-2') do
div(class: 'flex gap-4 items-center') do
label(class: 'flex items-center gap-2') do
input(type: 'checkbox', name: 'director', class: 'checkbox checkbox-sm', data: { testid: 'director' })
span { 'Director' }
end
label(class: 'flex items-center gap-2') do
input(type: 'checkbox', name: 'shareholder', class: 'checkbox checkbox-sm', data: { testid: 'shareholder' })
span { 'Shareholder' }
end
select(name: 'role', class: 'select select-sm w-fit', data: { testid: 'role' }) do
option(value: 'individual', selected: true) { 'Individual' }
option(value: 'company') { 'Company' }
end
end
div(**reactive_show(if_any: [{ director: true }, { shareholder: true, role: 'individual' }],
class: 'rounded-box border border-base-300 p-3',
data: { testid: 'name-fields' })) do
'Name fields — visible while Director OR (Shareholder AND Individual).'
end
end
end
# Issue #180: a NUMERIC threshold — reveal while quantity >= 10 (a Range).
def quantity_surcharge
div(class: 'flex flex-col gap-2') do
label(class: 'flex items-center gap-2') do
span { 'Quantity' }
input(type: 'number', name: 'quantity', value: '1',
class: 'input input-sm w-24', data: { testid: 'quantity' })
end
div(**reactive_show(if: { quantity: 10.. },
class: 'rounded-box border border-base-300 p-3',
data: { testid: 'surcharge' })) do
'Bulk surcharge applies — visible while quantity ≥ 10.'
end
end
end
endBulk 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
endreactive_select_all(group)— the header box. Its edit ticks or unticks every owned box of the group and dispatchesinput+changeon 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 noname.reactive_count(group)— the ticked count viatextContent, 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, withlength:'s Integer and Range shapes. It works inreactive_show,reactive_enable,reactive_on_complete,reactive_show_targetsand a conditionalconfirm:, and first paint evaluates it fromreactive_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 owndisabled(a button, a fieldset).- Rows added later by a Turbo stream
append— or removed — re-sync the header, the count,reactive_enableandreactive_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=deleteand the tickedids[]. 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
resetinside the root re-syncs the group bindings, so atype: "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" }
endSpread on the root, once. The controller writes a snapshot of every owned control on input (trailing-edge debounce, debounce: ms) and immediately on change, and flushes a pending write on disconnect so a fast Turbo visit never loses the last keystrokes. It restores on connect — first among the client bindings, so a reactive_show section, an armed reactive_on_complete, a filter and a compute root all read the restored values on first paint with no synthetic events — and never re-restores over a morph (server truth). By default a draft lands only in a control the server rendered blank (restore: :blank): a 422 re-render's submitted values beat an older draft; restore: :always lets the draft win. It clears on a successful turbo:submit-end of the containing form, on ttl expiry, or via js.persist_clear — never on a successful reactive action by itself (chain reply.js(js.persist_clear)).
Never persisted: hidden/file/password/submit controls, anything carrying reactive_persist_skip, 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.
# frozen_string_literal: true
# Issue #239: reactive_persist — a client-only localStorage draft over the
# fields the root OWNS. A token-less ClientBindings form (no actions, no
# POST): typing is remembered across a reload and restored on the next
# connect, a reactive_show section re-evaluates from the restored value on
# first paint, the honeypot / hidden / password controls never reach storage,
# "Next step" writes a state bag via js.persist_state and "Discard" forgets
# the draft via js.persist_clear. (The docs demo has no form to submit — in a
# real form a successful Turbo submit clears the draft automatically.)
class PersistFormComponent < Phlex::HTML
include Phlex::Reactive::ClientBindings
reactive_scope :apply
def reactive_values = { size: nil }
def view_template
div(**mix(reactive_root(id: 'persist-form', class: 'space-y-3'),
reactive_persist(key: 'docs-apply', ttl: 1.hour, debounce: 100))) do
input(**reactive_field(:name, type: 'text', placeholder: 'Your name', class: 'input input-bordered w-full'))
textarea(**reactive_field(:bio, placeholder: 'Tell us about yourself', class: 'textarea textarea-bordered w-full'))
div(class: 'flex gap-4') do
label(class: 'label cursor-pointer gap-2') do
input(**reactive_field(:size, type: 'radio', value: 's', class: 'radio'))
plain 'Small'
end
label(class: 'label cursor-pointer gap-2') do
input(**reactive_field(:size, type: 'radio', value: 'l', class: 'radio'))
plain 'Large'
end
label(class: 'label cursor-pointer gap-2') do
input(**reactive_field(:gift, type: 'checkbox', class: 'checkbox'))
plain 'Gift'
end
end
div(**reactive_show(if: { size: 'l' }, class: 'alert alert-info')) { 'Large surcharge applies' }
# Never persisted: the honeypot (explicit skip), a hidden input, a password.
input(name: 'fuckery', type: 'text', hidden: true, tabindex: '-1', **reactive_persist_skip)
input(type: 'hidden', name: 'apply[tz]', value: 'UTC')
input(**reactive_field(:secret, type: 'password', placeholder: 'Password (never stored)',
class: 'input input-bordered w-full'))
div(class: 'flex gap-2') do
button(**mix(on_client(:click, js.persist_state(step: 2)), class: 'btn btn-primary btn-sm')) { 'Next step' }
button(**mix(on_client(:click, js.persist_clear), class: 'btn btn-ghost btn-sm')) { 'Discard draft' }
end
p(class: 'text-sm opacity-70') { 'Type something, reload the page — the draft comes back.' }
end
end
endThe zero-fetch contract#
Because the component declares no actions and every trigger is on_client, a click here never mints a token and never posts. That is the tested contract: the browser suite spies on fetch and asserts it is called zero times across every tab switch, menu toggle, and drawer open.