phlex-reactive

CI Gem Version Docs

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
      button(**on(:decrement)) { "βˆ’" }
      span { @count }
      button(**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
    authorize! @todo, :update?      # YOU authorize β€” the token only proves identity
    @todo.toggle!(:done)
  end

  def rename(title:)
    authorize! @todo, :update?
    @todo.update!(title:)
  end

  def view_template
    li(**reactive_root(class: ("done" if @todo.done?))) do
      button(**on(:toggle)) { @todo.done? ? "βœ“" : "β—‹" }
      span { @todo.title }
    end
  end
end

One include, default #id (issue #81). include Phlex::Reactive::Component pulls in Streamable automatically (the explicit two-include form still works and is a harmless no-op). A record-backed component also gets #id for free β€” dom_id(record), exactly the id nearly every one wrote by hand β€” so def id is only needed to override it, and an explicit def id always wins. Caveat: two different component classes rendering the same record on one page both default to the same dom_id and 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 loud NotImplementedError stays).

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")
    button(type: "submit") { "Upload" }
  end
end

One multipart caveat: FormData can'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 sending tags: [] 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 flat params: { date: :string } matches nothing β€” the top-level key is now invoice, not date. There is no error: the action just receives its keyword defaults (date never set). If your inputs are named invoice[…] (any Form(model:)-style form), nest the schema under invoice: to match. When in doubt, read a field's real name attribute 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: true fires 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. Implies window:.
  • window: true binds the trigger to the window (Stimulus's native @window) for page-level events like scroll/resize. Window-bound triggers are never preventDefault-ed β€” a mounted dropdown must not kill link clicks elsewhere on the page β€” and skip the forced type="button".
  • once: true fires at most once, then unbinds (Stimulus's :once).
  • throttle: 250 rate-limits leading-edge: the first event fires immediately, further events are dropped until the window elapses. The mirror of debounce: (trailing-edge) β€” passing both raises ArgumentError.
# A dropdown that closes itself on any click outside β€” no Stimulus controller.
div(**mix(reactive_root, on(:close_menu, outside: true))) do
  button(**on(:toggle_menu)) { "Menu" }
  ul { menu_items } 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 a to: selector scoped to the root (to: :root targets the root element itself).
  • checked: :keep β€” for a click-bound checkbox/radio, the client skips its unconditional preventDefault so the native flip happens now (a bare toggle click has no navigation default to lose). on(...) also skips the forced type="button" it normally adds to click triggers β€” that would destroy the very checkbox being toggled β€” so you supply the real type="checkbox" / type="radio". On a change-bound control the flip is already native (change isn't cancelable) β€” :keep then 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.
button(**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-side apply throw) β€” the client replays the exact inverse ops, guarded by isConnected (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 the hide: true + reply.remove instant-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):

button(**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 a to: selector scoped to the root (to: :root targets the root element itself).
  • text: "Saving…" β€” swap the trigger's textContent while pending.
button(**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 gets data-reactive-busy toggled only while that action is in flight β€” a scoped spinner:
button(**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
  button(**on(:save, disable_with: "Saving…")) { "Save" }
end
  • track_dirty: true on the root wires every input under it to a full re-scan on change. dirty: true on a single reactive_field opts 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).
  • 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.
    button(**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).
    button(**on_client(:click, js.show("#menu"))) { "Menu" }
    div(id: "menu", hidden: true) { menu_items }
  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:

button(**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 β†’ a javascript: navigation surface), and style (CSS injection β€” use classes). The intended surface is class ops plus hidden, disabled, open, selected, and any aria-* / 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's textContent (stringified; nil clears) β€” XSS-safe by construction, never innerHTML, strictly less powerful than set_attr. With global: true it 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 bubbling CustomEvent so 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] on show/hide/toggle animates the visibility flip: during+from are applied, then fromβ†’to swaps on the next frame, and the helper classes are cleaned up on animationend (with a timeout fallback, so an element with no animation never leaves them stuck). The op chain is never blocked β€” later ops (a focus, a dispatch) 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/input trigger 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 omits disabled controls; reactive collection does not check disabled, so a disabled field that carries a computed/display value (a read-only total the 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 no name, or make it readonly instead of disabled when 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))
button(**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 put on(:save, event: "keydown.enter") and on(: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") { shipping_options }
    div(**reactive_show(unless: { mode: "off" })) { shipping_details }

    input(type: "checkbox", name: "gift")
    div(**reactive_show(if: { gift: true })) { gift_message_field }

    select(name: "size") { size_options }
    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..20 between), true/false compare a checkbox's checked state, nil matches blank. unless: negates and composes with if:. 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" }]), so director || (shareholder && individual) is one flat binding, no nested wrapper divs, no hand-applied distributive law.
  • First paint is computed for you β€” declare reactive_values once (def reactive_values = { mode: @order.mode, gift: @order.gift? }) and every binding whose fields are all provided renders the correct initial hidden: server-side. No per-section mirror method, no flash. An explicit hidden: always wins; a per-call values: override merges over reactive_values.
  • reactive_scope :form lets bindings and reactive_values use bare symbols while the client resolves [name="form[field]"].
  • disable: true disables 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_compute output write dispatches a real input event, so a derived value can drive visibility too.
  • Presentational only, strictly weaker than the js ops: it reads owned fields and toggles hidden (+ optionally disabled) on owned elements β€” no innerHTML, 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") { mode_options }
  # …
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 :number is coerced through Number (blank/NaN β†’ 0 β€” the array-form default), a :string reaches 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 via textContent (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 no name, 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 β€” so reactive_text(:title) is a live field echo out of the box.
  • Seed the server render. Your view_template must 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 plain reactive_text node 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.
  • textContent only, never innerHTML β€” 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.
button(**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.
        button(**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 #id and the client self-matches its next signed token by the root element's id. reactive_attrs does not emit the id β€” so if you put id: on a child instead of the **reactive_attrs element, 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. Use div(**reactive_root) (it emits id

  • token on one element) so the id can't land on the wrong node; if you spread reactive_attrs directly, keep id: on the same element (div(id:, **reactive_attrs)). The controller console.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
    button(**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.full_messages.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 + N queries per keystroke, fixed with one eager load and no gem change. Make the synchronous path cheap before you make it async; reach for defer only 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:)
  authorize! @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 emits reactive:error with a retry(); 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_threshold set (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.defer tokens 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_lazy shell 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 signed action, 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:)
    authorize! @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 a Phlex::Reactive::Response β€” the immutable value object the endpoint reads. You can build one directly (Phlex::Reactive::Response.replace(self)) and it still works, but reply is the preferred surface; treat Response as an internal detail. html:/content escaping. A plain string is HTML-escaped by Turbo, so html: @account.name is 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 an html_safe string 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:, and size: 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:token refresh), 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.
  • remove takes the record or its dom_id string β€” a just-destroyed ActiveRecord still answers dom_id correctly, so reply.remove(:items, todo) works; pass the raw id only if your row #id matches ActiveRecord::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_collection is 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.authorization_errors = [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.authorization_methods = %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 :name are invokable. A public method without action is 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 in Phlex::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. Inherit ApplicationController to 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_base mismatch?), a token class that no longer resolves, or a class that resolved but doesn't include Phlex::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 + Turbophlex-reactive
// counter_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
  static values = { url: String }
  increment() { this.#post("increment") }
  decrement() { this.#post("decrement") }
  #post(op) {
    fetch(`${this.urlValue}/${op}`, {
      method: "POST",
      headers: { "X-CSRF-Token": token() },
    })
  }
}
<%# _counter.html.erb %>
<div id="<%= dom_id(@counter) %>"
     data-controller="counter"
     data-counter-url-value="<%= counter_path(@counter) %>">
  <button data-action="counter#decrement">βˆ’</button>
  <span><%= @counter.value %></span>
  <button data-action="counter#increment">+</button>
</div>
# routes + controller
resources :counters do
  member { post :increment; post :decrement }
end
def increment
  @counter.increment!(:value)
  render turbo_stream: turbo_stream.replace(
    dom_id(@counter), partial: "counter",
    locals: { counter: @counter })
end
class Counter < ApplicationComponent
  include Phlex::Reactive::Component

  reactive_record :counter   # also defaults #id to dom_id(@counter)
  action :increment
  action :decrement

  def initialize(counter:) = @counter = counter

  def increment = @counter.increment!(:value)
  def decrement = @counter.decrement!(:value)

  def view_template
    div(**reactive_root) do
      button(**on(:decrement)) { "βˆ’" }
      span { @counter.value }
      button(**on(:increment)) { "+" }
    end
  end
end

One file. No JS. No routes. No partial. No hand-picked target.


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_errors defaults ON in test (it changes only an error BODY, never a status). Asserting an empty failure body? Set Phlex::Reactive.verbose_errors = false in your setup.

See the testing guide for the full layer-by-layer walkthrough.


Documentation

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.