phlex-reactive
Reactive Phlex components for Rails β Livewire-style actions and live cross-tab updates, without writing Stimulus controllers or hand-picking Turbo Stream targets.
π Full documentation
class Counter < ApplicationComponent
include Phlex::Reactive::Component # pulls in Streamable too
reactive_state :count
action :increment
action :decrement
def initialize(count: 0) = @count = count
def id = "counter"
def increment = @count += 1
def decrement = @count -= 1
def view_template
div(**reactive_root) do
(**on(:decrement)) { "β" }
span { @count }
(**on(:increment)) { "+" }
end
end
end
That's the whole counter. No Stimulus controller. No .turbo_stream.erb. No
route. No hand-picked target. Click + and the count updates in place.
Why
Stimulus + Turbo are powerful but tedious. A single interactive widget means a
Stimulus controller, a data-* soup, a .turbo_stream.erb view, a controller
action, and a hand-picked dom_id target β repeated for every feature. The
mental model is "wire everything by hand."
phlex-reactive borrows the mental model that makes Livewire and Phoenix LiveView pleasant β a component has state and actions; change state and the UI follows β and implements it the Rails way:
- Actions are Ruby methods. Declare
action :increment; the client calls it. - Re-render is auto-targeted. A component owns a stable
id; the response is a<turbo-stream>that replaces it. You never pick a target. - The same unit re-renders for clicks AND broadcasts. A click and a background broadcast both produce "replace the component by its id," so live cross-tab updates are the same mechanism as local interactivity.
- State lives in your database, not the browser. The DOM carries only a signed identity (a record's GlobalID), not a snapshot of state β so there's no mass-assignment surface and no re-signing protocol.
- One tiny client runtime. A single generic Stimulus controller, registered once, handles every reactive component. You don't write per-feature JS.
Pair it with pgbus and your live updates become transactional (no broadcast for a rolled-back change) and reconnect-safe (missed messages replay) over Postgres SSE β no Action Cable, no Redis.
Installation
# Gemfile
gem "phlex-reactive"
bundle install
Then run the installer β it registers the client controller and writes a config initializer:
bin/rails generate phlex:reactive:install
That's all for importmap apps: the engine mounts the action endpoint at
/reactive/actions and auto-pins (and preloads) the client runtime, and the
installer adds the eager registration below to your Stimulus entrypoint.
Verify the whole install any time with the doctor β it checks the route, the
Stimulus registration, CSRF, the identity verifier, and every component, printing
β/β/? with a fix for each failure:
bin/rails phlex_reactive:doctor
What the installer wires (or do it by hand)
// app/javascript/controllers/index.js
import { application } from "controllers/application"
import ReactiveController from "phlex/reactive/reactive_controller"
application.register("reactive", ReactiveController)
Register eagerly (not lazily) so a click immediately after load is never missed.
Scaffold a component
# state-backed (record-less)
bin/rails generate phlex:reactive:component Counter increment decrement
# record-backed (signed GlobalID identity)
bin/rails generate phlex:reactive:component Todos::Item toggle rename --record todo
Generates the component (and an RSpec spec if your app uses RSpec).
esbuild / webpack / bun
Import and register it from your controllers entrypoint:
import { application } from "./application"
import ReactiveController from "phlex/reactive/reactive_controller"
application.register("reactive", ReactiveController)
The gem ships a prebuilt, minified reactive_controller.min.js (~22 KB vs the
~106 KB commented source) with a linked sourcemap, and auto-pins it for importmap
apps β so browsers load the small file while devtools still shows the real code.
Point your bundler at the gem path or copy the .min.js (+ its .map) in. See
docs/installation.md.
Requirements: Rails 7.1+, Phlex 2 (phlex-rails), Turbo 8+ (for morphing),
and a Phlex ApplicationComponent base class. pgbus is optional but recommended
for broadcasting.
Integration troubleshooting (silent "nothing happens")
Two host-app setups make the first reactive component silently do nothing β
components render, but no action ever fires, with no error pointing at the cause.
Run bin/rails phlex_reactive:doctor first β it flags both of these (and more)
with the fix inline. The gem also logs a warning for each at boot; here are the
fixes:
A catch-all route shadows POST /reactive/actions. The engine appends its
route after everything in your config/routes.rb, so a bottom-of-file
catch-all wins and every reactive POST 404s:
# config/routes.rb β a catch-all like this shadows the engine's appended route
match "*path", to: "errors#not_found", via: :all
Exempt the reactive path from the catch-all (or set
Phlex::Reactive.action_path to an unshadowed path):
match "*path", to: "errors#not_found", via: :all,
constraints: ->(req) { !req.path.start_with?("/reactive/") }
At boot the gem warns ([phlex-reactive] POST /reactive/actions does not resolve to phlex/reactive/actions β¦) when the route is shadowed.
The reactive controller isn't registered (lazyLoadControllersFrom apps).
lazyLoadControllersFrom("controllers", application) only registers controllers
under app/javascript/controllers/. The gem's controller lives outside that dir,
so data-controller="reactive" does nothing until you register it explicitly:
// app/javascript/controllers/index.js (or your Stimulus entrypoint)
import ReactiveController from "phlex/reactive/reactive_controller"
application.register("reactive", ReactiveController)
If reactive elements are on the page but the controller never connected, the
runtime logs a console warning ([phlex-reactive] found N element(s) with data-controller="reactive" but the reactive controller never connected β¦).
Debugging & tooling
Four read-only introspection surfaces answer "what's reactive, where is it defined, is it authorized, and what does this page POST?" β plus an installable Claude Code toolkit. See the Debugging & tooling guide for the full workflow.
bin/rails phlex_reactive:doctor # validate the whole install (β/β/? + a fix each)
bin/rails phlex_reactive:actions # every component Γ action: params, file:line, auth
bin/rails "phlex_reactive:find[counter]" # fuzzy-find one; prints each action's method source
bin/rails phlex_reactive:mcp # a read-only MCP server (needs `gem "mcp"`)
In the browser console, map every reactive root + trigger on the page back to its
server Component#action names (a standalone module β zero cost until imported):
(await import("phlex/reactive/inspect")).report()
Install the debugging skill + MCP config for Claude Code in one command:
rails g phlex:reactive:claude
The mental model in one picture
βββ click / input βββββββββββββββββββββββββββββββββββββββββββ
β βΌ
[ button(**on(:increment)) ] POST /reactive/actions { token, act, params }
β² β
β verify signed token (no state trusted)
β rebuild component (record from DB)
β run the whitelisted action
β re-render β <turbo-stream replace id> (default; an action
β may return reply.<verb> β see "Controlling the action's reply")
βββββββββ Turbo applies it in ββββββββββββββββββββββββββββββββ
...and for OTHER tabs/users:
model change β Component.broadcast_replace_to(stream) β pgbus SSE β same morph
Client actions and server broadcasts converge on one re-render unit: the
component, targeted by its id.
Quickstart: a live, cross-tab counter
# app/components/counter.rb β see the top of this README for the full class
render Counter.new(count: 0)
Open the page in two tabs, click + β done. To make it update across tabs when
the underlying record changes, use a record-backed component (below).
Two kinds of reactive component
1. Record-backed (the common case)
State lives in an ActiveRecord row. The signed identity is the record's GlobalID; the server re-finds it on each action. Always prefer this.
class Todos::Item < ApplicationComponent
include Phlex::Reactive::Component
reactive_record :todo # identity AND the default #id: dom_id(@todo)
action :toggle
action :rename, params: { title: :string }
def initialize(todo:) = @todo = todo
def toggle
@todo, :update? # YOU authorize β the token only proves identity
@todo.toggle!(:done)
end
def rename(title:)
@todo, :update?
@todo.update!(title:)
end
def view_template
li(**reactive_root(class: ("done" if @todo.done?))) do
(**on(:toggle)) { @todo.done? ? "β" : "β" }
span { @todo.title }
end
end
end
One include, default
#id(issue #81).include Phlex::Reactive::Componentpulls inStreamableautomatically (the explicit two-include form still works and is a harmless no-op). A record-backed component also gets#idfor free βdom_id(record), exactly the id nearly every one wrote by hand β sodef idis only needed to override it, and an explicitdef idalways wins. Caveat: two different component classes rendering the same record on one page both default to the samedom_idand collide β give one an explicit prefixed id:def id = dom_id(@todo, "rich"). State-backed components still must define#id(they're frequently multi-instance, so a class-name default would silently collide; the loudNotImplementedErrorstays).
2. State-backed (signed instance vars)
Sign small, JSON-serializable instance vars into the token. Use it alone for
a record-less widget (a counter, a wizard step), or alongside reactive_record
to carry transient UI state β which field, what mode β next to the row. Both the
record's GlobalID and the state are signed into one token and rebuilt on each
action. Keep state small and JSON-serializable.
reactive_state :count, :step # signed; rebuilt on each action
The inline edit example combines both: a
reactive_record :record plus reactive_state :attribute, :editing.
Concrete examples
| Example | What it shows |
|---|---|
| Counter | State-backed, the smallest reactive component |
| Payment split | Nested bracketed params, auto-collected siblings, and a live reactive_compute + reactive_text preview (#64β#67, #104) |
| Cross-tab chat | Record-backed action + pgbus broadcast β live sync across tabs/browsers |
| Live todo list | Per-row components, add/toggle/rename/archive, optimistic toggle + delete, Enter-to-add, broadcast on change |
| Inline edit + dirty tracking | Show β edit toggle plus an "Unsaved" badge + leave-guard, with zero shipped state |
| Notifications / badges | Pure broadcast β a background event pushes a re-render, plus a broadcast_js_to cross-tab pulse |
| Reactive collections | Add/remove rows + a running count + an empty state, declared once with reactive_collection, optimistic dismiss |
| Loading states | disable_with: + busy_on + aria-busy, with a latency toggle to make the pending window visible |
| Client-only ops | on_client tabs / outside-close menu / accessible drawer β zero fetches, zero custom JS |
| Failure surface | error_flash + data-reactive-error + dismiss_after: β what you get for free when an action fails |
| Team inbox (flagship) | The whole toolkit in one UI: collection rows, optimistic archive that reverts on failure, cross-tab broadcast, an on_client kebab, error flashes |
Every page renders its real reactive component inline (source read straight off the file), so the demo and the code can never drift. The Team inbox is the flagship β every feature composed into one believable UI.
API reference
Phlex::Reactive::Streamable
| Method | Use |
|---|---|
#id |
Stable DOM id == Turbo Stream target. Must match the root element's id. Record-backed components default to dom_id(record) (issue #81); everything else implements it (def id). An explicit def id always wins. |
.replace(model = nil, morph: false, **opts) |
<turbo-stream action=replace target=id> of a freshly built component; morph: true adds method="morph" |
.update(model = nil, morph: false, **opts) |
<turbo-stream action=update target=id> (inner-HTML update); morph: true adds method="morph" so Turbo morphs the inner HTML in place (issue #113) |
.append(target:) / .prepend(target:) / .remove |
The other Turbo Stream actions |
.broadcast_replace_to(*streamables, model:, morph: false) |
Broadcast a replace over the stream transport (pgbus SSE / Action Cable); morph: true morphs in place |
.broadcast_update_to(*streamables, model:, morph: false) |
Broadcast an inner-HTML update; morph: true morphs in place, so a peer editing the component keeps its focus/caret (issue #113) |
.broadcast_append_to(*streamables, target:, model:) / _prepend_ / _remove_ |
The other broadcast variants |
.broadcast_replace_to_each(stream_keys, model:, morph: false, exclude:, visible_to:) / _update_ / _append_ / _prepend_ / _remove_ |
Render once, fan out the same payload to K different stream keys β a per-tenant loop. K renders + K HMACs collapse to 1 + K cheap channel calls (~9.5Γ at K=10). Each key is a [record, :symbol] pair (or a bare string). Transport opts + morph: forward per key. Per-viewer visible_to: content stays render-per-call. See Broadcasting. |
#to_stream_replace / #to_stream_morph / #to_stream_remove |
Stream the already-built instance (used internally after an action / by reply); #to_stream_morph morphs in place |
#to_stream_update(morph: false) |
Inner-HTML update of the already-built instance; morph: true morphs in place (issue #113) |
Use in controllers: render turbo_stream: Counter.replace(counter).
Phlex::Reactive::Component
| Macro / helper | Use |
|---|---|
reactive_record :name |
Record-backed identity (GlobalID). State = the DB. Also defaults #id to dom_id(record). |
reactive_state :a, :b |
Signed instance-var identity. Standalone, or combined with reactive_record to sign transient UI state alongside the row. |
action :name, params: { x: :integer } |
Declare a client-invokable action + its param schema. Default-deny. |
mark_authorized! |
Inside an action: satisfy the verify_authorized guard after a bespoke check the interceptor can't see (a hand-rolled policy). Call it only after your check passes. |
skip_verify_authorized [ :a, :b ] |
Opt a component (bare) or specific actions out of the default-ON verify_authorized guard β for a genuinely public component (a counter, a client-only filter). |
reactive_root(**overrides) |
Spread onto the root element: emits the component id and reactive_attrs together, so the controller root always carries #id. Preferred over id: + reactive_attrs. **overrides (class:/data:) deep-merge. |
reactive_attrs |
Marks an element reactive + carries the signed token (no id). Spread alongside id: on the same element: div(id:, **reactive_attrs). Prefer reactive_root, which can't split them. |
on(:action, event: "click", **params) |
Spread onto a trigger element. Adds type=button for clicks. |
on(:action, event: "input", debounce: 300) |
Coalesce rapid events into one round trip after a quiet period (live-as-you-type). |
on(:action, event: "keydown.enter") |
Fire only on a specific key β Enter-to-submit / Escape-to-cancel β via Stimulus's native keyboard filter (event: passes straight through). See Keyboard triggers. |
on(:action, confirm: "Sure?") |
Gate a destructive trigger behind a confirmation. Defaults to window.confirm; override the dialog with setConfirmResolver. |
on(:search, listnav: "[role=option]") |
Add combobox keyboard navigation β Arrow keys move a client-side highlight, Enter picks (clicks the option's own trigger), Escape clears. See Combobox keyboard navigation. |
on(:close_menu, outside: true) |
Fire only for events outside this component's root (close-a-dropdown-on-outside-click). Window-bound; never preventDefaults, so links elsewhere keep navigating. |
on(:track, event: "scroll", window: true, throttle: 250) |
window: binds the trigger to the window (page-level scroll/resize); throttle: rate-limits leading-edge β first event fires, the rest drop until the window elapses. Mutually exclusive with debounce:. |
on(:toggle, optimistic: { checked: :keep }) |
Apply a reversible visual hint the instant the trigger fires (before the round trip); revert it if the action fails. Cosmetic only. See Optimistic hints. |
on(:save, disable_with: "Savingβ¦") |
Disable the trigger + swap its text while the action is pending (a disabled button also swallows a rapid double-click). Shorthand for loading: { disable: true, text: "Savingβ¦" }. See Loading states. |
on(:save, loading: { disable: true, class: "opacity-50", text: "β¦" }) |
Full loading form: disable:, a loading class: (on the trigger or a to: target), a text: swap. Reverts on settle. |
busy_on(:save) |
Mark any element so it carries data-reactive-busy only while save is in flight β a spinner styled with pure CSS, zero Ruby. See Loading states. |
on(:action, once: true) |
Fire at most once, then unbind (Stimulus's native :once). |
on_client(:click, js.toggle("#menu")) |
Client-only trigger: applies declared DOM ops with ZERO round trip β no token, no POST, ever. Takes the same window:/once:/outside: modifiers. See Client-only ops. |
js |
The immutable op builder behind on_client: show/hide/toggle (the hidden attribute, with an optional transition:), add_class/remove_class/toggle_class, set_attr/remove_attr/toggle_attr (allowlisted names), focus/focus_first, text (set textContent β XSS-safe), and dispatch β chainable. |
reactive_input(:param, **attrs) / reactive_select(:param, **attrs) |
Render a control already bound to an action param (no magic name:). |
reactive_field(:param, **attrs) |
The attribute hash behind the above β spread onto any control. |
reactive_text(:name, initial) |
Mirror a compute output (or a declared input) into a text node β a live preview heading, a character counter, "Hello, {name}" β via textContent (XSS-safe). The text sibling of reactive_field; carries no name, so it's never POSTed. See Client-side computes. |
reactive_show(if:/if_any:/unless:) |
Value-conditional visibility (the x-show/data-show case): spread onto the element to show/hide β it toggles hidden from the fields' current values, client-only, zero round trip. One conditions language: a Hash is an AND, an Array is membership, a Range is a threshold, if_any: is OR-of-AND, unless: negates. reactive_values computes first paint; disable: disables a hidden section's controls. See Value-conditional visibility. |
reactive_show_targets(:field, "#id" => value) |
Cross-root visibility: the component that owns the field declares which outside, id-allowlisted elements it governs (a nav tab, a panel in another pane) β the visibility parallel of mirror:. Spread on the root via mix(reactive_root, β¦), once per root β several fields go in one call via the hash form. The value uses the same where-style vocabulary ("advanced", %w[a b], 10..). Id selectors only (raise at render + client warn-skip); toggles hidden only. See Value-conditional visibility. |
reactive_filter(input:, option:, group: nil, empty: nil) |
Client-side option filtering for a preloaded combobox: spread onto the root β typing in the named input shows/hides the options by their data-reactive-filter-text haystack, zero round trips. Optional group: collapses an all-hidden group header; empty: reveals a no-matches node. See Client-side option filtering. |
reactive_listnav("[role=option]") |
The standalone combobox keyboard wiring (Arrow/Enter/Escape) for an input that fires no action β the preload-and-filter case. Same behavior as on(β¦, listnav:), minus the POST. |
reactive_compute :name, inputs: { title: :string, qty: :number }, outputs: |
Typed inputs: a :string reaches the JS reducer raw, a :number is coerced through Number. The array form (inputs: %i[a b]) stays all-numeric. |
reactive_compute :name, ..., mirror: { sum: "#summary-sum" } |
Cross-root text mirrors: paint a compute value into declared, id-allowlisted nodes outside the reactive root (a recap in another tab pane) via textContent β no bespoke listener. See Cross-root mirrors. |
reactive_root(track_dirty: true, warn_unsaved: true) / reactive_field(:param, dirty: true) |
Dirty tracking against the DOM's own defaultValue/defaultChecked/defaultSelected β no client state. Marks changed fields + the root data-reactive-dirty; warn_unsaved: arms a beforeunload/turbo:before-visit guard. Style with [data-reactive-dirty]. See Dirty-field tracking. |
nested_update!(:assoc, attrs) |
Map a nested param onto <assoc>_attributes with id preservation; update the record. |
reactive_collection :name, item:, container:, count:, empty:, size: |
Declare an add/remove-row list once; actions call reply.append/prepend/remove. See Reactive collections. |
reply.replace / .morph / .update / .remove / .redirect(url) / .with(*) / .js(ops) |
Return from an action to control the reply (flash, remove, redirect, multi-stream, server-pushed client ops). See Controlling the action's reply. |
reply.append(name, model) / .prepend(...) / .remove(name, model) |
Add/remove a row in a declared reactive_collection (row + count + empty-state in one reply). |
Param types: :string (default), :integer, :float, :boolean, :file,
:date, :datetime, :decimal. Anything not in the schema is dropped before
reaching your method. :date/:datetime parse ISO8601 (a value that won't
parse is dropped β the keyword default applies); :decimal parses through
BigDecimal (dropped on a non-numeric value). The schema is compiled once at
declaration: a typo'd type symbol (params: { count: :interger }) raises
Phlex::Reactive::UnknownParamType at class load, not silently coercing to a
string at click time.
Custom param types (Phlex::Reactive.param_type). Register your own type in
an initializer β the block receives the raw client value and returns the coerced
value, or Phlex::Reactive::ParamSchema::DROP to reject it (the keyword default
then applies, keeping the drop-don't-fabricate contract):
# config/initializers/phlex_reactive.rb
Phlex::Reactive.param_type(:money) do |value|
/\A\d+(\.\d{1,2})?\z/.match?(value.to_s) ? BigDecimal(value) : Phlex::Reactive::ParamSchema::DROP
end
# then, in any component:
action :charge, params: { amount: :money }
def charge(amount:) = @invoice.charge!(amount) # amount is a BigDecimal or unset
Register during boot only: the registry is frozen after initialization, so a
runtime param_type call raises. A schema referencing a registered type is
validated at declaration like any built-in.
File uploads (:file). Declare :file (or [:file] for multiple) to accept
an uploaded file in a reactive action β attach a document/receipt/image to the
record without dropping out to a bespoke controller. When the reactive root holds
a populated <input type="file">, the client sends the action as multipart
FormData (instead of JSON) β token + act as fields, scalar params as fields,
any nested/array params bracket-expanded into params[key][sub] /
params[key][index] fields (the same Rails-form shape, so a JSON body and a
multipart body coerce identically β #39), and the file(s) appended; the endpoint
coerces :file to the ActionDispatch::Http::UploadedFile, passed through
untouched. A non-file value sent to a :file param is dropped (the keyword
default applies β never a fabricated file). Token threading and the
re-render/morph are identical; only the request encoding changes when a file is
present.
reactive_record :document
action :upload, params: { file: :file, caption: :string } # single (has_one_attached)
action :upload_pages, params: { pages: [:file] } # multiple (has_many_attached)
def upload(file: nil, caption: nil)
@document.file.attach(file) if file
@document.update!(title: caption) if caption.present?
end
def view_template
form(**on(:upload, event: "submit")) do
input(type: "file", name: "file")
input(name: "caption")
(type: "submit") { "Upload" }
end
end
One multipart caveat:
FormDatacan't carry an empty array or hash, so on the multipart (file-present) path an empty[]/{}param is omitted and the action's keyword default applies β it does not arrive as an explicit empty collection the way it does over JSON. If you rely on sendingtags: []to clear a collection, send that action without a file (the JSON path). A non-empty nested/array param rides along fine next to a file.
Array & nested params. Wrap a type in an array for an array param, or a hash schema in an array for Rails-style nested attributes β so one reactive action can mirror a normal nested-attributes update instead of forcing a per-row component:
action :save, params: {
date: :string,
bank_account_ids: [:integer], # array of scalar
invoice_items_attributes: [ # array of hash
{ id: :integer, quantity: :float, price: :float, _destroy: :boolean }
]
}
def save(date:, bank_account_ids:, invoice_items_attributes:)
@invoice.update!(date:, bank_account_ids:, invoice_items_attributes:)
end
Nested coercion recurses per field, drops undeclared nested keys, and accepts an
array as either a JSON array or a Rails index hash ({ "0" => β¦, "1" => β¦ }).
Model-scoped form fields just work. A standard Rails Form(model: @invoice)
names its inputs invoice[date], invoice[status], β¦ and the client posts those
names verbatim. A nested schema matches them with zero field renaming β the
endpoint expands bracket notation before coercion, so invoice[date] nests under
invoice and invoice_items_attributes[0][qty] becomes the index-hash form
above:
action :save, params: {invoice: {date: :string, status: :string}}
# client posts { "invoice[date]": "β¦", "invoice[status]": "β¦" } β save(invoice: { date:, status: })
A flat schema silently drops bracketed names (issue #67). The schema must mirror the field names, not the conceptual params. Because the endpoint expands
invoice[date]to{ "invoice" => { "date" => β¦ } }before matching the schema, a flatparams: { date: :string }matches nothing β the top-level key is nowinvoice, notdate. There is no error: the action just receives its keyword defaults (datenever set). If your inputs are namedinvoice[β¦](anyForm(model:)-style form), nest the schema underinvoice:to match. When in doubt, read a field's realnameattribute and shape the schema to it.
Nested reactive components compose. A reactive component rendered inside
another is its own root β field collection stops at nested
data-controller="reactive" roots, so an outer action collects only its own
named inputs, never a nested component's. An invoice editor's save sees its
flat fields; each line-item row's quantity/price belong to that row's own
action. No name-disjointness workarounds required.
Debounced triggers (live-as-you-type). Pass debounce: (milliseconds) to
coalesce rapid events β typically keystrokes on an "input" trigger β into a
single action round trip fired after the quiet period, instead of one POST per
keystroke. A blur flushes a pending dispatch so the last edit is never dropped.
Omit debounce: for the immediate-dispatch default.
# Recompute a total live as the user types, without hammering the endpoint.
input(**mix(on(:update, event: "input", debounce: 300), name: "quantity", value: @item.quantity))
Event modifiers β outside:, window:, once:, throttle:. Four more
on(...) options cover the page-level trigger patterns that otherwise need a
hand-written Stimulus controller:
outside: truefires the action only for events whose target is outside this component's root β the close-a-dropdown-on-outside-click pattern. An event inside the root is a complete client-side no-op. Implieswindow:.window: truebinds the trigger to the window (Stimulus's native@window) for page-level events likescroll/resize. Window-bound triggers are neverpreventDefault-ed β a mounted dropdown must not kill link clicks elsewhere on the page β and skip the forcedtype="button".once: truefires at most once, then unbinds (Stimulus's:once).throttle: 250rate-limits leading-edge: the first event fires immediately, further events are dropped until the window elapses. The mirror ofdebounce:(trailing-edge) β passing both raisesArgumentError.
# A dropdown that closes itself on any click outside β no Stimulus controller.
div(**mix(reactive_root, on(:close_menu, outside: true))) do
(**on(:toggle_menu)) { "Menu" }
ul { } if @open
end
# Throttled page-scroll tracking.
div(**mix(reactive_root, on(:track, event: "scroll", window: true, throttle: 500)))
These four (like debounce:/confirm:/listnav:) are reserved keyword
names on on(...) β no longer usable as free action params.
Optimistic visual hints (optimistic:)
Every reactive action waits a full round trip for its visual change β and it's
worse than neutral for a checkbox: the client preventDefaults the trigger, so
an on(:toggle) checkbox never even flips until the morph arrives.
optimistic: gives Livewire's "flip it client-side, let the morph correct" (and
React's useOptimistic): a small, always-reversible, cosmetic vocabulary
applied the instant the trigger fires and reverted if the round trip fails.
Hints are visual only β never data, never a computed value (that would be client state the DOM can't be trusted to hold). Supported ops in the hint hash:
toggle_class:/add_class:/remove_class:β a class string or array, applied to the trigger by default, or to ato:selector scoped to the root (to: :roottargets the root element itself).checked: :keepβ for a click-bound checkbox/radio, the client skips its unconditionalpreventDefaultso the native flip happens now (a bare toggle click has no navigation default to lose).on(...)also skips the forcedtype="button"it normally adds to click triggers β that would destroy the very checkbox being toggled β so you supply the realtype="checkbox"/type="radio". On achange-bound control the flip is already native (changeisn't cancelable) β:keepthen only contributes the failure revert.hide: trueβ hides the target immediately.
# A checkbox that flips instantly; the label paints in the same gesture. The
# morph reconciles from server truth; a failure snaps both back.
input(type: "checkbox", checked: @todo.done,
**mix(on(:toggle, event: "change", optimistic: { checked: :keep, toggle_class: "is-done", to: ".status" }),
name: "done"))
# Instant delete: hide the row NOW, remove it on the reply.
(**on(:destroy, confirm: "Delete?", optimistic: { hide: true, to: :root })) { "Delete" }
The success/failure contract (load-bearing):
- On failure β any branch (
redirected/http/content-type/network, plus a client-sideapplythrow) β the client replays the exact inverse ops, guarded byisConnected(a detached row is skipped, it's gone anyway). The hint stored on the queued request, so the serialized per-controller queue reverts the right request's hint. - On success there is NO cleanup. A reply that re-renders the root
overwrites the hint with server truth. A reply that does not re-render
the root (
reply.remove, streams-only) leaves the hint standing β that's thehide: true+reply.removeinstant-delete recipe working as intended: the row hides, then removes, and never flashes back.
optimistic: (like debounce:/confirm:/throttle:) is a reserved keyword
name on on(...). An unknown hint op, a checked: value other than :keep,
or a non-hash raises at render β a dead hint fails loudly, never silently.
Declarative loading states (loading: / disable_with:)
Between the click and the morph, the user needs to see that something is
happening β and a mutating button needs to stop a rapid double-click from firing
twice. loading: / disable_with: are Livewire's wire:loading +
phx-disable-with and LiveView's phx-click-loading, without a Stimulus
controller. Both are reserved keyword names on on(...).
The moment a request is enqueued β covering the queue wait, not just the fetch β the trigger and root get the always-on busy vocabulary, and (if declared) the loading hint applies; everything reverts when the round trip settles (success or failure), guarded so a re-rendered trigger is never clobbered.
disable_with: β the common case. Disable the trigger and swap its text
while pending. A disabled button fires no further clicks, so a rapid double-click
enqueues exactly one POST (the queue only serializes tokens β it does not
dedupe; the disable is what dedupes):
(**on(:save, disable_with: "Savingβ¦")) { "Save" }
loading: β the full form. A hash of:
disable: trueβ disable the trigger while pending.class: "β¦"/[ β¦ ]β a loading class on the trigger, or on ato:selector scoped to the root (to: :roottargets the root element itself).text: "Savingβ¦"β swap the trigger'stextContentwhile pending.
(**on(:destroy, confirm: "Sure?", loading: { disable: true, class: "opacity-50 pointer-events-none" })) { "Delete" }
disable_with: "Savingβ¦" is the shorthand for { disable: true, text: "Savingβ¦" }
and, if you pass both, merges over an explicit loading: (its text/disable
win). An unknown loading key or a non-hash loading: raises at render.
The aria-busy + data-reactive-busy contract (always on β zero Ruby).
Independent of any loading: hint, every reactive round trip marks the DOM
for the whole enqueueβsettle window, so you can style spinners and dimming with
pure CSS:
- the trigger and the root carry
data-reactive-busy="<action>"(a space-separated set on the root, so two concurrent actions never clobber); - the root carries
aria-busy="true"(driven by a pending counter β it clears only when the last in-flight request settles, so overlapping actions don't clear it early); busy_on(:action)marks any element inside the root so it getsdata-reactive-busytoggled only while that action is in flight β a scoped spinner:
(**on(:save, disable_with: "Savingβ¦")) { "Save" }
span(**busy_on(:save), class: "spinner")
phlex-reactive ships no CSS for these β you own the styling. A minimal example (not shipped β copy into your app's stylesheet):
/* Reveal a busy_on / aria-busy spinner only while its action is in flight. */
.spinner { display: none; }
[data-reactive-busy] .spinner,
[data-reactive-busy].spinner { display: inline-block; }
/* Dim the whole component during any round trip. */
[aria-busy="true"] { opacity: 0.6; transition: opacity 120ms ease; }
The disable + text swap apply only at enqueue, never during a debounce:
quiet period β so a debounced input trigger (whose element is the text field)
is not disabled mid-typing. On settle the text is restored only if it still
matches what was swapped in; a morph that rendered a new server label is left
alone.
Dirty-field tracking (dirty: / track_dirty: + warn_unsaved:)
Show "unsaved changes", enable Save only when something changed, or warn
before navigating away β Livewire's wire:dirty β without shipping any client
state. The trick: the browser already holds the last server-rendered value with
zero extra bytes. input.defaultValue / defaultChecked / option.defaultSelected
are the attributes from the last render. So dirty = current β default, and
phlex-reactive reads that baseline straight from the DOM β nothing to snapshot,
nothing to sign.
div(**reactive_root(track_dirty: true, warn_unsaved: true)) do
input(**reactive_field(:title, value: @todo.title, dirty: true))
span(class: "unsaved-badge") { "Unsaved" } # shown via [data-reactive-dirty] CSS
(**on(:save, disable_with: "Savingβ¦")) { "Save" }
end
track_dirty: trueon the root wires every input under it to a full re-scan on change.dirty: trueon a singlereactive_fieldopts that one field in (use it when only some fields should count).- On each change the client re-scans every field the root owns in one pass and
marks:
- each changed field with
data-reactive-dirty="true", and - the root with
data-reactive-dirty="<count>"(the attribute is absent at zero β so[data-reactive-dirty]matches iff the form is dirty).
- each changed field with
- The re-scan is a full pass, not a per-field toggle β required for radio groups: deselecting a radio fires no event on the deselected one, so only re-scanning everything keeps its flag honest.
The CSS vocabulary (you own the styling β phlex-reactive ships none):
/* Reveal an "unsaved" badge only while the form has changes. */
.unsaved-badge { display: none; }
[data-reactive-dirty] .unsaved-badge { display: inline; }
/* Outline just the fields that changed. */
[data-reactive-dirty] { outline: 2px solid gold; }
/* Enable Save only when dirty (pair with a :disabled default). */
[data-reactive-dirty] button[data-testid="save"] { pointer-events: auto; opacity: 1; }
Baselines reset on the server's re-render. A plain replace re-connects the
controller (re-scan on connect); an in-place morph keeps the element
connected and fires no Stimulus lifecycle, so the client also re-scans on
turbo:morph-element after the morph writes fresh default* attributes. So a
reply.morph save renders the field with the new value as its new default,
and the badge clears with no reload. (Turbo 8 morph preserves a focused field's
in-progress value while writing the fresh defaults β the post-morph re-scan is
what keeps the root count honest in that state.)
warn_unsaved: true arms a navigate-away guard gated on the live dirty
count: beforeunload (a real browser unload) and turbo:before-visit (a Turbo
in-app navigation). A clean form never blocks. Caveats: browsers show their
own generic beforeunload copy (your message string is legacy and ignored), and
turbo:before-visit does not fire on restoration visits (Back/Forward) β a
documented Turbo gap, not a phlex-reactive one.
Out of scope (v1): rich-text / contenteditable editors have no default*
baseline and are not tracked. Combining reactive_field(dirty:) with your own
data:/on(...) still needs mix(...) (a bare merge clobbers the data-action
the descriptor rides on β the same rule as everywhere else).
Client-only ops (on_client + js) β zero round trips
Not every interaction needs the server. A tab switch, a dropdown, an accordion
β purely visual state β used to mean either a wasteful signed round trip or the
very Stimulus controller this gem exists to eliminate. on_client binds a DOM
event to a chain of declared DOM operations that the one generic controller
applies locally: no token, no params, no POST, ever.
def view_template
div(**mix(reactive_root, on_client(:click, js.hide("#menu"), outside: true))) do
# Tabs: one op chain per tab β hide all panels, show one, restyle the tabs.
(**on_client(:click, js.hide(".panel").show("#panel-2")
.remove_class(".tab", "active").add_class("#tab-2", "active"))) { "Tab 2" }
# A menu that opens client-side and closes on ANY outside click (the root
# carries the window-bound trigger above).
(**on_client(:click, js.show("#menu"))) { "Menu" }
div(id: "menu", hidden: true) { }
end
end
The js builder is immutable (each verb returns a new chain) and its
vocabulary is a fixed whitelist mirrored by the client: show/hide/toggle
flip the hidden attribute; add_class/remove_class/toggle_class take one
or more classes. Targets are CSS selectors resolved within the component's
root (nested reactive components are never touched β same ownership rule as
field collection); :root targets the root element itself; global: true on
an op escapes to the whole document. An op name the client doesn't recognize
logs a warning and is skipped β the rest of the chain still applies.
Attributes, focus, dispatch, and transitions. Beyond visibility and classes, the same chain covers the rest of the client-only vocabulary:
(**on_client(:click, js
.toggle("#menu", transition: %w[transition-opacity opacity-0 opacity-100])
.toggle_attr(:root, "aria-expanded") # accessible disclosure state
.focus_first("#menu") # move focus into the opened menu
.dispatch("app:menu-toggled", detail: { open: true }))) { "Menu" }
set_attr(to, name, value)/remove_attr(to, name)/toggle_attr(to, name)mutate an attribute. The attribute name is allowlisted, enforced twice β at build time in Ruby (an offending name raises) and again in the client interpreter (a hand-built op is warned and skipped). Refused: event handlers (on*β XSS), URL-bearing names (href,src,srcdoc,action,formaction,xlink:hrefβ ajavascript:navigation surface), andstyle(CSS injection β use classes). The intended surface is class ops plushidden,disabled,open,selected, and anyaria-*/data-*attribute.focus(to)focuses the first match;focus_first(to)focuses the first focusable descendant of the match (e.g. the first menuitem inside an opened menu).text(to, value)sets the target'stextContent(stringified;nilclears) β XSS-safe by construction, neverinnerHTML, strictly less powerful thanset_attr. Withglobal: trueit is the cross-root text escape: paint a value into a recap node outside the component's root (js.text("#sum_total", total, global: true)).dispatch(name, to: nil, detail: {})emits a bubblingCustomEventso another component or a plain Stimulus controller can react to a client-only interaction βto:picks the element (default: the component root),detail:is the payload.transition: [during, from, to]onshow/hide/toggleanimates the visibility flip:during+fromare applied, thenfromβtoswaps on the next frame, and the helper classes are cleaned up onanimationend(with a timeout fallback, so an element with no animation never leaves them stuck). The op chain is never blocked β later ops (afocus, adispatch) run immediately.
window:, once:, and outside: compose exactly like on(...)'s event
modifiers: the dropdown above closes on any click outside the component, and
window-bound triggers never preventDefault, so links elsewhere keep working.
Client ops are ephemeral UI β the one contract to internalize. Any server
re-render of the component (an action reply, a broadcast, a morph) rebuilds
from server state and resets whatever the ops toggled: the menu closes, the tab
snaps back. That is by design β the same caveat LiveView's JS commands carry.
For state that must survive a re-render (an edit mode, a selection the server
should know about), use a signed action instead; on_client is for state the
server should never care about.
Auto-collected sibling fields β the read contract. A reactive action doesn't
just receive its own trigger's value: the client gathers every named control
in the reactive root (input[name], select[name], textarea[name], and named
rich-text/contenteditable editors) and merges them under the action's params,
so one action reads the whole form. Explicit on(:act, x: β¦) params win over a
collected field of the same name; collection stops at nested reactive roots (see
Nested reactive components compose above). Two things worth pinning down:
- Timing β params reflect the DOM at dispatch, not a pre-event snapshot
(issue #65). Field values are read when the request is sent (after the
debounce quiet period, if any), so a
change/inputtrigger sees its own field's new value and every peer's current value. There is no capture of the values as they were before the interaction β if your computation needs a peer's prior value (e.g. a spill-back that folds an overflow into the edited field), that peer's current DOM value is the prior value only because nothing else has changed it yet. Read at dispatch time, trust the current DOM. - Disabled fields ARE collected (issue #66) β deliberately different from a
native form. A
<form>submit omitsdisabledcontrols; reactive collection does not checkdisabled, so a disabled field that carries a computed/display value (a read-onlytotalthe client keeps in sync) reaches the action. This is intentional β it's what makes "read a computed disabled field" work. If you need form-submit parity (drop the disabled value), give the control noname, or make itreadonlyinstead ofdisabledwhen you do want it collected by both paths.
Keyboard triggers (Enter-to-submit / Escape-to-cancel). event: is
interpolated straight into the Stimulus action descriptor, so any Stimulus event
string works β including its native keyboard filters. Pass event: "keydown.enter" to fire only on Enter, event: "keydown.esc" for Escape β the
classic "Enter adds the row", "Escape cancels the edit" interactions. The action
runs only on that key, not on every keypress β no client JavaScript, no
event.key check of your own, and no new option to learn (it's Stimulus's own
keyboard-filter syntax):
# Enter in the composer adds the todo (same action as the Add button).
input(**mix(on(:add, event: "keydown.enter"), name: "title", placeholder: "New todoβ¦"))
# Inline editor: Enter on the field saves; a separate control cancels on Escape.
input(**mix(on(:save, event: "keydown.enter"), name: "title", value: @todo.title))
(**on(:cancel, event: "keydown.esc")) { "Cancel" }
The filter tokens are Stimulus's (enter, esc, space, up, down, a bare
letter, β¦). Because a keyboard trigger isn't a click, it does not get the
type="button" a click trigger does. Folding the key into event: keeps key
free as an ordinary action-param name (on(:switch, key: "pgbus") still passes
key through as a param).
One action per element. Each trigger element carries a single reactive action (its
data-reactive-action-param), so you can't puton(:save, event: "keydown.enter")andon(:cancel, event: "keydown.esc")on the same input β the second would overwrite the first's action name. Bind each key trigger to its own element (the field saves on Enter; a Cancel button β or the field's own blur β handles Escape), as above.
Value-conditional visibility (reactive_show)
on_client covers the unconditional client-only interactions; the last gap
was show/hide from a form field's current value β the Alpine x-show /
Datastar data-show / Livewire wire:show case. reactive_show closes it with
ONE Ruby-native conditions language: spread it onto the element to show/hide and
declare an if: / if_any: / unless: condition with where-style values β
the generic controller toggles the hidden attribute from the fields' current
values on every input/change. Client-only, no token, no POST, ever:
def view_template
div(**reactive_root) do
select(name: "mode") { }
div(**reactive_show(unless: { mode: "off" })) { shipping_details }
input(type: "checkbox", name: "gift")
div(**reactive_show(if: { gift: true })) { }
select(name: "size") { }
div(**reactive_show(if: { size: %w[l xl] })) { surcharge_note } # membership
input(type: "number", name: "quantity")
div(**reactive_show(if: { quantity: 10.. })) { bulk_note } # threshold
end
end
- The value language: a Hash is an AND (multiple keys ANDed), an
Array is membership, a Range is a threshold (
10..β₯ 10,..10β€ 10,...10< 10,10..20between),true/falsecompare a checkbox's checked state,nilmatches blank.unless:negates and composes withif:. Never an expression β every term is a declared literal, so there is no eval surface. A blank/non-numeric value fails a numeric term closed (hidden). - OR-of-AND β
if_any:takes an array of AND-hashes (if_any: [{ director: true }, { shareholder: true, role: "individual" }]), sodirector || (shareholder && individual)is one flat binding, no nested wrapper divs, no hand-applied distributive law. - First paint is computed for you β declare
reactive_valuesonce (def reactive_values = { mode: @order.mode, gift: @order.gift? }) and every binding whose fields are all provided renders the correct initialhidden:server-side. No per-section mirror method, no flash. An explicithidden:always wins; a per-callvalues:override merges overreactive_values. reactive_scope :formlets bindings andreactive_valuesuse bare symbols while the client resolves[name="form[field]"].disable: truedisables a hidden section's own controls so a switched-away value never submits (the stale-value fix).- Field reads follow the collection rules: a checkbox compares its checked
state; a radio group reads the checked radio's value; everything else
reads
.value. Ownership is the usual rule β a nested reactive component's fields and bindings belong to the nested component. - Composes with computes: a
reactive_computeoutput write dispatches a realinputevent, so a derived value can drive visibility too. - Presentational only, strictly weaker than the js ops: it reads owned fields
and toggles
hidden(+ optionallydisabled) on owned elements β noinnerHTML, no attribute freedom. Cross-root writes take the escape below.
Cross-root targets (reactive_show_targets). A plain reactive_show is
root-scoped by design β but "a mode selector reveals dependent sections
elsewhere on the page" (a nav tab, a panel in a different tab pane, a sticky
sidebar note) routinely puts the dependents outside the control's root.
reactive_show_targets is the declared escape, the visibility parallel of the
cross-root text mirror:
the component that owns the field declares which outside ids it governs,
spread on the root, using the same where-style values:
div(**mix(reactive_root, reactive_show_targets(:mode,
"#advanced-tab" => "advanced", # equals
"#advanced-panel" => "advanced",
"#premium-note" => %w[gold platinum]))) do # membership
select(name: "mode") { }
# β¦
end
Same posture as mirror:: opt-in and declared, never implicit β a plain
reactive_show stays root-isolated; targets are single id selectors only
(a class/compound selector raises at render AND is warn-and-skipped by the
client β two-sided default-deny); values use the same vocabulary; and the
toggle is hidden only. The field read stays owned β you can only drive
outside visibility from a field the declaring root owns. A target id not on the
page is silently skipped, so a target inside an unrendered tab pane is fine.
One call per root. Phlex mix space-joins duplicate string data:
values, so a second reactive_show_targets call on the same root would
concatenate two JSON payloads into an unparseable attribute (the client warns
and ignores it). Several fields go in one call via the hash form:
reactive_show_targets(mode: { "#advanced-tab" => "advanced" },
kind: { "#premium-note" => %w[gold platinum] })
Client-side computes (reactive_compute + reactive_text)
Some math should feel instant with no round trip β a NEW, unsaved record's
running total, a live title preview, a character counter. reactive_compute
declares a client-side reducer (a plain JS function registered once) that runs on
input and writes derived values straight into the DOM. When the component also
carries on(...), the debounced POST still fires and the server reply reconciles
β the compute just paints first.
reactive_compute :preview,
inputs: { title: :string, qty: :number }, # typed: :string raw, :number β Number
outputs: %i[title_preview char_count] # written with no round trip
div(**mix(reactive_root, reactive_compute_attrs(:preview))) do
input(**mix(reactive_field(:title, value: @post.title),
data: { action: "input->reactive#recompute" }))
h2 { reactive_text(:title_preview, @post.title) } # a text-node output
small { reactive_text(:char_count) } # another text-node output
end
// Register the reducer once at boot. Its signature is (values, { changed }).
import { setComputeReducer } from "phlex/reactive/compute"
setComputeReducer("preview", ({ title }) => ({
title_preview: title, char_count: `${title.length}/80`,
}))
- Typed inputs.
inputs:takes a hash to type each input: a:numberis coerced throughNumber(blank/NaN β 0 β the array-form default), a:stringreaches the reducer raw so a live text preview reads real text. The array form (inputs: %i[a b]) stays all-numeric and byte-identical on the wire. reactive_text(:name, initial)mirrors a value into a text node viatextContent(XSS-safe by construction). An output whose name matches an owned form field writes that field's.value; an output with no matching field writes every owned[data-reactive-text="<name>"]node. It carries noname, so it's never collected or POSTed as a param.- Reducer-less mirrors. A declared input also mirrors into its own
reactive_text(:same_name)node on every keystroke with no reducer at all β soreactive_text(:title)is a live field echo out of the box. - Seed the server render. Your
view_templatemust seed each mirror with the same derived value the reducer would (reactive_text(:char_count, "5/80")), or a later morph repaints stale text β the same reconcile contract the whole new-vs-persisted split relies on.
Cross-root mirrors (mirror:) β painting a recap outside the root
reactive_text is deliberately root-isolated (a nested component's nodes are
never touched β issue #15's ownership rule). But a derived value often needs to
show up in a text node that isn't inside the computing root at all: a read-only
recap in another tab pane, a sticky footer total. Collapsing two components into
one form-wide root just for a display mirror would be a large, risky restructure β
so the component declares the escape instead:
reactive_compute :split,
inputs: %i[a b total],
outputs: %i[a b],
mirror: { sum_a: "#sum_a", sum_total: ["#sum_total", "#footer-total"] }
On every compute pass, each declared mirror name is painted into its
document-wide id target(s) via textContent. The value comes from wherever the
pass produced it β one declaration covers all three shapes:
- a reducer-result key (
sum_total:above β an extra, text-only output the reducer returns alongside its real outputs), - a just-written output's settled field value,
- a declared input's identity value (works with no reducer at all, like the owned-text-node identity mirror).
A name the pass produced no value for is skipped β a mirror never blanks a recap. The security posture matches the rest of the library's default-deny:
- Opt-in and declared, never implicit. Only the selectors in the
mirror:map are ever written β a plainreactive_textnode stays root-isolated. - Id selectors only. A class/attribute/
*/compound selector raises at declare time, and the client interpreter re-checks the same shape (warn-and-skip) β a hand-built attr can't widen a declared mirror into a page-wide selector write. textContentonly, neverinnerHTMLβ XSS-safe by construction, and never a field/attribute/style write.
For a server-driven cross-root paint (from an action reply or a broadcast),
use the text op instead: reply.js(js.text("#sum_total", total, global: true)).
Combobox keyboard navigation (listnav:)
A searchable list needs Arrow keys to move a highlight, Enter to pick, Escape to
close β interactions that are ephemeral client UI state (a highlight per
keystroke would be absurd as a server round trip). Pass listnav: (a CSS
selector for the option elements) to a search trigger and the generic controller
handles all of it client-side, with no bespoke Stimulus controller:
# The search input: debounced live search + keyboard list navigation.
input(**mix(
on(:search, event: "input", debounce: 200, listnav: "[role=option]"),
name: "query", value: @query
))
# Each option is BOTH a listnav target (role=option) and its own reactive
# select trigger β Enter just clicks the highlighted one.
(**mix(on(:select, name: opt), role: "option")) { opt }
listnav: appends Stimulus's native keyboard filters
(keydown.down/up/enter/esc) to the input's data-action. Arrow Up/Down move a
data-reactive-highlighted marker among the options with no round trip;
Enter clicks the highlighted option β so selection runs through its normal
on(:select) reactive action (signed, default-deny, authorized like any other);
Escape clears the highlight. Only the highlight is client-side β the selection
stays a real signed action, and the highlight is never shipped as trusted state.
Client-side option filtering (reactive_filter)
listnav: is the keyboard half of a combobox; reactive_filter is the other
half: preload the options, type to narrow β zero round trips. For a small,
static catalog a server search per keystroke is pure latency (the data was
already known at first paint), and a bespoke hide/show Stimulus controller is
exactly the per-feature JS this gem exists to remove. Declare the filter on the
root instead:
div(**mix(reactive_root, reactive_filter(
input: "#exercise-search", # the input whose value drives the filter
option: "[role=option]", # the elements to show/hide
group: "[data-filter-group]", # optional: collapse a header when all its options hide
empty: "#no-matches" # optional: reveal when 0 options match
))) do
# STANDALONE keyboard nav β no action on the input, so typing never POSTs.
input(id: "exercise-search", type: "search", **reactive_listnav("[role=option]"))
categories.each do |category, exercises|
div(data: { filter_group: "" }) do
h3 { category }
exercises.each do |exercise|
# The haystack is server-rendered β pack in synonyms/categories.
(**mix(
on(:select, id: exercise.id),
role: "option",
data: { reactive_filter_text: exercise.search_text }
)) { exercise.name }
end
end
end
div(id: "no-matches", hidden: true) { "No matches" }
end
On every keystroke the generic controller lowercases the input's value and
toggles hidden on each option by a substring match against its
data-reactive-filter-text haystack (falling back to the option's own text) β
a declared literal match, never an expression, so there is no eval surface. A
group whose every contained option is hidden collapses with them; the empty
node reveals at 0 visible. Everything resolves within this root only and
re-applies after a morph.
It composes: reactive_listnav gives the input Arrow/Enter/Escape without an
action (Arrow keys skip filtered-out options; on(β¦, listnav:) requires a
dispatching trigger β wrong for an input that must never POST), and each option
stays its own signed on(:select) trigger. Only filtering is client-side β
selection still round-trips as a real signed action. Blank selectors raise at
render: a dead binding must fail loudly, not no-op in the browser.
Combining on(...) / reactive_attrs with your own attributes. Both return
a hash that includes a data: key. Spreading them and passing another data:
(or class:, id:) would clobber it β use Phlex's mix to deep-merge. For the
root, prefer reactive_root, which already mixes id + token for you:
# β
merges cleanly (data-action survives, your data-testid/class are added)
button(**mix(on(:increment), class: "btn", data: { testid: "inc" })) { "+" }
div(**reactive_root(class: "card", data: { testid: "root" })) { ... } # id + token + your attrs
# β the extra data: overwrites on()'s data:, so the action never binds
button(**on(:increment), data: { testid: "inc" }) { "+" }
The reactive root must carry
#id(issue #48). The server targets your component's#idand the client self-matches its next signed token by the root element'sid.reactive_attrsdoes not emit the id β so if you putid:on a child instead of the**reactive_attrselement, the root's id is empty, token threading falls back to the first token in the response, and the next action silently fails with HTTP 403. Usediv(**reactive_root)(it emits id
- token on one element) so the id can't land on the wrong node; if you spread
reactive_attrsdirectly, keepid:on the same element (div(id:, **reactive_attrs)). The controllerconsole.warns on connect when a reactive root has no id.
Binding inputs to action params (drop the magic name:). A field's value
travels with an action only if its name equals the param. Hand-writing
name: "value" on every input is easy to forget β the action then silently gets
nothing. reactive_input/reactive_select emit the binding for you (the trigger
stays on the button, so focusing the field doesn't dispatch and collapse edit
mode):
action :save, params: { value: :string, status: :string }
def view_template
span(**reactive_root) do
reactive_input(:value, value: @record.name) # <input name="value" β¦>
reactive_select(:status) do # <select name="status">β¦</select>
%w[open closed].each { |s| option(value: s, selected: s == @record.status) { s } }
end
(**mix(on(:save), data: { testid: "save" })) { "Save" }
end
end
reactive_field(:value, **attrs) returns just the attribute hash if you'd rather
spread it onto a control yourself. An explicit name: still wins (escape hatch).
Editing an associated record (accepts_nested_attributes_for). nested_update!
maps a declared nested param straight onto <assoc>_attributes and carries the
existing record's id, so update_only: matches it in place instead of building a
second has_one (the boilerplate that's easy to get subtly wrong):
# Account has_one :address; accepts_nested_attributes_for :address, update_only: true
action :save, params: { address: { street: :string, city: :string } }
def save(address:)
nested_update!(:address, address) # update!(address_attributes: address.merge(id: @account.address&.id))
end
nested_attributes(:address, address) returns the id-merged hash without
updating, if you need to combine it with other attributes.
Custom confirmation dialogs (setConfirmResolver)
on(:action, confirm: "Really delete this?") gates a destructive trigger behind
a confirmation. Because the reactive controller preempts the event (its own
preventDefault + POST), Hotwire's data-turbo-confirm β which routes through
Turbo.config.forms.confirm β never runs for a reactive trigger. So by default
the gate uses the browser-native window.confirm (synchronous, no dependency,
screen-reader friendly).
If your app already themes confirmations (the common Hotwire setup β
Turbo.config.forms.confirm = (message) => Promise<boolean>, backed by a styled
modal), reuse that exact dialog for reactive triggers with one line at boot:
import { setConfirmResolver } from "phlex/reactive/confirm"
// Reuse the same themed dialog the rest of the app already uses.
setConfirmResolver((message) => window.Turbo.config.forms.confirm(message))
The resolver receives the confirm: message and returns true/false (or a
Promise of one). It may be async β the controller awaits it, then runs
the action only on a truthy result; a falsy result (or a rejected promise β e.g.
the user dismissed the dialog) cancels the action, exactly like declining the
native prompt. The native default is always prevented up front, so a submit
trigger never navigates while the dialog is open.
Unset, behavior is identical to the native window.confirm β the confirm:
markup and on(...) API are unchanged; only the client's resolution strategy
gains a seam.
reply β controlling the action's reply
By default an action re-renders its component in place. To do more, return
reply.<verb> β a subject-bound builder available in every component. It governs
only the actor's HTTP reply (cross-tab updates still use
broadcast_*_to(..., exclude: reactive_connection_id)). Returning anything else
keeps the default, so existing actions are unaffected.
reply reads cleanly: the component is the implicit subject (no self to
thread) and there's no constant to qualify (it's a method, so a namespaced
component needs no alias):
def rename(title:)
return reply.replace.flash(:error, @todo.errors..to_sentence) unless @todo.update(title:)
reply.replace
end
def approve = (@row.approve!; reply.remove) # drop the element
def publish = (@article.publish!; reply.redirect(article_url(@article))) # slug changed β Turbo.visit
def add(item:) = reply.replace.stream(Totals.update(@order)) # multi-stream
# Per-field reactive editing (a "spreadsheet" grid): a debounced save fires
# while the user is still typing/tabbing. Morph in place so the focused <input>
# and its in-progress value survive the re-render (issue #28). Note the action is
# named `update`, yet `reply.morph` is unambiguous β the verb is on `reply`:
def update(name:) = (@row.update!(name:); reply.morph)
# Re-render a COMPANION element (a heading mirroring the edited name) alongside self:
def rename(value:) = (@account.update!(name: value); reply.replace.also_update("page_heading", html: @account.name))
# Update ONLY part of the component (issue #30): re-stream just the total cell,
# NOT the whole row. reply.streams emits exactly your streams plus a tiny
# token-only refresh β no full-self replace β so a sibling <input> the user is
# mid-typing in is never torn down. The signed token still rolls forward.
def update(quantity:, price:) = (@item.update!(quantity:, price:); reply.streams(Totals.update(@item)))
| Builder | Reply |
|---|---|
reply.replace / reply.update(morph: false) |
re-render in place (default; replace swaps the whole element via outerHTML, update swaps only the inner HTML) |
reply.morph / reply.replace(morph: true) / reply.update(morph: true) |
re-render in place via Idiomorph (method="morph") β preserves the focused <input> + caret; for per-field reactive editing (replace #28; update #113) |
.also_update(target, html:) |
also re-render a companion element by DOM id; html is a plain string (escaped) or a Phlex component |
.also_replace(component, morph: false) |
also re-render another Streamable component, targeting its own #id; morph: true morphs it in place |
.flash(level, content, target: β¦) |
append a flash; content is a plain string (escaped, wrapped in a level-carrying <div> β see Flash levels) or a Phlex component (rendered verbatim; off-request β no Rails flash); target defaults to Phlex::Reactive.flash_target ("flash") |
reply.remove |
remove the element (backed by Streamable#to_stream_remove) |
reply.redirect(url) |
client-side Turbo.visit (pass a *_url); rides a reactive:visit turbo-stream, not an HTTP 3xx |
reply.streams(*streams) |
partial update β emit exactly these streams (no full-self replace) + a tiny token-only refresh, so live inputs survive; for per-field grid editing (issue #30) |
.js(ops, target: β¦) |
also push client DOM ops (focus, dispatch, class/attr toggles) over a reactive:js stream, applied AFTER the render β reply.morph.js(js.focus("[name=next]")) focuses the morphed field (issue #97) |
.defer(component, placeholder:, morph:) |
take an expensive segment off the actor's critical path (issue #165) β the reply returns immediately and the real render streams to the SAME actor when ready; see Deferred segments |
reply.with(*streams) / #stream(*more) |
multi-stream (self re-render still injected for the token) |
.flash/.stream/.also_* are additive on a self-replace, so the component's
signed token always refreshes. reply.streams is the exception that proves
the rule: it deliberately skips the full-self replace (so your hand-built streams
update only the targets you name) and refreshes the token via a tiny inert
reactive:token stream instead β the token rolls forward without re-rendering
(and clobbering) the component's live inputs.
Deferred segments (reply.defer + reactive_lazy)
Profile first. An app-side N+1 or a missing eager-load looks exactly like framework lag β a scoreboard re-rendering on every debounced keystroke once "felt slow" here, and the real cause was
2 + Nqueries per keystroke, fixed with one eager load and no gem change. Make the synchronous path cheap before you make it async; reach fordeferonly when a reply segment is genuinely expensive (a cross-aggregate rollup, a report, an external call).
Everything in a reply renders synchronously on the request thread, so one
expensive segment stalls the actor's whole interaction. reply.defer takes it
off the critical path β the actor's reply returns immediately and the real
HTML streams to that same actor the moment the render finishes:
action :update, params: { weight_kg: :float, reps: :integer, rpe: :float }
def update(weight_kg:, reps:, rpe:)
@set, :update?
@set.update!(weight_kg:, reps:, rpe:)
reply
.streams(volume_cell_stream) # instant, cheap β synchronous
.defer(SessionTotals.new(workout: @workout)) # expensive β deferred
end
Be honest about the trade: defer improves the actor's reply latency and makes time-to-full-content slightly worse (one extra hop). It moves cost off the critical path; it never removes it.
While the deferred render is pending, the target keeps its current (stale)
content and carries data-reactive-defer-pending + aria-busy β style the
window in pure CSS:
[data-reactive-defer-pending] { opacity: .5; }
Options:
reply.defer(comp) # keep-content default (above)
reply.defer(comp, placeholder: true) # comp's deferred_placeholder, or a built-in shell
reply.defer(comp, placeholder: Skeleton.new) # explicit skeleton (component, or an html_safe string)
reply.defer(comp, morph: true) # arrival morphs in place (mode rides INSIDE the signed token)
deferred_placeholder (optional, on the deferred component) returns a Phlex
component instance, an html_safe string, or a plain string (escaped β data,
not markup).
Semantics you can rely on:
- Transactional β the directive rides the reply, which only renders after the action's transaction committed; a rollback or a denied action leaks no deferred render (and enqueues no job).
- Actor-scoped β the deferred render reaches only the actor; peers keep
getting updates via
broadcast_*_to(use both when both need the value). - Superseding β a newer action for the same target aborts the in-flight deferred render; a fast typist never gets stale totals painted over fresh ones.
- Interactive on arrival β the streamed root carries a fresh action token.
- Failure-visible β a failed deferred fetch clears the pending state, sets
data-reactive-error="defer", and emitsreactive:errorwith aretry();render?false resolves to a 204 (pending cleared, content kept).
Delivery is transport-adaptive (Phlex::Reactive.defer_transport, default
:auto): a parallel authenticated fetch to POST /reactive/defer everywhere
(carrying a purpose-scoped, short-TTL signed identity token β
defer_token_ttl, default 120s; an action token is rejected at the defer
endpoint and vice versa), or β when pgbus's reactive Streams and ActiveJob
are present β a durable one-shot pgbus stream rendered by
Phlex::Reactive::DeferredRenderJob off the request thread
(defer_job_queue config; the durable replay closes the
broadcast-before-subscribe race). :fetch forces the fetch lane; :stream
requests push and degrades to fetch with a warning when the capability is
absent. Both lanes are invisible to your action code.
The push lane's queue lifecycle. Each deferred segment on the push lane mints a durable one-shot pgbus stream. Its queue is reclaimed by pgbus's age-based orphan-stream sweep (pgbus β₯ 0.9.10) β ensure the pgbus Dispatcher is running with
streams_orphan_thresholdset (its default). We do not drop the queue from the render job: an eager drop would destroy a not-yet-consumed message and reopen the very broadcast-before-subscribe race the durable lane exists to close. On pgbus β€ 0.9.9 the sweep only reaped empty queues (which a durable stream never becomes), so a one-shot queue leaked β upgrade to β₯ 0.9.10, or stay on the pull lane (defer_transport = :fetch, the universal default, which needs no cleanup).
Security of the defer token. The defer endpoint re-renders the real
component, whose fresh root carries a normal (non-expiring) action token β so a
defer token is, within its TTL, a render of that identity and a path to that
identity's action token. What bounds the damage in every case: the signature
proves identity, not permission β the harvested action token is useless
against authorize! in the action, which re-checks the current actor.
Authorize every mutating action; the token, defer or otherwise, is never the
authority.
The two defer-token channels are bound differently, by their leak surface:
reply.defertokens ride an action's HTTP response (which can transit a logging proxy, a shared HAR, an APM that captures bodies) β the real cross-infrastructure leak vector. They are actor-bound: signed under the requesting session (Phlex::Reactive.defer_binding_for(request), the persisted session id by default β override to bind to your own actor identity, e.g. a user id), so a leaked one can't be redeemed in another session.reactive_lazyshell tokens live in the page HTML the actor already fetched over their own session (a small leak surface) β and can't be actor-bound anyway, because the shell renders on a fresh visit before the session exists (Rails establishes it during that response). They are minted unbound; the TTL +authorize!are their bound.
Apps with no persisted session (the ActionController::Base default, a
token-auth API) mint every defer token unbound β there too the TTL +
authorize! are the whole bound. Set Phlex::Reactive.base_controller_name to
a session-bearing controller, or override defer_binding_for, to bind
reply.defer tokens to your actor identity.
Lazy initial mount β the same machinery for the first render
(Livewire's #[Lazy]): declare reactive_lazy and the page ships the
placeholder shell; the client fetches the real content on connect. Every
reactive-machinery render (an action's self-replace, broadcasts, the defer
endpoint) stays REAL, so actions never pay two round trips:
class SessionTotals < ApplicationComponent
include Phlex::Reactive::Component
reactive_record :workout
reactive_lazy # first render = placeholder shell
# reactive_lazy tag: :tr # for a <tr>/<li> root the shell must match
def deferred_placeholder = TotalsSkeleton.new # optional
end
The lazy shell's <div> root would be invalid inside <tbody>/<ul>, so a
component whose real root is a <tr>/<li> sets reactive_lazy tag: :tr (etc.)
to ship a matching shell element. The client re-fetches the real content both on
connect AND after a Turbo page-refresh morph (which re-shows the shell while
keeping the element connected), so a lazy component survives a turbo:reload.
One edge case: a
reply.defer(placeholder:)shell (the action-driven, not page-mount, form) carries no token of its own β the transient directive owns its delivery. If a page is snapshotted by Turbo mid-defer and later restored from cache, that placeholder can appear stuck (the directive that would have filled it is long gone). It self-corrects on the next action; deferred content is never lost server-side. Lazy mounts (which carry the token on the shell) don't have this β they re-fetch on restore.
Flash levels
The level reaches the wire (issue #77). String content is wrapped in a
level-carrying <div>, so :error and :notice are styleable:
<div class="reactive-flash reactive-flash--error" data-reactive-flash-level="error">
Save failed
</div>
Style against .reactive-flash--{level} (the class) and hook scripts/tests on
data-reactive-flash-level (the data attribute). The string keeps the same
injection contract as before, applied inside the wrapper: a plain string is
HTML-escaped (a model value can't inject markup); an html_safe string passes
verbatim.
Prefer your own markup? Two escape hatches:
# 1. Pass a Phlex component as the content β rendered VERBATIM, no wrapper
# (you own the markup entirely, including the level styling):
reply.replace.flash(:error, Alert.new(level: :error, message: msg))
# 2. Or configure a flash component ONCE β string flashes render through it
# (instantiated new(level:, content:)); component content still bypasses it:
Phlex::Reactive.flash_component = MyFlash # default nil β the built-in wrapper
Server-pushed client ops (reply.js + broadcast_js_to, issue #97)
Sometimes the server needs the client to do something other than swap HTML β
focus the next field after a save, dispatch an app event to a toast host, add an
unread badge β WITHOUT re-rendering to make it happen. reply.<verb>.js(ops)
chains a reactive:js stream carrying declared client DOM ops (the same js
builder as on_client)
onto any reply. The op stream rides after the render streams, so a focus op
sees the freshly rendered/morphed DOM:
def save(title:)
@todo.update!(title:)
# Morph in place, THEN focus the next field + tell a toast host we saved:
reply.morph.js(js.focus("[name=next_field]").dispatch("app:saved", detail: { id: @todo.id }))
end
target: scopes op resolution on the client; it defaults to the bound
component's id for replace/morph/update (so @root and component-relative
selectors just work), and to document-scope for a subject-free reply.with.
global: true on a single op opts it out of the target scope to document-wide
resolution β reply.morph.js(js.text("#sum_total", total, global: true)) paints
a recap node outside the component while the morph stays root-targeted.
The same ops broadcast to every subscriber of a stream over the usual transport (Action Cable or pgbus) β a background nudge to all viewers:
# In a model/job: light up the bell in every viewer's tab, minus the actor's own.
Notifications::Badge.broadcast_js_to(user, :alerts,
js.add_class("#bell", "has-unread"), exclude: reactive_connection_id)
broadcast_js_to refuses focus-class ops (focus/focus_first raise
ArgumentError): broadcasting focus would steal it in every subscriber's tab, so
focus is an actor-reply concern only. Everything else is a fair broadcast (class
and attribute toggles, dispatch). As with on_client, the ops are
whitelist-interpreted client-side β an unknown op warns and is skipped β and the
ops attribute is HTML-escaped, so a value can't break out of it. reactive:js
is not a self-render: it never counts toward the token refresh, so the reply's
signed token still rolls forward exactly as it would without the ops.
Ephemeral by design. Like
on_client, server-pushed ops are transient UI: the next server re-render of the component resets whatever they toggled. State that must survive a re-render belongs in a signedaction, not an op.
Record-authorized, transient-state actions (issue #64)
A reactive_record component isn't obligated to persist or broadcast β the
record can be there purely for identity + authorization while the action's
real job is to recompute live, unsaved form values the user is mid-edit. The
record is re-located and instantiated on each action (from_identity), never
auto-saved and never auto-broadcast; persistence and cross-tab broadcast are both
opt-in (you call record.update! / broadcast_*_to yourself). Pair that with
reply.streams and you get a first-class "authorize via the row, compute over
the params, stream a partial update, touch neither the DB nor peer tabs" action:
class Invoice::PaymentFields < ApplicationComponent
include Phlex::Reactive::Component
reactive_record :invoice # identity + authorization ONLY β not persisted here
action :rebalance, params: { invoice: { field_a: :integer, field_b: :integer,
field_c: :integer, total: :integer } }
def rebalance(invoice:)
@invoice, :update? # the token proves identity, not permission
result = recompute(invoice) # pure computation over the collected params
reply.streams(*set_value_streams(result)) # NO persist, NO broadcast
end
end
This is deliberate, not a misuse: reply.streams is exactly the reply for "emit
these targeted updates, roll the token forward, and leave everything else β the
DB, the other tabs, the sibling inputs the user is typing in β untouched."
Broadcasting is deliberately omitted so peer tabs with their own in-flight edits
aren't clobbered. Authorize the record as always β identity is never permission.
Under the hood.
reply.<verb>returns aPhlex::Reactive::Responseβ the immutable value object the endpoint reads. You can build one directly (Phlex::Reactive::Response.replace(self)) and it still works, butreplyis the preferred surface; treatResponseas an internal detail.html:/contentescaping. A plain string is HTML-escaped by Turbo, sohtml: @account.nameis safe even for user-supplied values. To emit intentional markup, pass a Phlex component (html: Heading.new(name: @record.name)) β rendered and auto-escaped through the renderer β or anhtml_safestring for raw HTML you control.
Failure UX & lifecycle events
The generic controller dispatches three bubbling, composed CustomEvents
around every action round trip, so an app can toast an error, instrument
latency, veto a dispatch, or build retry UI without forking the controller:
| Event | When | event.detail |
|---|---|---|
reactive:before-dispatch |
after the trigger's preventDefault/confirm:, before debounce/enqueue |
{ action, params, element } β cancelable: event.preventDefault() skips the round trip entirely (nothing is scheduled) |
reactive:applied |
after the response's token was captured and the streams were handed to Turbo.renderStreamMessage |
{ action, params, html } |
reactive:error |
in every failure branch of the round trip | { action, params, kind, status?, body?, retry } |
reactive:error's kind tells you what failed:
kind |
Meaning | Extra detail |
|---|---|---|
redirected |
the POST was redirected (an auth before_action / CSRF guard bounced it) |
status, retry |
http |
non-2xx response (403 default-deny/authorization, 400 bad token, 404 record gone, 500 β¦) | status, body, retry |
content-type |
200, but not a turbo-stream (an HTML error page, a misconfigured route) | status, retry |
timeout |
the request took longer than the configured window (default 30s) and was aborted β the server may or may not have finished | retry |
offline |
the browser was offline (navigator.onLine === false) when the action fired β the fetch was never sent |
retry |
network |
fetch itself rejected (DNS, connection reset, an interface drop mid-flight) β the server never saw the request |
retry |
apply |
the server processed the action successfully, but something AFTER the fetch threw (a malformed response, a Turbo render error) | no retry |
apply covers a throw in the controller's own post-fetch code β not a
throwing listener on reactive:applied itself. Per the DOM spec,
EventTarget#dispatchEvent never propagates a listener's exception back to
its caller (it's reported to the console instead), so a listener that throws
can't surface as reactive:error at all β it just logs and the round trip is
otherwise unaffected.
detail.retry() re-enters the controller's request queue: it re-reads the
freshest signed token and re-collects the component's fields at send time,
so nothing stale is replayed. It fires no second reactive:before-dispatch
(one veto per user gesture), and it no-ops with a console.warn once the
component has left the DOM. The existing console.error logging is unchanged β
the events add hooks, they don't replace the log.
kind: "apply" carries no retry() at all β by the time this fires the
server has already completed the mutation, so retrying would re-POST an
action that already succeeded (potentially a non-idempotent one). Every kind
EXCEPT apply is retriable.
Request timeout (kind: "timeout")
A server that never responds used to wedge a component's request queue forever
(each action chains on the previous one) β the spinner never cleared and every
later action froze. Now the fetch is bounded by AbortSignal.timeout: after the
window (default 30 s) it aborts, fires reactive:error kind: "timeout",
and the queue advances so the component keeps working. Configure the window with
a page-stable meta (app-authored, following the same pattern as the action path):
<meta name="phlex-reactive-timeout" content="15000"> <%# 15s #%>
Non-goal β no automatic replay. A timed-out POST may have succeeded server-side (the server just answered too late). phlex-reactive never auto-replays a request, and even a manual
retry()can double-apply a non-idempotent action. Make retryable actions idempotent, or gate your retry UI accordingly.
Offline (kind: "offline")
When the browser is offline (navigator.onLine === false) at send time, the
action short-circuits before the fetch β the edit is never half-sent β and
fires reactive:error kind: "offline" with a retry(). The check lives at the
network boundary, so a request that enqueued while online but reaches the wire
after a connection drop is reported as offline, not network.
phlex-reactive also mirrors data-reactive-offline onto <html> whenever the
browser goes offline (kept in sync by the online/offline events) β a pure
CSS hook, zero app JS:
[data-reactive-offline] .save-button { opacity: .5; pointer-events: none }
[data-reactive-offline] .offline-banner { display: block }
// Auto-retry a specific action when the connection returns:
document.addEventListener("reactive:error", (e) => {
if (e.detail.kind === "offline") {
addEventListener("online", () => e.detail.retry(), { once: true })
}
})
Latency simulator (development aid)
On localhost the clickβmorph round trip is ~5 ms, so the pending affordances
you just wired β aria-busy, disable_with:, busy_on, optimistic hints β
flash by too fast to actually see while developing or demoing them. That's the
same problem LiveView solves with liveSocket.enableLatencySim(ms).
phlex-reactive ships the equivalent. Because importmap module exports aren't
reachable from the DevTools console, the two functions are exposed on a
window.PhlexReactive handle β but only when your layout opts in with a
development-gated meta:
<%# app/views/layouts/application.html.erb, inside <head> β DEVELOPMENT ONLY %>
<%= tag.meta(name: "phlex-reactive-env", content: "development") if Rails.env.development? %>
Then, from the browser console:
PhlexReactive.enableLatencySim(400) // delay EVERY action by 400ms
// β¦click around; aria-busy, spinners, disable_with, optimistic hints are now visibleβ¦
PhlexReactive.disableLatencySim() // back to full speed
The delay is read live before each fetch (so toggling takes effect on the very
next action, no reload) and persists to sessionStorage β it clears when the tab
closes, so you can't accidentally leave it on across sessions. A one-time console
banner reminds you while it's active.
Without the meta there is no global handle at all and the per-request read
short-circuits on a null β zero production surface. It's purely a
development convenience, gated by markup you author.
The events bubble from the component's root element (or from document when
the root was detached by the failing round trip), so they compose with plain
Stimulus listening β a global toaster is one attribute on an ancestor:
<body data-controller="toast" data-action="reactive:error->toast#show">
// toast_controller.js
show(event) {
const { kind, status, retry } = event.detail
this.flash(`Action failed (${kind}${status ? ` ${status}` : ""})`, { onRetry: retry })
}
Or veto/instrument at the document level:
document.addEventListener("reactive:before-dispatch", (event) => {
if (offline) event.preventDefault() // cancel: nothing is enqueued
})
document.addEventListener("reactive:applied", ({ detail }) => {
metrics.count(`reactive.${detail.action}.ok`)
})
One honest caveat on timing: reactive:applied means the turbo-streams were
handed to Turbo β renderStreamMessage applies them asynchronously, so the
DOM mutation may complete a tick later. If you need post-morph timing, listen
to Turbo's own events (turbo:before-stream-render and friends).
Showing the user a failure (not just the console)
The events above are the hook β but a user who just wants to see "that didn't work" shouldn't have to write a toast controller. There are three built-in ways to surface a failure, cheapest first:
1. In-action validation replies (already works). For a failure your action knows about β a validation error, a business rule β return a flash directly. It renders at 200 (a normal reply, not an error):
def rename(title:)
return reply.replace.flash(:error, "Title can't be blank") if title.blank?
@todo.update!(title:)
end
2. Phlex::Reactive.error_flash β server-rendered flashes on endpoint
failures. For the failures the endpoint catches (bad token, default-deny,
authorization, missing record β the 400/403/404 rescue paths), set a lambda and
every one renders a turbo-stream flash the user sees, at the same status it
already returns (statuses never change):
# config/initializers/phlex_reactive.rb
Phlex::Reactive.error_flash = ->(kind) { "Something went wrong (#{kind})." }
The client now renders non-OK turbo-stream bodies (previously it read the
body only for the console and discarded it), so an error_flash β or a plain
controller replying status: :unprocessable_entity with a turbo-stream flash β
lands in your flash region. The failing component's root also gets
data-reactive-error="<kind>", so you can style it in pure CSS with zero JS,
and the next successful action clears it:
[data-reactive-error] { outline: 2px solid var(--danger); }
Note. A 400 (invalid token) reply never refreshes the client's held token β the identity token is not a nonce, it stays retry-valid. The client only adopts a fresh token from a body that re-renders this element's id, so a foreign/error body can't swap it out.
3. Offline fallback (no server to render anything). A network failure
reached no server, so there's nothing to render. Opt in with a server-rendered
<template> in your layout β on a network failure the client clones it into the
flash region (it's your trusted markup, cloned verbatim β no client templating):
<template data-reactive-error-flash>
<div class="reactive-flash reactive-flash--error">You appear to be offline.</div>
</template>
Self-dismissing flashes (dismiss_after:)
A flash that never cleans itself up piles up. Pass dismiss_after: (ms) and the
flash removes itself after the timeout β driven by a document-level handler,
so it self-cleans both reply-delivered and broadcast-delivered flashes (the
flash container is a plain host-app div with no controller attached):
reply.replace.flash(:error, "Couldn't save β try again", dismiss_after: 4000)
It wraps string content with data-reactive-dismiss-after="4000"; a verbatim
Phlex component owns its own lifecycle and is left untouched.
Reactive collections (add/remove rows + count + empty-state)
An add/remove-row list β line items, attachments, tags, comments, a
notifications list β is one of the most common reactive surfaces, and every one
re-implements the same orchestration by hand: append the row to the right
container, remove it on delete, keep a count badge in sync, and swap an
empty-state in/out as the list crosses 0β1. reactive_collection declares
that contract once on the container so each action is a single call.
Declare the collection on the container component, then reply.append /
reply.prepend / reply.remove in the actions:
class NotificationsList < ApplicationComponent
include Phlex::Reactive::Component
reactive_collection :notifications,
item: NotificationRow, # the per-row Streamable component
container: "notifications", # the DOM id rows live in
count: "notifications-count", # optional companion id (the size badge)
empty: NotificationsEmpty, # optional empty-state component
size: -> { Todo.count } # resolves the live size (re-counted, never client state)
action :add, params: {title: :string}
action :dismiss, params: {id: :integer}
def add(title:)
todo = Todo.create!(title:)
reply.append(:notifications, todo) # append row + bump count + clear empty-state
end
def dismiss(id:)
Todo.find(id).destroy!
reply.remove(:notifications, id) # remove row + bump count + restore empty-state at 0
end
# view_template renders the count, the container <ul>, and the empty-state on
# first paint β the same components the helper streams in/out on each delta.
end
| Builder | Reply (one Response) |
|---|---|
reply.append(name, model) |
append the row into the container + update the count + remove the empty-state when the list crosses 0β1 |
reply.prepend(name, model) |
as append, but the row goes to the top |
reply.remove(name, model) |
remove the row by its dom_id + update the count + append the empty-state back when the list crosses β0 |
size:is the source of truth β it's re-counted server-side after the mutation, so the badge and the empty-state are correct-by-construction (no off-by-one, no client-held count).count:,empty:, andsize:are all optional: omit them and only the row stream is emitted.- Repeated add/remove just works β each reply rolls the container's signed
token forward (via the inert
reactive:tokenrefresh), so the second click from the list root is accepted. Without this an add/remove list would be add-once-only (correct on the first click, silently rejected after); the helper bakes the refresh in so you never hit it. removetakes the record or itsdom_idstring β a just-destroyed ActiveRecord still answersdom_idcorrectly, soreply.remove(:items, todo)works; pass the raw id only if your row#idmatchesActiveRecord::RecordIdentifier.- Reply governs the actor's HTTP response only. For a cross-tab live list
(other viewers see the row appear) keep broadcasting the row with
NotificationRow.broadcast_append_to(..., exclude: reactive_connection_id)βreactive_collectionis the per-actor add/remove + count + empty-state wrapper, not a replacement for the broadcast.
Configuration (config/initializers/phlex_reactive.rb)
Phlex::Reactive.configure do |c| end if false # (plain accessors below)
# Inherit auth/CSRF/Current from your app on the action endpoint:
Phlex::Reactive.base_controller_name = "ApplicationController"
# Render your authorization library's error as 403:
Phlex::Reactive. = [Pundit::NotAuthorizedError]
# or: [ActionPolicy::Unauthorized]
# verify_authorized (ON by default): an action that authorizes NOTHING raises
# AuthorizationNotVerified inside the transaction (the mutation rolls back β
# fail-closed). Satisfy it by calling one of authorization_methods, calling
# mark_authorized! after a bespoke check, or declaring skip_verify_authorized on
# a genuinely public component/action. Set the method names to match your library:
Phlex::Reactive. = %i[authorize! authorize allowed_to?]
# Phlex::Reactive.verify_authorized = false # turn the guard off (not recommended)
# Use your ApplicationController to render components (app helpers / Current):
Phlex::Reactive.renderer = ApplicationController
# Sign tokens with a dedicated key instead of secret_key_base:
Phlex::Reactive.verifier = ActiveSupport::MessageVerifier.new(ENV["REACTIVE_KEY"])
# Change the endpoint path (default "/reactive/actions"):
Phlex::Reactive.action_path = "/_r/actions"
# Diagnostic error bodies + dropped-param logging (default: Rails.env.local? β
# on in development AND test, off in production):
Phlex::Reactive.verbose_errors = true
# User-visible flash on endpoint failures (default nil = off). When set, every
# rescue path (400/403/404) ALSO renders a turbo-stream flash the user sees β at
# the SAME status it returns today (statuses never change). The lambda receives
# the failure kind (:tampered/:unknown_class/:not_reactive_class/:forbidden/
# :not_found), so you can map it to a friendly message:
Phlex::Reactive.error_flash = ->(kind) do
case kind
when :not_found then "That item is no longer available."
when :forbidden then "You don't have permission to do that."
else "Something went wrong β please try again."
end
end
# Component-aware wrapper around every action (audit / rate-limit / assert).
# Sees the resolved component, action name, and COERCED params; runs inside
# the connection-id scope but OUTSIDE the transaction. See "Two seams" below.
Phlex::Reactive.around_action do |ctx, &action|
RateLimiter.check!(ctx.request.remote_ip, ctx.action_name) # raise -> 403
result = action.call
AuditLog.record!(actor: Current.user, action: ctx.action_name)
result # <- REQUIRED: return the continuation's value
end
Two seams: HTTP-layer (base_controller_name) vs component-layer (around_action)
There are two places to wrap a reactive action, and they see different things:
Base controller (base_controller_name) |
Phlex::Reactive.around_action |
|
|---|---|---|
| Layer | HTTP request | the resolved component action |
| Sees | headers, session, request |
the component instance, action name, coerced params, request |
| Runs | full Rails filter chain | inside with_connection_id, outside the transaction |
| Use for | auth, CSRF, coarse per-IP rate limiting | audit logging, component-aware rate limiting, assertions |
Plain Rails around_action / rate_limit on a dedicated base controller already
covers attributes, authentication, and coarse per-IP throttling β but that layer
never sees the resolved component, the declared action name, or the coerced
params, and can't sit inside the connection-id scope yet outside the action's
transaction. Phlex::Reactive.around_action is that component-aware seam. ctx is
a frozen Phlex::Reactive::ActionContext (component, action_name, params,
request); the fold runs after token verify, default-deny, and param coercion,
so a wrapper can never widen what's invokable.
Contract β each wrapper MUST return action.call's value. The endpoint
type-checks the action's return for a Phlex::Reactive::Response; a wrapper that
ends on its logger's return value instead silently downgrades every reply to the
implicit self-replace. A wrapper raising a registered authorization_errors error
renders as 403; an unregistered raise is a 500. Multiple wrappers nest in
registration order (last-registered outermost). Tests reset the stack with
Phlex::Reactive.reset_around_actions!.
If you set a custom action_path, expose it to the client:
<meta name="phlex-reactive-action-path" content="<%= Phlex::Reactive.action_path %>">
The client request timeout (default 30 s) is likewise an app-authored meta β there is no server-side setting, so drop it in your layout head if 30 s is wrong for your slowest action:
<meta name="phlex-reactive-timeout" content="15000"> <%# 15s, in ms #%>
Security
phlex-reactive is built so the easy path is the safe path β but the boundary is real, so read this once.
- State is never trusted from the client. The DOM holds a
MessageVerifier- signed identity β{component, gid}(record-backed),{component, state}(state-backed), or{component, gid, state}when a component declares both β not raw state. A tampered class, record, or state value fails signature verification β 400. - Actions are default-deny. Only methods declared with
action :nameare invokable. A public method withoutactionis unreachable. - You must authorize. The signature proves the token is yours, not that
this user may act on this record. Call your authorizer inside the action
(
authorize! @todo, :update?) and register its error inPhlex::Reactive.authorization_errors. - Params are schema-coerced. Only declared params reach your method, each cast to its declared type. No raw mass assignment.
- CSRF + auth are the host app's. The endpoint inherits from your configured
base_controller_name. InheritApplicationControllerto get CSRF and auth β but if you have public reactive components, ensure the action path isn't force-redirected to a login page for logged-out users.
Token payload versioning
The signed identity payload carries a version ("v",
Phlex::Reactive::TOKEN_VERSION) so a future change to the token shape can
upgrade tokens already in flight instead of breaking every open page at
deploy. When you change the shape, bump TOKEN_VERSION and register an upgrader:
# config/initializers/phlex_reactive.rb
Phlex::Reactive.register_token_upgrader(0) do |payload|
payload.merge("gid" => rewrite_old_gid(payload["gid"]))
end
On verify, the payload runs through the upgrader chain (oldest β current) before
your component rebuilds from it. A pre-versioning token (no "v") is read as-is
β introducing versioning invalidated nothing. It fails closed: a token signed
by a newer deploy than the running code (a rollback) verifies its signature but
carries an unknown version, so Phlex::Reactive.verify returns nil and the
endpoint answers 400 β never guessing a shape it doesn't understand. See
the security guide
for depth.
Debugging endpoint failures (verbose_errors)
Every endpoint failure is warn-logged as [phlex-reactive] β¦ in every
environment. With Phlex::Reactive.verbose_errors on (the default in
development and test via Rails.env.local?; off in production), the failure
response ALSO carries a plain-text diagnostic body β the client already prints
it via console.error β and param coercion warn-logs every dropped key with
its full bracketed path and reason (undeclared / uncoercible), including a
hint when a flat name looks like the bracketed twin of a declared nested key
(or vice versa). What each status means:
- 400 β token signature invalid (stale token from before a deploy?
secret_key_basemismatch?), a token class that no longer resolves, or a class that resolved but doesn't includePhlex::Reactive::Component - 403 β an undeclared action (the body lists the declared actions) or a registered authorization error raised inside the action
- 404 β the signed GlobalID no longer resolves (record deleted)
The flag never changes a status β only the body and the coercion log.
The same flag also makes on(:typo) fail loudly at render time: when
verbose_errors is on, on(:name) raises Phlex::Reactive::Error (listing the
declared actions) if :name isn't declared on that component β so a misspelled
or forgotten action surfaces the moment you load the page in dev/test, instead
of as an unexplained 403 on click. Production (flag off) keeps the permissive
emit, so a stale page after a deploy that removed an action never 500s on render.
A component that declares no actions of its own (a cross-component dispatch
helper β a child row rendering a trigger for its container's action) is skipped;
on_client triggers are never checked (they aren't declared actions). The
server's default-deny stays the security boundary β this is a dev-time courtesy.
See docs/security.md for the threat model and a checklist.
How it beats Stimulus + Turbo (same feature, less code)
A counter, today vs. with phlex-reactive:
| Stimulus + Turbo | phlex-reactive |
|---|---|
|
One file. No JS. No routes. No partial. No hand-picked target. |
Live updates with pgbus (recommended)
pgbus replaces Action Cable's transport
with Postgres SSE and fixes its reliability gaps. With it installed,
broadcast_*_to and turbo_stream_from route over pgbus automatically:
class Message < ApplicationRecord
broadcasts_to ->(m) { [m.room, :messages] }, durable: true
end
- Transactional: a broadcast inside a transaction that rolls back never fires β and the DB change is undone. No "ghost" UI updates.
- Reconnect-safe: a tab that dropped replays missed messages on reconnect
(
Last-Event-ID+ PGMQ archive). - No race on subscribe: messages broadcast between render and subscribe are replayed, not lost.
- No Redis, no Action Cable.
See docs/broadcasting.md and docs/transport-pgbus.md.
Observability
The hot paths emit ActiveSupport::Notifications events, so an APM (AppSignal,
Datadog, Skylight) sees reactive traffic at the component level β which
component/action a slow request was, render time, and broadcast fan-out. Three
events, all under the phlex_reactive namespace:
| Event | Fires | Payload |
|---|---|---|
action.phlex_reactive |
once per request | component, action, outcome (ok/denied_undeclared/invalid_token/not_found/unauthorized/unverified) |
render.phlex_reactive |
per component render | component, bytesize |
broadcast.phlex_reactive |
per broadcast_*_to (Action Cable and pgbus) |
component, stream_action, streamables |
Payloads carry names, the outcome, and sizes only β never the token, params, or state, so an event can't leak a secret. Subscribe from an initializer:
ActiveSupport::Notifications.subscribe("action.phlex_reactive") do |*args|
event = ActiveSupport::Notifications::Event.new(*args)
MyAPM.record("reactive.#{event.payload[:outcome]}", event.duration,
component: event.payload[:component], action: event.payload[:action])
end
To watch reactive traffic in your own log without an APM, flip on the bundled
LogSubscriber (default off) β one compact line per event at DEBUG:
# config/initializers/phlex_reactive.rb
Phlex::Reactive.log_events = true
# [reactive] Counter#increment ok (3.1ms)
# [reactive] render Counter 512B (0.9ms)
# [reactive] broadcast replace Counter β2 (1.4ms)
The events fire whether or not you enable the LogSubscriber; the flag only controls the gem's own log lines. See docs/performance.md.
Client debug mode (devtools-lite)
The LogSubscriber above is the server lens. The client lens is
console.error on a failure plus the lifecycle events
β but on the successful-but-wrong path (which streams arrived? did a token
refresh come?) there was nothing to see. Phlex::Reactive.debug fills that gap:
# config/initializers/phlex_reactive.rb
Phlex::Reactive.debug = Rails.env.development?
With it on, every reactive root carries data-reactive-debug="true" and the
generic controller console.groups every dispatch in the browser:
βΌ reactive #todo_42 rename β 200 (48ms)
params: [title] + collected: [title]
encoding: json
streams: replace β #todo_42
token: refreshed β
The trace carries names and outcomes only β the explicit param names and the
collected sibling-field names (never their values, which may be sensitive),
the request encoding (json/multipart), the HTTP status, the response's stream
actions + targets, whether a token refresh arrived (never the token value),
and the round-trip time. Off (the default) it does nothing β a single attribute
check per dispatch, no string building β so it is safe to leave gated on
Rails.env.development?.
Testing
Phlex::Reactive::TestHelpers is the public test surface β mix it in once and
never reach for a private method or a hand-rolled POST:
# spec/rails_helper.rb
RSpec.configure do |c|
c.include Phlex::Reactive::TestHelpers # run_reactive + matchers
c.include Phlex::Reactive::TestHelpers, type: :request # + the HTTP helpers
end
run_reactive β the no-HTTP action driver. It runs the action through the
SAME contract the endpoint enforces β default-deny, the signed identity
round-trip (a record-backed component's row is re-found), schema coercion, the
transaction wrapper β with no HTTP, and returns a Result. So a unit test can't
pass on a component that would fail a real click:
result = run_reactive(Counter.new(count: 0), :set, count: "42") # client sends strings
expect(result).to have_reactive_replace("counter")
expect(result.component.instance_variable_get(:@count)).to eq(42) # :integer, cast
# default-deny, deleted-record, and authorization all surface as the real failures:
expect { run_reactive(Counter.new(count: 0), :drop_table) }
.to raise_error(Phlex::Reactive::TestHelpers::UndeclaredReactiveAction)
Result answers replace? / remove? / redirect? / redirect_url /
streams / response, plus component (the instance rebuilt from identity, the
one the action ran against). A registered authorization error raises (the
endpoint maps it to 403). Matchers: have_reactive_replace,
have_reactive_remove, have_reactive_token_for β the last pins the token
refresh so a reply that would silently break the next click fails your test.
HTTP helpers β post_reactive_action(component_or_class, act, params:, payload:)
and post_reactive_multipart(...) POST a signed token to
Phlex::Reactive.action_path exactly as the client does. Token minting β
reactive_token_for(component_or_class, payload = {}).
verbose_errorsdefaults ON in test (it changes only an error BODY, never a status). Asserting an empty failure body? SetPhlex::Reactive.verbose_errors = falsein your setup.
See the testing guide for the full layer-by-layer walkthrough.
Documentation
- Installation & bundler setups
- Mental model & architecture
- Actions & events (the
on(...)API) - Security & threat model
- Broadcasting & live updates
- Transport: pgbus vs Action Cable
- Testing reactive components
- Performance & benchmarking
- Examples: counter Β· payment split Β· chat Β· todo list Β· inline edit Β· notifications Β· collections Β· file uploads & custom types Β· loading states Β· client-only ops Β· failure surface Β· team inbox
Credits & prior art
The mental model is stolen, gratefully, from Laravel Livewire (public method = action) and Phoenix LiveView (a component is a re-render unit). The transport and reliability come from pgbus. The rendering is all Phlex.
License
MIT.