0.01
The project is in a healthy, maintained state
phlex-reactive makes Phlex components reactive without writing Stimulus controllers or hand-picking Turbo Stream targets. Declare actions in Ruby; a single generic client runtime turns clicks and form input into a server round trip that re-renders the component and morphs it back in. Components self-target by a stable DOM id, so the same unit re-renders for client actions AND server-pushed broadcasts. State lives in the database behind a signed identity β€” no attacker-controlled snapshot. Pairs with pgbus for reliable, transactional, reconnect-safe live updates with no Action Cable and no Redis.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

~> 1.0
>= 2.0, < 3
>= 7.1, < 9.0
>= 1.5, < 3
~> 2.6
 Project Readme

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)

Eager registration is the simple, safe default. To load the controller lazily instead, import phlex/reactive/early eagerly (the engine pins it with preload: true) so the triggers below are not lost while it loads:

// app/javascript/application.js β€” before any controller loads
import "phlex/reactive/early"

It gzips to under 1.5 KB and has no Stimulus import. A trigger (on(...) or on_client(...)) that fires before the controller connects β€” a click while a lazily loaded controller is still downloading, a custom event dispatched as soon as the page is interactive β€” is queued, its native default is stopped exactly where the controller would stop it, and the controller replays it when it connects. Each root announces that moment with a bubbling reactive:connect event and a data-reactive-connected attribute. Without the import, register the controller eagerly: an event that fires before connect is otherwise lost.

A queued trigger older than 10 s when its controller connects is dropped (a warning under data-reactive-verbose), as is one whose element left the page. Change the window with Phlex::Reactive.early_event_ttl_ms and a meta tag:

<meta name="phlex-reactive-early-ttl" content="<%= Phlex::Reactive.early_event_ttl_ms %>">

Good to know:

  • Every firing before connect is replayed (up to 50), so give a high-frequency trigger (input, mouseover) a debounce: or throttle: as usual.
  • If you already wait for the controller yourself and then dispatch (a getControllerForElementAndIdentifier polling loop), remove that loop when you add the import, or the trigger fires twice.
  • data-reactive-connected is set at connect; an in-place morph strips it again. Listen for reactive:connect rather than reading the attribute later.
  • Until the controller connects, a captured link or form trigger has its native behavior stopped and nothing else happens (one with prevent: false keeps it, as it does once connected). If the controller never loads, or the entry is dropped (older than the TTL, its element gone, more than 50 queued), a prevented click or submit does nothing (one with prevent: false still did its native default).
  • window: triggers β€” how a hotkey is usually bound (on(:toggle, event: "keydown.k", window: true) or on_client("keydown.k", …, window: true)) β€” are captured too: a hotkey pressed anywhere on the page before the controller connects is replayed, and it wakes a dormant root. Two differences from element-bound triggers: the keypress is never prevented (a browser shortcut on the same key keeps working, as it does once connected), and it is replayed only if the controller connects within 1.5 s of the keypress (or the TTL above, if shorter) β€” later, the user has moved on. Two roots sharing a hotkey both hear it, as they do once connected: one press wakes both and each handles it. A once: true hotkey replayed before connect stays spent for the root until it disconnects β€” keep its trigger element stable across replies (a reply that morphs the root but swaps that element leaves the hotkey dead until the next disconnect), or bind the hotkey without once:.
  • A shortcut: (below) is the exception to "never prevented": the chord the page claims is prevented before connect too (so mod+s cannot open the browser's save dialog in the first moments), and a chord pressed inside a field the user is typing in, or the other platform's mod twin, is neither queued nor prevented.
  • Not captured, so still lost before connect: outside: triggers (an outside click before connect has nothing to close, and recording it would wake a dormant dropdown on every click); key filters beyond Stimulus's default key names; and every other controller action β€” only on(...) (reactive#dispatch) and on_client(...) (reactive#runOps) are replayed, not the built-in nestedAdd/nestedRemove, tagsAdd/tagsPick, listnav* or recompute bindings.

Dormant roots: mount on first use. Loading the controller lazily only helps on pages with no reactive root: a root renders data-controller="reactive", and that is what makes Stimulus load and connect it. A root that only matters after a gesture (a closed dialog, a menu that loads on open) can stay dormant instead:

class ItemsPanel < ApplicationComponent
  include Phlex::Reactive::Component

  reactive_dormant # every root of this component (inherited)
  action :load

  def view_template
    div(**mix(reactive_root, on(:load, event: "panel:opened", once: true))) { … }
  end
end

div(**reactive_root(dormant: true)) { … } # or one render at a time

A dormant root renders data-reactive-dormant="reactive" in place of the controller attribute, so nothing mounts and a lazily loaded controller is not fetched. The first on(...)/on_client(...) trigger that reaches it β€” a window: hotkey pressed anywhere on the page included, an outside: one not β€” wakes it: phlex/reactive/early moves the identifier into data-controller, Stimulus loads and connects the controller, and the trigger is replayed once.

  • It needs import "phlex/reactive/early". Without that module nothing wakes the root: a link or form trigger just does its native thing (the link navigates, the form posts), and a dormant root nested inside an awake one hands its triggers to the OUTER component (they run the outer's action of that name with the outer's token, or get a 403). bin/rails phlex_reactive:doctor lists the dormant components and says where it found the import (an app JS entry, or an inline module script in a view or component). With more than one import map, every map needs the phlex/reactive/early pin: the doctor names each one without it, and Phlex::Reactive.importmaps = -> { { "landing" => landing_map } } adds maps that are not Rails.application.importmap.
  • The actor's own reply, a reactive_lazy(on:) materialize and the defer endpoint render the root awake, since that page's controller is loaded by then. Everything else renders it dormant: the page, a broadcast (broadcast_to, also inside an action), a page refresh, a deferred render pushed over a stream. If one of those lands on an awake root, the next trigger wakes it again. A to_stream_replace you build inside an action and send to other pages yourself is rendered awake, so broadcast with broadcast_to instead.
  • Use it for a root whose only behavior is its triggers. What the controller does at connect (reactive_persist restore, reactive_compute seeding, show/filter sync, dirty tracking) waits for the wake, and the browser inspector (inspect.js) does not list a dormant root.
  • With reactive_lazy: an event shell (reactive_lazy on: "panel:opened") can be dormant, and that event wakes and loads it in one go. on: :visible cannot (its shell has no trigger to wake on; declaring both raises), and a plain reactive_lazy shell always mounts, because it fetches on connect. On a lazy component use the class-level reactive_dormant: the shell is rendered by the framework and never sees a per-render reactive_root(dormant: true).
  • With a controller of your own on the same root, list it first: mix({ data: { controller: "dropdown" } }, reactive_root). Waking appends reactive, which then matches what an awake reply renders; in the other order the first morph reply reconnects both controllers once.
  • A dormant root nested inside an awake one belongs to the outer root until it wakes: its fields are collected with the outer root's actions.

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 (the whole client in one file, about 25 KB gzipped) with a linked sourcemap, and auto-pins it for importmap apps β€” so browsers load the small file while devtools still shows the real code. It imports three small modules by their bare names β€” phlex/reactive/confirm, phlex/reactive/confirm_predicate and phlex/reactive/compute, the seams you can override β€” so point your bundler at the gem's app/javascript directory (one prefix alias for phlex/reactive), or alias the controller and those three names. See docs/installation.md.

The split client (opt-in): a smaller core, features on demand

phlex/reactive/reactive_controller is one file with everything in it. That is the default, and nothing about it changes: one import, the whole client, nothing fetched later.

The same client also ships split in two, for an app that wants the smallest possible script on its light pages:

// instead of "phlex/reactive/reactive_controller" β€” never both
import ReactiveController from "phlex/reactive/core"
application.register("reactive", ReactiveController)

phlex/reactive/core is the controller without its feature modules. It imports one the first time a root on the page needs it:

Module Gzipped Default client Split client
phlex/reactive/reactive_controller 28.9 KB loaded with your controllers not loaded
phlex/reactive/core 13.5 KB not loaded (it is inside the file above) loaded with your controllers
phlex/reactive/features/persist (reactive_persist drafts) 3.2 KB inside the one file fetched when a root declares reactive_persist, or a persist_state / persist_clear op runs
phlex/reactive/features/defer (reply.defer, reactive_lazy) 2.8 KB inside the one file fetched when a root is a reactive_lazy shell, a morph turns one into a shell, or a reply.defer arrives
phlex/reactive/features/form (dirty tracking, warn_unsaved, the paste-trigger gate) 1.0 KB inside the one file fetched when a root tracks dirty fields or holds a paste_into trigger
phlex/reactive/features/effects (reactive_effects, dismiss_after) 1.7 KB inside the one file fetched when a root declares an effect, or when the first stream that needs it arrives: one with an effect, or with a dismissing flash
phlex/reactive/features/bindings (reactive_show, show targets, reactive_on_complete, reactive_filter, reactive_tags, reactive_css_vars, nested rows, the conditional confirm:) 8.4 KB inside the one file fetched when a root declares one of them, or holds a trigger with a conditional confirm
phlex/reactive/features/compute (reactive_compute, reactive_text) 2.1 KB inside the one file fetched when a root carries a compute binding
phlex/reactive/features/sortable (reactive_sortable drag and drop) 3.1 KB inside the one file fetched when a root is or holds a sortable or source list
phlex/reactive/features/drag (reactive_box, reactive_point draggable regions) 3.6 KB inside the one file fetched when a root is or holds a frame, a box or a point
phlex/reactive/features/countdown (reactive_countdown) 1.0 KB inside the one file fetched when a root is or holds a countdown
phlex/reactive/features/hints (optimistic:, busy:) 1.0 KB inside the one file fetched when a root holds a trigger that declares a hint
phlex/reactive/features/devtools (the latency simulator, the zero-target warnings, the debug trace) 1.6 KB inside the one file fetched with the core on a page that carries <meta name="phlex-reactive-env" content="development"> or while a delay is stored for the tab; at connect for a root in debug mode; otherwise the first time a verbose root has something to warn about. In production with none of these, never
phlex/reactive/early 1.2 KB on every page, if you import it the same

That is the whole split: everything that can leave the core has. What remains in it β€” the request pipeline, the signed token, field collection, client ops and on_client bindings, early-trigger replay, list navigation β€” is what every reactive page uses.

What you trade. With the default client a feature is simply there when a root connects. With the split client the first root of a page load that needs a feature waits for one small request (cached afterwards; on a Turbo visit the module is already loaded and nothing waits). While it waits:

  • a reactive_persist root shows what the server rendered. What the user changes is kept and drafted when the module arrives (also when they leave the page before it does); an action they fire waits for the restore and posts the restored values β€” unless the module is slower than the feature timeout (10 s), fails to load, or the root leaves the page first, in which case it goes out with the values on the page;
  • a reactive_lazy shell shows its placeholder a moment longer; an on: event that fires in that window loads the shell once the module is there;
  • a stream that brings the page's first effect or dismissing flash β€” a broadcast, an action's reply β€” is rendered when the effects module has arrived, so that very stream is animated and its flash dismissed on time (dismiss_after: counts from the render). Every stream that arrives while it waits renders after it, in order. The wait is capped at one second (under an import that never settles, each later stream that needs the feature pays its own one-second hold β€” bounded, never wedged β€” and a plain stream behind it waits with it, for order); after that the streams render without the effect, and a flash they brought is dismissed counting from the module's arrival instead. A root that declares an effect loads the module when it connects, so this only happens on a page where no root does;
  • a dirty-tracked form counts an edit made in the window when the module arrives. warn_unsaved: does not prompt for a navigation in the window itself: preload the module on a page where that matters (below);
  • a form's bindings β€” reactive_show, reactive_filter, the tag chips, the nested-row JSON field β€” show what the server rendered until the module arrives, then seed from the fields as they are: a choice made in the window is kept and reflected, a tag picked in the window is added, a conditional confirm: asks once the module can evaluate it (and proceeds without a dialog if the module cannot load or is slower than the feature timeout β€” the endpoint's authorization is the real gate). Because the JSON-mode rows write a field a request collects, a root with any of these holds its requests until the module is here (bounded as above);
  • a reactive_compute root shows the server's values for its outputs until the module arrives (about 20–25 ms with the module served from the same host, measured on the split client in the browser suite; one request's worth on a real network), then seeds; an edit made in the window is recomputed from the value the user typed, never overwritten. Such a root holds its requests until the module is here, for the same reason;
  • a trigger with an optimistic: or busy: hint: its request waits for the hints module (at most the feature timeout); the hint applies if the module arrives in time, and after a timeout or a failed load the request goes out without it β€” once either way. The busy markers (aria-busy, data-reactive-busy, busy_on) are on from the click, as always; a failure after a late apply still reverts the optimistic hint. A root whose triggers declare a hint loads the module when it connects, so this is the first click of a page's first root at most. In that window a busy: { disable: true } hint cannot stop a second click: three rapid clicks are three ordered requests (the default bundle's synchronous disable blocks the second and third);
  • the latency simulator delays a request from the first one on (the request waits for the module); a zero-target warning on a verbose root, or a debug trace, is logged once the module is here. enableLatencySim / disableLatencySim are exported by phlex/reactive/features/devtools here, not by the controller.

None of that exists with the default client.

Taking the small core without the network wait. Preload the features you know a page uses:

# config/importmap.rb β€” after the engine's own pins
pin "phlex/reactive/core", to: "phlex/reactive/core.min.js", preload: true
pin "phlex/reactive/features/persist", to: "phlex/reactive/features/persist.min.js", preload: true
pin "phlex/reactive/features/form", to: "phlex/reactive/features/form.min.js", preload: true # the unsaved guard a task after connect
pin "phlex/reactive/features/bindings", to: "phlex/reactive/features/bindings.min.js", preload: true # show/filter/tags a task after connect
pin "phlex/reactive/features/compute", to: "phlex/reactive/features/compute.min.js", preload: true # computed fields a task after connect

(or a <link rel="modulepreload"> on the pages that need it). The module is then already loaded, so there is no network wait β€” but a preloaded module still arrives through import(), which resolves in a later task: the feature connects in the task after connect(), not inside it (measured: with modulepreload the compute seed lands 29–51 ms after reactive:connect on localhost), featuresReady is pending for that moment, and the first root's request gate applies to it. The default bundle is the only way to get connect-time seeds. A root that is dormant fetches nothing at all β€” not the core, not a feature β€” until its first trigger; the trigger is then replayed after the core has loaded, and waits for a feature like any other.

Bundlers. The core reaches a feature with a literal import("phlex/reactive/features/persist"). Give your bundler one prefix alias for the gem's JavaScript directory, which covers the core, the seams and every feature, now and later:

// esbuild (build script)
import * as esbuild from "esbuild"
import { execSync } from "node:child_process"
const gemJs = `${execSync("bundle show phlex-reactive").toString().trim()}/app/javascript`

await esbuild.build({
  // …
  splitting: true, format: "esm", // each feature becomes its own chunk
  plugins: [{
    name: "phlex-reactive",
    setup(build) {
      build.onResolve({ filter: /^phlex\/reactive\// }, ({ path }) => ({ path: `${gemJs}/${path}.min.js` }))
    },
  }],
})

(Vite/webpack: the same rule as a prefix resolve.alias for phlex/reactive.) Without code splitting the features are bundled into your entry, which is the default client with extra steps. With exact per-module aliases the build fails on the feature it cannot find (esbuild: Could not resolve "phlex/reactive/features/persist"). If you pin copied files by hand in an import map, pin phlex/reactive/core and each phlex/reactive/features/* too; a feature that cannot be imported logs one console error naming it, marks the root data-reactive-error="feature", emits reactive:error (kind: "feature") and does nothing. The specifiers are bare, not relative, because Propshaft serves each file under its own digest and only the import map can resolve one to another (issue #57).

One or the other. Each entry carries its own copy of the client's state. Loading both logs the client was loaded twice to the console.

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:actions UNVERIFIED=1 # only actions with no detected authorization AND no skip
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"`)

The auth column reads authorized* (an authorization call was detected β€” a heuristic), skipped / skipped (class) (declared skip_verify_authorized, per action or component-wide), or unverified (neither β€” review these).

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_to(stream, replace: model) β†’ 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_to(js:) 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
Project board (flagship) The kanban: cards move across lanes with enter/exit effects (per-visitor style picker incl. random), live count badges in every tab via broadcast_to(js:), nested reactive rows with inline rename, confirm-gated archive

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 and the Project board are the flagships β€” every feature composed into believable UIs.


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_to(*streamables, replace: model, morph: false) Broadcast a replace over the stream transport (pgbus SSE / Action Cable); morph: true morphs in place
.broadcast_to(*streamables, update: 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_to(*streamables, append: model, target:) / prepend: / remove: The other broadcast verbs β€” the verb is a kwarg (exactly one of replace:/update:/append:/prepend:/remove:/js:)
.broadcast_to(each: stream_keys, replace: model, morph: false, exclude:, visible_to:) Render once, fan out the same payload to K different stream keys β€” a per-tenant loop. Pass each: instead of *streamables. 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(morph: false) / #to_stream_remove Stream the already-built instance (used internally after an action / by reply); morph: true 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. compute: :name binds a client-side compute at the root β€” descriptors plus the input->reactive#recompute delegation, so no field needs its own wiring; nil collapses to no binding. See Client-side computes.
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.
reactive_dormant / reactive_root(dormant: true) Mount the controller on first use: the root renders data-reactive-dormant="reactive" instead of data-controller="reactive" until one of its triggers fires. Class-level and inherited (reactive_dormant false turns it off again), or per render (reactive_attrs(dormant: true) too). Needs import "phlex/reactive/early". See the dormant roots note under Installation.
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(:track, event: "drop", prevent: false) Keep the event's native default on an element-bound trigger: the client skips its preventDefault. See Keep the native default.
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, debounce:/throttle: waits (ms) and prevent: false as on, plus detail: (run only when event.detail carries the listed values). See Client-only ops.
js The immutable op builder behind on_client: show/hide/toggle (the hidden attribute, with an optional transition: and expanded:), add_class/remove_class/toggle_class, set_attr/remove_attr/toggle_attr (allowlisted names; toggle_attr also flips between two values), focus/focus_first/blur, show_modal/close_dialog (a real modal <dialog>), copy_media (copy src/srcset/alt from a server-rendered <img>, no URL argument), text (set textContent β€” XSS-safe), set_value (set a field's live .value and dispatch input), dispatch, submit (requestSubmit the target's own form, optionally through a submitter:), paste_into (read the clipboard into a field, gesture-gated), preview_file (paint a chosen image file onto an img through an object URL the client makes and revokes), check_group (tick or untick a checkbox group, then re-sync its bindings), and persist_state/persist_clear (the reactive_persist draft) β€” chainable. keep: true on the visibility, class and attr ops makes their result survive a morph.
reactive_field(:param, **attrs) The attribute hash that binds a control to an action param (no magic name:) β€” spread onto any control: input(**reactive_field(:value, value: @record.name)), select(**reactive_field(:status)) { … }.
reactive_shared_field(name:, value: nil, **attrs) A control inside a nested component that its host's actions post too (#434): input(type: "hidden", **reactive_shared_field(name: "item[choice]", value: @choice)). Collected by its own component and by the next server component above it, one level only, and read by that host's client bindings (reactive_show, disable: true, reactive_enable, show targets, computes; #440); name: is the host's param, verbatim (never re-scoped); a blank name raises at render. See Nested reactive components compose below.
reactive_editable(:name, multiline: false, placeholder: nil, on_enter: nil, on_escape: :revert, spellcheck: true) A plain-text editable element as a field (issue #351): h3(**reactive_editable(:title)) { @card.title }. Its text travels under the scope-aware name: with multiline: true its line breaks are sent as \n; a single-line field (the default) folds them to spaces; a morph keeps the focused element's text and caret (attributes still morph); paste is plain text; Enter runs on_enter: (a single-line field swallows it); Escape reverts. reactive_dirty and reactive_persist treat it like an input. See the Editable text page.
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; { same: :email } compares two fields, { digits: 10 } checks a digit format, after: :blur waits for the field's first blur, and clear:/focus:/scroll: run when the section changes visibility. See Value-conditional visibility.
reactive_media(from:, pool:, take:, match:, when:, pending:, label:, blank_label:) A preview <img> that follows a field (#373): the field's value (its first take: characters, or all of it) is a key into pool, whose values are selectors for <img> elements the server rendered β€” never URLs. A hit copies that image's src/srcset/alt like js.copy_media (a pool <img> with an alt and no src is a hit without an image, #381); a miss clears the preview. label: gets the alt (or blank_label) as textContent. Below take: characters, or while match: (a JavaScript-compatible Regexp over the whole key) rejects the key, the binding is pending (#380) β€” never blank_label (Regexp options or Ruby-only syntax raise at render); when: (same rules) tests the whole field before the pool lookup while the key stays the prefix, so take: 3, when: /\A\d{3,24}\z/ keeps 007bad pending (#390); pending: :clear (default) clears the preview and label, :keep leaves them. The preview's data-reactive-media-state reads pending/hit/miss, so CSS can hide its row. See Media previews.
reactive_image_fallback(hide: true, show_alt: nil, wrap: nil) An <img> that falls back and recovers (#383): on error it gets data-reactive-image-state="error"; hide: (default true) also hides it, and show_alt:, independently, writes its alt into that node and shows it; wrap: (#392) hides the box around it (marked data-reactive-wrap-hidden, shown again only when it carries that mark, its text never written); load sets "loaded". An img that finished before Turbo inserted it (a frame, a page or a stream render, #452) is marked once it is in. A later src (from copy_media, reactive_media, preview_file, a morph, or any src that loads) shows it again; removing the src is not an error. Composes with reactive_media(…, **reactive_image_fallback). See Client-only ops.
reactive_countdown(until:, format: :ms, waiting:, done:) A server deadline counted down on the client (#400): until: is an ISO8601 date-time with a zone (or a Time); the text is the time left from Date.now (1:30), as textContent, once a second and again when a hidden tab is shown. At zero (or already past on connect) it reads 0:00, data-reactive-countdown-state="done", waiting: hides, done: shows and reactive:countdown-done fires once per deadline (a morph that keeps until: does not fire again; a new until: restarts). The server renders the first text and visibility. No request, no frame reload. See Client-only ops.
reactive_css_vars(to:, vars:, global: false) Fields that feed CSS custom properties on an element (#417), for a preview that follows a control with no request: vars: { "--light" => "light", "--turn" => { field: "turn", unit: "deg" } }. Each field is read as reactive_show reads one (a checkbox gives 1/0, a radio group its checked value) and parsed as a finite number; the client writes number + unit with style.setProperty, and anything else (empty, text) removes the property so the var() fallback applies. No text from a field reaches CSS. Units: "", px, %, deg, turn, ms, s, em, rem, fr; names match --[a-zA-Z0-9-]+; a bad name or unit or a blank field raises at render. Runs on connect, on input/change and after a morph. to: resolves like a client op's (:root, inside the root, global: true, an Array). Render the same properties inline for the first paint. See Client-only ops.
reactive_enable(if:/if_any:/unless:) The sibling of reactive_show that flips the element's own disabled (a button, a fieldset) instead of hidden β€” same conditions, same reactive_values first paint. button(**reactive_enable(if: { "ids[]" => { checked: 1.. } })). See Bulk selection.
reactive_select_all("ids[]") / reactive_count("ids[]") A checkbox group's header box (ticks/unticks every owned box; shows checked / indeterminate / unchecked) and its ticked count as text β€” re-synced on every change and when rows are appended or removed. under: "a.b" makes a nested header over the leaves whose data-reactive-group is in that path. See Bulk selection.
reactive_group_targets("ids[]", count: "#c", enable: { "#b" => 1.. }) Spread on the root that owns a checkbox group: drives a count and an enable on outside, id-allowlisted elements (a bulk bar the page renders). reactive_group_target_attrs gives that outside markup its first paint. See A bulk bar outside the list's root.
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..); a "#id" key takes a full conditions Hash for a multi-field predicate ("#warn" => { if: { type: "trade", price: ..0 } }). Id selectors only (raise at render + client warn-skip); toggles hidden only. See Value-conditional visibility.
reactive_persist(key:, ttl: 7.days) Client-only drafts: spread on the root (once) and the generic controller keeps a localStorage draft of every owned control β€” debounced write on input, immediate on change, flushed on disconnect, restored into blank controls on the next connect (restore: :always lets the draft win), cleared by a successful Turbo submit / ttl / js.persist_clear. Never hidden/file/password; reactive_persist_skip opts a control out; fields: narrows; storage: :session keeps the draft in this tab's sessionStorage instead. See Client-only drafts.
js.persist_state(step: 2) / js.persist_clear The draft ops (actor-only): merge a flat state bag into the draft (restored as data-reactive-persist-state + the reactive:persist-restored event) / forget the draft.
reactive_filter(:field, option: nil, group: nil, empty: nil, match: :substring, fold: false) Client-side option filtering for a preloaded combobox: spread onto the root and name the field that drives it β€” reactive_filter(:q) compiles :q to [name="q"] (scope-aware) and typing shows/hides the options by their data-reactive-filter-text haystack, zero round trips. option: defaults to [role=option]; optional group: collapses an all-hidden group header; empty: reveals a no-matches node. match: is where the query must sit in the haystack β€” :substring (anywhere, the default), :prefix (at its start) or :word_start (at the start of any word); fold: true strips accents (combining marks) on both sides ("zur" finds ZΓΌrich). input: is the escape hatch β€” a raw CSS selector for a name-less driving input (input: "#tags_query"), the form-builder case. 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_listnav(focus: true) is the roving-focus variant for a role="menu" container: Arrow Down/Up move real focus among [role=menuitem] (wrapping), Home/End jump to the edges; Enter/Space stay native and Escape is yours to bind.
reactive_tags(:tags) Tag-chip input (the combobox/tags widget): spread onto the root and name the hidden field that stores the comma-joined value β€” the client maintains that field + the chip list entirely client-side (form state, zero round trips), rebuilding chips from your server-owned <template>. Composes with reactive_filter (type to narrow) and reactive_listnav (Enter picks the highlighted option). name: is the escape hatch β€” a verbatim wire name (name: "user[tags]", never re-scoped), the form-builder case. See Tag-chip input.
reactive_tags_add / reactive_tags_option(tag) / reactive_tags_remove(tag) The tags triggers, all client-only: reactive_tags_add on the query input adds the typed text on Enter (mix it after reactive_listnav; Enter never submits the enclosing form); reactive_tags_option makes a preloaded suggestion add its declared tag on click; reactive_tags_remove is a chip's remove button (no arg inside the template β€” the client fills the tag per chip).
reactive_compute :name, inputs: { title: :string, qty: :number }, outputs: Typed inputs: a :string reaches the JS reducer raw, a :number is coerced through Number, a :boolean is a checkbox's checked state. The array form (inputs: %i[a b]) stays all-numeric; the permit-style form (inputs: [:qty, title: :string]) mixes both β€” bare symbols default :number, a trailing hash types the exceptions. outputs: is the field allowlist; a reducer-result key also paints any owned reactive_text node by presence and any mirror: id, so an outputs: entry that exists only to reach a text node is redundant (harmless β€” a widening).
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_dirty / reactive_dirty warn_unsaved: true / reactive_dirty only: %i[...] Dirty tracking, declared once at the class level, 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; only: scopes tracking to named fields. 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_nested_list(:assoc, as: :attributes | :json) / reactive_nested_template(:assoc) / reactive_nested_row Draft nested-attribute rows (the "new parent + child rows" form): the container rows land in, the <template> holding ONE row's markup, and the row wrapper marker β€” all client-only form state, keyed by association (several collections per root). as: :json (default :attributes) serializes the rows into ONE hidden JSON field instead of posting accepts_nested_attributes_for names β€” for an app whose controller JSON.parses a serialized param; name: names that hidden field verbatim (name: "order[todos]", never re-scoped β€” the form-builder escape hatch, JSON mode only). See Draft rows for a new parent.
reactive_nested_add(:assoc, from:, clear:) / reactive_nested_remove(confirm:) The row triggers, client-only (zero round trips): add clones the template and renumbers its placeholder index; remove deletes a draft row from the DOM, or _destroy-marks + hides a persisted row (a hidden [_destroy] input present). Fill-then-add: from: { row_field => "#source-selector" } seeds the new row from add controls that live OUTSIDE it (a preset select, a typeahead), and clear: true resets them β€” composes with as: :attributes AND as: :json. Confirm on remove: reactive_nested_remove(confirm: "Really delete this row?") gates the remove behind the same overridable confirmResolver as on/on_client (a per-row string, or the conditional { when:, message: } Hash). See Draft rows for a new parent.
nested_field_name(:assoc, :field, index: nil) The Rails accepts_nested_attributes_for wire name for one row field β€” order[line_items_attributes][NEW_ROW][quantity] (the template placeholder) by default, a real index when given. Scope-aware under reactive_scope; scope: overrides it per call (scope: "order", verbatim β€” the form-builder escape hatch).
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).
reply.pending(records, in:, job:) { … } + include Phlex::Reactive::Settles / reactive_settle For an action that enqueues work: mark targets pending, let your job settle them (row + count + empty-state) when it finishes. See Async actions.
Container.broadcast_collection_to(*keys, container:, in:, append:/prepend:/remove:) The broadcast-side reply.append/reply.remove: the row plus the count companion plus the empty-state toggle, to peers.

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.

Named param schemas (Phlex::Reactive.param_schema). Register a reusable schema once so sibling components stop duplicating a verbatim params Hash that drifts β€” follows the same boot-only contract as param_type:

# config/initializers/phlex_reactive.rb
Phlex::Reactive.param_schema :todo, title: :string, done: :boolean

# then, in any component:
action :save, params: :todo

# or compose it into a bigger schema:
action :bulk, params: { **Phlex::Reactive.param_schema(:todo), note: :string }

Reading an unregistered name raises, listing the ones that are registered.

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. A Rails-bracketed field name (blog_post[summary], blog_post[image]) expands the same way β€” params[blog_post][summary], never the unparseable params[blog_post[summary]] (#231). 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

Clearing a checkbox group next to a file. When the reactive root holds a file, the action goes out as FormData, and FormData has no way to write an empty array. A group with values is fine: each value goes out as params[name][]. A group with nothing ticked would simply be missing, so the client lists its name in a separate field, empty_groups[], and the endpoint sets that param to []. The action gets the same value it would get over JSON.

This works for any param the action declares as an array, by its plain name or with the component's reactive_scope in front. A features[] group is announced as features, and a todo[tags][] group under reactive_scope :todo as todo[tags]. Under a scope you write the group's name by hand, because reactive_field(:"tags[]") gives todo[tags[]], which isn't read as a group:

input(type: "checkbox", name: "todo[tags][]", value: "ruby")   # reactive_scope :todo

The endpoint only fills a key the body didn't send, and only at the top level. A group declared one level down (params: { project: { features: [:string] } }) or inside nested attributes is left alone, and the action gets its keyword default as before. With verbose_errors on, the log tells you when that happens.

Other empty [] or {} params are still left out of a form body. If you rely on tags: [] to clear something that isn't a [] group, send that action without a file.

Checkbox groups. Controls sharing a name that ends in [] are collected as an array of the chosen values β€” a ticked box contributes its value, an unticked one nothing, a <select multiple> its selected options. Nothing ticked is an empty array, not a missing key, so an action can tell a cleared group from one that never rendered, over a form body as well (see above). Declare it as an array type:

action :save, params: { features: [:string] }   # <input type="checkbox" name="features[]" value="news">

Three shapes keep their own meaning: a lone checkbox without [] stays the documented yes/no boolean; a radio group keeps its single checked value, [] or not; and a hidden input sharing a name with a checkbox is that box's companion (Rails' check_box emits one, carrying the unchecked_value) and contributes nothing. A hidden input without a same-named checkbox is an ordinary value β€” the usual shape for a list maintained by JS.

The suffix is the only trigger, and it applies to a single control too: a lone <input type="text" name="tags[]"> posts ["abc"] where it used to post "abc". Declare such a param as an array type (tags: [:string]) β€” against a flat tags: :string the array coerces to its literal to_s. If the [] was never meant as a list, drop it from the name.

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.

The exception is reactive_scope :invoice: the endpoint expands the names and then peels that one level, on the JSON body and the multipart one alike, so the flat schema is the one to declare. Bare params posted beside the scoped fields (a trigger's on(:save, note: "x")) stay at the top level next to them; as on the client, an explicit on(...) param wins over a field of the same name.

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.

A client-only helper (a Phlex::Reactive::ClientBindings component: a reactive_filter list, a reactive_show wrapper) is the exception. Its root has no token and no action, so it is transparent to server round trips: an on(...) trigger inside it dispatches on the nearest server component above, and that component's actions collect the controls inside it. Wrap a long checkbox list in a searchable helper and its boxes still post to the form around it (#410). The helper's own bindings stay on the helper's root. A lazy or deferred shell (reactive_lazy, reply.defer) is never treated as client-only, with or without a token: it is a server component still loading, so it stays a boundary.

A shared field is the opt-in exception for a nested server component (#434). A form widget with actions of its own (a picker that keeps the chosen id in a hidden field) marks the field its host must post:

# Inside the picker component, itself rendered inside the item form:
input(type: "hidden", **reactive_shared_field(name: "item[choice]", value: @choice))
# => name="item[choice]" value="…" data-reactive-shared-field

The picker's own actions still collect it, and so do the actions of the next server component above the picker (client-only roots in between stay transparent). One level only: a component above that host collects it only when the host marks a copy of its own. The widget's unmarked fields stay its own. The name goes on the wire verbatim (it is the host's param, never re-scoped under the widget's reactive_scope), on the JSON body and the multipart one alike; a name the host also renders follows the usual rule for a plain name, the later control in the document wins. The widget's own actions receive the field under that same name, so declare it there too when an action reads it (action :pick, params: { item: { choice: :string } }); otherwise the action drops it (with Phlex::Reactive.verbose_errors on, the default in development and test, the log's dropped-param line names it). A conditional confirm: judges the fields the action is about to post, so it sees the shared field too.

The host's client bindings read a shared field as well (#440), with the same one-level rule, so a client-only widget (a ClientBindings picker with its own root for a filter and its on_client ops) can keep its value field inside that root:

# Inside the picker's root:
input(**reactive_shared_field(name: "choice", required: true, value: @choice))

# In the host, around and beside the picker:
div(**reactive_show(if: { mode: "other" }, disable: true)) { render Picker.new }
div(**reactive_show(if: { choice: "b" })) { "b picked" }
button(**mix(on(:save), reactive_enable(if: { choice: "b" }))) { "Save" }

reactive_show (every term, after:, clear:), reactive_enable, reactive_show_targets, reactive_group_targets, the checked_* counts, reactive_on_complete, reactive_media and reactive_css_vars all read it, and a disable: true group disables and re-enables it with its own controls, so a required field in a hidden section never blocks a native submit. For a reactive_compute it is an input (its edits recompute the host) and, when its name is a declared output, an output the host writes. When the widget re-renders (a morph or a plain replace) or restores a reactive_persist draft, the host's bindings re-sync against the new field, and its compute re-runs when it seeds (the default). The widget's unmarked fields (its query input) stay its own, and a nested root's own bindings are still evaluated by that root alone. Client ops, listnav, busy_on, reactive_filter, a select-all header, dirty tracking and reactive_persist keep strict ownership, so the field is the widget's there. A native <form> posts it anyway, like any named control.

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". An element-bound trigger is prevented unless it says prevent: false (see Keep the native default).
  • 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.

Page-level shortcuts β€” shortcut:. Undo, redo, save and a command palette bind with one keyword on on(...) or on_client(:keydown, …):

div(**mix(reactive_root, on_client(:keydown, js.toggle("#palette"), shortcut: "mod+k"))) do
  span(**on(:undo, shortcut: "mod+z"))         # one reactive action per element
  span(**on(:redo, shortcut: "mod+shift+z"))
  textarea(name: "notes")
end

A shortcut is a window keydown binding with four rules a plain event: "keydown.meta+z", window: true does not have:

  • mod is ⌘ on Apple platforms and Ctrl elsewhere. The helper renders a meta twin and a ctrl twin; the client runs the one for its platform (from navigator.userAgentData.platform, else navigator.platform), and the other is a complete no-op, so Ctrl+Z on a Mac stays the browser's. A chord without mod ("esc", "alt+n") renders one descriptor.
  • Typing is left alone. With focus in an input, textarea, select or anything contenteditable (including reactive_editable), the shortcut does nothing and the browser's own handling (text undo) runs. Opt in with the Hash form: shortcut: { keys: "esc", in_editable: true }.
  • A key something else handled is left to it. When a handler inside the page already called preventDefault on the keydown β€” Escape cancelling a drag (reactive_box, reactive_point), an element-bound on/on_client binding on keydown.esc, your own listener β€” the shortcut does nothing (issue #429). Another shortcut's preventDefault does not count: two shortcuts on one chord both run. To run regardless, use the Hash form: shortcut: { keys: "esc", handled: true }. One exception: while a root with a shortcut on that chord has not connected yet, phlex/reactive/early prevents the key for it first (before any other handler), so a drag's Escape then still runs the shortcuts on it.
  • The chord is prevented. A matching shortcut calls preventDefault synchronously, before any confirm: dialog, so mod+s does not also open the save dialog. (Other window bindings are still never prevented.)

shortcut: decides the event and the window binding: passing it with event:, window: or outside: raises ArgumentError, as does a key outside Stimulus's default key map (a letter, a digit, enter, tab, esc, space, the arrows, home, end, page_up, page_down; bind a custom keyMappings key with event: and window:) other than delete and backspace. On on_client the event must be bare :keydown or "keydown".

delete and backspace are matched by the library rather than Stimulus (its default key map lacks them, and a keydown.delete filter makes Stimulus throw at keypress): the shortcut binds a bare keydown@window and the client matches the chord, with every rule above β€” on(:remove_selected, shortcut: "delete") never fires while typing in a field. They cannot take once: (the first key pressed of any kind would spend the bare binding). shortcut is now a reserved keyword on on(...).

once: true on a shortcut unbinds the Stimulus descriptor after its first firing, and a press inside a field counts: the guard leaves the chord to the browser, but the :once listener is already spent. So does a press another handler already handled (a drag's Escape). Keep once: off a shortcut that can be pressed while the user types or while a drag is on, or pair it with in_editable: true and handled: true.

Keep the native default β€” prevent: false

An element-bound trigger (on or on_client) calls preventDefault() before its ops or action run, so a bare button inside a <form> cannot submit it. (Once the controller is connected, a binding whose key filter, detail: or spent once: skips the event leaves it alone; before that, phlex/reactive/early prevents every firing that matches a descriptor.) Some listeners only observe an event and need the browser to carry on: a drop zone built from a native <input type="file"> stretched over an area gets the dropped file only when nothing cancelled the drop. prevent: false (#433) skips the preventDefault for that trigger, and its ops or action still run:

div(id: "zone", class: "relative", **mix(
  on_client(:dragenter, js.add_class("#zone", "is-over")),
  on_client(:dragleave, js.remove_class("#zone", "is-over")),
  on_client(:drop, js.remove_class("#zone", "is-over"), prevent: false) # keeps the drop
)) do
  input(type: "file", name: "file", class: "absolute inset-0 opacity-0")
  plain "Drop a file here"
end

div(**on(:track, event: "drop", prevent: false))  # the same keyword on a server trigger
  • The default, prevent: true, renders no attribute: existing markup is byte-identical.
  • It belongs to its own binding: a sibling on_client on another event (the dragenter above) is still prevented, and so is the event when another binding on the same element and event does not opt out.
  • A window: or outside: trigger never prevents, so prevent: false changes nothing there; with shortcut: (a chord the page claims) it raises ArgumentError. Anything but true/false raises too.
  • A click trigger keeps its forced type="button"; a button that should submit its form natively says so with mix(on(...), type!: "submit").
  • With confirm:, this trigger does not cancel the native default, whatever the dialog answers: the dialog gates only its ops or action.
  • phlex/reactive/early follows the same rule before the controller connects.
  • prevent: is a reserved keyword name on on(...), like the event modifiers.

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 (reactive_dirty)

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.

reactive_dirty is declared once, at the class level, alongside your other macros (reactive_record/reactive_state/action) β€” not as a reactive_root or reactive_field keyword:

class TodoForm < ApplicationComponent
  include Phlex::Reactive::Component

  reactive_record :todo
  reactive_dirty warn_unsaved: true
  action :save, params: { title: :string }

  def view_template
    div(**reactive_root) do
      input(**reactive_field(:title, value: @todo.title))
      span(class: "unsaved-badge") { "Unsaved" }   # shown via [data-reactive-dirty] CSS
      button(**on(:save, disable_with: "Saving…")) { "Save" }
    end
  end
end
  • reactive_dirty (no args) wires every input under the root to a full re-scan on change. reactive_dirty only: %i[title] scopes tracking to the named fields only β€” each carries its own descriptor instead of the root delegating for the whole subtree (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 β€” for a focused field, once the saved value matches what is in the box. (A morph keeps a focused field's in-progress value while writing its fresh default β€” the client does that, issue #338, since Turbo's stream morph would overwrite it β€” and the post-morph re-scan is what keeps the root count honest in that state.)

reactive_dirty 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 editors (Lexxy, Trix) and bare contenteditable elements have no default* baseline and are not tracked; a reactive_editable is (against the text of its last server render, #351). Combining reactive_field 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; :trigger (#442) targets the element whose on_client binding fired (see "One binding for a list" below); 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.

One binding for a list (#442). A list whose rows each write their own value binds once: within: delegates the record to the rows, :trigger is the row that was picked, and from: + attr: read the row's value off it instead of baking a literal into every row:

ul(id: "menu", role: "listbox", **on_client(:click, js
  .set_value("#item-value", from: :trigger, attr: "data-value")
  .text("#item-label", from: :trigger, attr: "data-label")
  .remove_attr("[role=option]", "aria-selected")
  .set_attr(:trigger, "aria-selected", "true")
  .dispatch("app:item-picked", detail_from: { id: "data-id", text: :text }),
  within: "[role=option]")) do
  @items.each do |item|
    li { button(role: "option", data: { id: item.id, value: item.value, label: item.label }) { item.label } }
  end
end
  • within: "[role=option]" runs the record only for an event from inside an element matching the selector, under the bound element and owned by this root (a row of a nested reactive root is that root's). That element is the ops' :trigger. Anywhere else (the list's padding, a heading) is a complete no-op: no preventDefault, no confirm, no ops. Keyboard picking needs reactive_listnav on the search input (not on the rows): Enter on the row it highlights clicks that row, which goes through the same binding, and so does a click before the root connects (phlex/reactive/early queues a row's click and leaves a click outside every row alone, so a link in the list still navigates; a row inside a nested reactive root is held too, and its replay is a miss). A within: selector that does not parse runs nothing, warned under verbose_errors. The list is not forced to type="button". It raises with window:, outside:, shortcut: and once: (Stimulus drops a :once listener on its first event, a miss included), and on an event that does not bubble (focus, blur, mouseenter, mouseleave, …: the list never hears it from a row; use focusin, focusout, mouseover, mouseout).
  • :trigger is accepted wherever an op takes a target, and by expanded:, submit(submitter:) and copy_media(from:). Without within: it is the element the binding is on. set_value, paste_into and preview_file refuse it as the field they write, and preview_file as its into:, name: and wrap:.
  • from: + attr: on set_value, text and set_attr replace the literal value: from: is :trigger, :root or a selector (the op's own scoping, global: included, first match); attr: is a data-* attribute or :text (the element's textContent) β€” nothing else, at render and again in the client. A missing attribute reads "" (it clears); a from: that matches nothing skips the op. The write side keeps its own rules (set_attr's name allowlist, text's textContent, set_value's .value).
  • dispatch(…, detail_from: { key => attr }) builds the event's detail off the trigger, every value a String, merged over detail: (a key in both raises, and so does a detail: that is no Hash).
  • A server-pushed op has no trigger: reply.js, broadcast_to(js:) and reactive_on_complete raise on :trigger, from: :trigger and detail_from:. Nor does a window-bound on_client (window:, outside:, shortcut:): its :trigger ops resolve to nothing, warned under verbose_errors.

In development and test (verbose_errors), an op whose selector resolves to zero elements also warns once per unique (op, selector, scope) β€” with a targeted hint when the element exists but sits outside the op's scope: "use to: :root" when the selector matches the component's own root (root-scoped resolution never includes the root itself), or "use global: true" when it matches only inside a nested reactive root or elsewhere in the document. Production stays silent β€” a zero-match no-op is legitimate there.

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: { during: "transition-opacity", from: "opacity-0", to: "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. toggle_attr(to, name, on, off) (#271) flips between two values instead of toggling presence β€” toggle_attr("#trigger", "aria-expanded", "true", "false"); an absent attribute becomes on, and one value or two equal values raise at build time. 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, srcset, 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.

  • copy_media(to, from:) (#373) copies src, srcset and alt from the from <img> onto the to <img> β€” how a preview shows an image the server already rendered while src stays refused. Both are elements (:root or a selector); the URL is read from the live source element, never passed. A missing source or a non-<img> source clears the target; an <img> source without src removes the target's src/srcset and copies its alt (#381), so an entry with no image keeps its name; a non-<img> target is warned about and skipped; an attribute that already holds the copied value is not rewritten, so the same image twice does not reload. Allowed in a broadcast (it only reads the subscriber's own page).

  • 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).

  • blur(to) (#399) blurs the focused element when it is the match or sits inside it, so Escape ends a menu's :focus-within (on_client("keydown.esc", js.blur("#menu"))); focus elsewhere on the page is left alone. Actor-only like focus: refused in broadcasts.

  • show_modal(to) / close_dialog(to) (#365) call HTMLDialogElement#showModal() / #close(): the top layer, a focus trap, ::backdrop, Escape to close and focus back on the opener β€” all the browser's own. A dialog already open non-modally is reopened as a modal; a target that is not a <dialog> is skipped. A dialog the server renders open with data: { reactive_modal: true } (a full page, a reply, a broadcast) is made modal as soon as the client sees it, and closed with close() when a morph renders it without open. Actor-only like focus: refused in broadcast_to(js:) β€” render the dialog open or closed, with the marker, instead.

  • 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: {}, detail_from: {}) 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, and detail_from: (#442) adds values read off the trigger.

  • submit(to = :root) commits the target's own form via requestSubmit(): the target itself when it is a form, its form owner for a control (input.form, honoring a form= attribute), else the nearest ancestor form. Constraint validation runs and a real cancelable submit event fires, so it composes with both a native/Turbo form and an on(:save, event: "submit") interception. Actor-only like focus: allowed from on_client / reply.js / a reducer's $ops, refused in broadcast_to(js:) (a broadcast would force-submit every subscriber's form). Binding a submit op to the submit event itself raises at render β€” it would re-fire itself forever. submitter: (a CSS selector, resolved with the op's own scoping) submits through that control β€” form.requestSubmit(submitter) β€” so the request carries its name=value: js.submit("#bulk", submitter: "#delete-submit") posts action=delete. It composes with on_client(…, confirm:) (a cancelled confirm submits nothing). A submitter that is not a submit control of that form warns and falls back to a plain requestSubmit(). Note that a selector target resolves inside the root, so when the form is the reactive root, use the default target: js.submit(submitter: "#delete-submit").

  • set_value(to, value) fills a field β€” an input, textarea or select β€” from text already on the page (issue #374): it assigns the live .value (never the attribute, never parsed as HTML; nil and "" clear) and dispatches one bubbling input when the value changed, so reducers and reactive_show react as if typed. No change, no focus (chain js.focus). A select selects the matching option; a file input accepts "" only. A contenteditable or any other non-field warns and is skipped; :root raises. On a checkbox or radio it sets the submitted value, not the checked state (use check_group or a click for that). js.set_attr(…, "value", …) still writes only the attribute. Actor-only: refused in broadcasts.

  • paste_into(to) reads the clipboard into a field on a user gesture (issue #228): navigator.clipboard.readText(), then the field gets the text through the normal input pipeline β€” .value is set, a bubbling input event fires (compute reducers, reactive_show, reactive_on_complete all run exactly as if the user had typed), and the field is focused so a partial paste continues from the caret. Built for fields whose real <input> is visually hidden (an OTP cell UI), where right-click β†’ Paste can't reach the editable input. The permission UX is the browser's own (Chromium prompts, Safari shows its paste pill); a denied/dismissed read, empty text, or a missing API is a silent no-op. on_client marks the trigger with data-reactive-clipboard and the controller sets hidden = !available on connect β€” author the trigger hidden and it is revealed only where the clipboard API exists, so a dead button never shows. The gate owns the trigger's hidden flag: render the trigger unconditionally and don't also bind reactive_show to it (the two passes would fight over the same attribute). Actor-only like focus/submit: refused in broadcast_to(js:) (a broadcast that reads every subscriber's clipboard would be hostile). The op is async fire-and-forget β€” chained siblings apply immediately, never waiting for the read.

  • preview_file(to, into:, name: nil, global: false, valid: false, wrap: nil, max_bytes: nil, accept: nil, per_file: false, rejected: nil) (#375; global: true resolves to, into:, name:, wrap: and rejected: document-wide, for an img outside the root) paints the file a file input holds onto an img before any upload: input(type: "file", id: "photo-file", accept: "image/*", **on_client(:change, js.preview_file("#photo-file", into: "#photo", name: "#photo-name"))). On change the client takes the input's first image/* file (the default list; an explicit accept: below replaces it), makes an object URL from it (URL.createObjectURL), sets it as the img's src, and puts file.name in the name: node's textContent. An empty selection or a non-image file removes the src and clears the name. The op carries no URL β€” there is no parameter for one and the client never reads a url key β€” so set_attr of src (and srcset, #373) stays refused (#96); only an <img> ever gets a src (an into: that is anything else warns and is skipped, as is a to that is not an input[type=file]). The runtime keeps the last URL per img and revokes it on a re-pick, a clear, and when the img leaves the page or a morph strips its src β€” a server re-render resets the preview like any other op. Actor-only: refused in broadcast_to(js:). Upload validation (#382): valid: true treats an input whose validity.valid is false (setCustomValidity, required) as empty, so a rejected file never gets a preview. An app that clears the input itself (input.value = "" fires no change) dispatches reactive:file-changed on it; bind the op to that event too and the preview, the name and the URL are cleared: op = js.preview_file("#photo-file", into: "#photo", valid: true), input(type: "file", id: "photo-file", **mix(on_client(:change, op), on_client("reactive:file-changed", op))). Frame, size and type (#391): wrap: "#photo-frame" shows that element when an image is painted and hides it when the preview clears (marked data-reactive-wrap-hidden; only a wrapper carrying the mark is shown again, and its text is never written). max_bytes: 2_000_000 refuses a larger file before any object URL exists; accept: true reads the input's accept attribute (a String is the list itself; omitted keeps image/*). Only an image is painted: a listed file that is not image/* (a PDF) clears the preview and hides wrap: without a rejection. A refused file clears the preview, hides wrap: and dispatches reactive:file-rejected on the input with detail.reason "type" or "size"; input.files and the validity are left alone. Several files (#439): on an <input type="file" multiple>, per_file: true checks every file (accept:, then max_bytes:), rebuilds input.files from the accepted ones (through a DataTransfer, synchronously), dispatches one reactive:file-rejected per refused file with detail.reason, detail.file, detail.index (its place in the pick) and detail.remaining (how many files the input keeps), and paints the first accepted image. An action mixed in after the op on the same event posts only the kept files: input(type: "file", id: "pick", name: "file", multiple: true, accept: "image/png", **mix(on_client(:change, js.preview_file("#pick", into: "#shot", max_bytes: 1_000_000, accept: true, per_file: true)), on(:upload, event: "change"))). With every file refused the input is emptied and the action receives none. The reverse mix order posts before the op runs. Without a DataTransfer the rejections still fire and the input keeps every file (remaining counts them all). A single input, and per_file: omitted, keep the one-file check above; per_file: takes only true, false or nil. Name each refused file (#445): rejected: "#refused" is a CSS selector for a container holding an app-authored <template>. For each refused file, in pick order and before any reactive:file-rejected fires, the client deep-clones the template, writes the file's name as textContent (never HTML) into every [data-reactive-file="name"], removes every [data-reactive-file-reason] part whose value is not that file's reason ("size" or "type"), marks each top-level element data-reactive-file-rejected and appends it: ul(id: "refused") { template { li { span(data: { reactive_file_reason: "size" }) { b(data: { reactive_file: "name" }); plain " is too large." } } } }. A reason with no part of its own keeps only the entry's unmarked parts (here an empty li), so give every reason a part, or put data-reactive-file-reason on a top-level element of the template to list only the reasons it has a sentence for. Every run of the op removes the marked entries first (and only those: a heading and the <template> stay), so the next pick replaces the list and a pick with no refusal empties it. A morph that reaches the container (the action's reply.morph of the component, on the same change) keeps the entries, after whatever the server renders (so never render data-reactive-file-rejected children yourself); a plain replace, or an update stream aimed at the container itself, renders it afresh. It works for one file and with per_file: true. A container that is not on the page, or has no <template> child, warns on a verbose root; the rejections still fire.

  • expanded: on show/hide/toggle (#271) keeps a disclosure trigger's aria-expanded honest: js.toggle("#menu", expanded: "#trigger") writes the op's intended state (show β†’ "true", hide β†’ "false", toggle β†’ the state after the flip: "true" when the target was hidden, "false" when it was visible) onto the target β€” set before any transition runs. It resolves with the op's own scoping (:root, a root-scoped selector, or document-wide under global: true). Pair it with one disclosure target; with several, the last one wins.

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

  • keep: true (#369) on show/hide/toggle, the class ops and the attr ops makes the op's result survive a morph of its target (a reply.morph, a broadcast_to(…, morph: true), a page-refresh morph). Without it the morph writes class, hidden and every attribute back from the server render, so a selection made on the client is lost the moment another viewer's change morphs the list. The client records what each kept op left on each target β€” the class on or off, hidden, the attribute's value or its absence β€” and puts it back after Turbo morphs that element. Classes and attributes no kept op touched still follow the render; the latest kept op on an element and a class or attribute wins, so "clear all, then select one" leaves one selected (an op without keep: leaves the kept record alone, so close a kept panel with a kept hide); an element a stream replaces is a new element and starts clean. With expanded:, the trigger's aria-expanded is kept too. Refused in broadcast_to(js:): kept state belongs to one tab.

    js.remove_class(".item", "selected", keep: true)
      .add_class("#item-#{item.id}", "selected", keep: true)
      .show("#details-#{item.id}", keep: true)

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. An element-bound binding does, unless it says prevent: false (a drop that must still reach a native file input; see Keep the native default).

Gate on the event's payload with detail: (#346). A Hash of top-level event.detail keys to the value each must strictly equal (===). Values are JSON scalars only (true/false, nil, a String, a number); anything else raises at render. A missing event.detail or key never matches, and a miss is a complete no-op: no confirm:, no preventDefault. A bulk form whose successful reply only swaps a flash clears its selection on success, and keeps it after a failed (4xx/5xx) submit for the retry:

form(action: "/posts/bulk", method: "post",
     **on_client("turbo:submit-end", js.check_group("ids[]", false), detail: { success: true })) { … }

It is not Turbo-specific: an app's own upload:done event with { ok: true } filters the same way. detail: does not combine with once: (raises): Stimulus removes a :once listener on its first event, so a first non-matching event would leave the binding dead.

Several bindings on one element (#271). Each on_client call emits one self-describing binding record β€” its descriptor, flags, confirm: and ops β€” so two calls compose through mix. The descriptors token-join; the records space-join (spaces inside a record ride as \u0020); the client runs only the record whose descriptor matches the firing event (event type, key filter, window-boundness):

div(**mix(reactive_root,
          on_client(:click, js.hide("#menu", expanded: "#trigger"), outside: true),
          on_client("keydown.esc", js.hide("#menu", expanded: "#trigger").focus("#trigger"))))
# data-action="click@window->reactive#runOps keydown.esc->reactive#runOps"
# data-reactive-ops-param='{"on":"click","window":true,"outside":true,"ops":[…]} {"on":"keydown.esc","ops":[…]}'

An outside guard or a confirm: belongs to its own binding only β€” it never gates a sibling. Two element-bound :click calls on the same <button> still collide on the forced type="button" (mix renders type="button button"): chain the ops into one call instead.

An accessible menu with no custom JS. Pair the above with reactive_listnav(focus: true) on the role="menu" list β€” Arrow Down/Up rove real focus among [role=menuitem] (wrapping), Home/End jump to the edges β€” and the whole WAI-ARIA menu-button pattern is declared ops. See the client-only ops example.

The general autosubmit story. With submit in the vocabulary, the classic onchange="this.form.requestSubmit()" filter form is one declared line β€” no bespoke controller, no reactive action, and Turbo Drive turns the resulting submit into a normal visit:

form(action: "/products", method: "get") do
  select(name: "sort", **mix(on_client(:change, js.submit("form")), data: { testid: "sort" })) do
    option(value: "name") { "Name" }
    option(value: "price") { "Price" }
  end
end

Use change-bound autosubmit for discrete controls (selects, radios, checkboxes). For a text field that should commit "when the value is complete," an unconditional on_client(:input, js.submit) would fire on every keystroke β€” that conditional case is exactly what a reducer's $ops and reactive_on_complete are for.

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.

A page-level chord (undo on ⌘Z/Ctrl+Z, save on mod+s) is a shortcut: rather than event: with window: true: it resolves mod per platform, leaves fields the user is typing in alone and prevents the chord (see Page-level shortcuts 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, and { length: … } compares the value's length β€” exact ({ length: 6 }) or an Integer Range ({ length: 6.. }, { length: 4..8 }). Length counts codepoints on both sides (Ruby String#length, client [...value].length), so multibyte input agrees; a blank field has length 0. { checked: … } counts the ticked boxes of a checkbox group β€” { "ids[]" => { checked: 1.. } } (see Bulk selection). { same: :email } compares the field with another owned field ({ confirm_email: { same: :email, case: :fold } }); case: :fold lowercases ASCII A-Z only, on the server and in the client, and a same: name no element of the render carries raises at render. { digits: 10, starts_with: "9" } is a closed digit format β€” ASCII 0-9 only, an exact count or an Integer Range (digits: 9..11), an optional digit prefix. Pair it with after: :blur (or after: :code for a multi-field condition) to keep a soft hint hidden until the field has blurred once. 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).
  • clear:, focus:, scroll: run when the section changes visibility, never on first paint: going hidden, clear: :notes (or an Array) empties those fields with js.set_value's write and dispatches input per field that changed (a checkbox unchecks); going visible, focus: "#notes" focuses the first match inside the root and scroll: true scrolls the section into view. A clear: name the render never produced, or a file input, raises when the outermost render completes (a fragment render, or one that calls flush, is not checked). These run on a controller that saw the previous visibility: a morph: true reply that flips it runs them, a plain replace reconnects the root and counts as first paint.
  • 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] })

Multi-field targets. A "#id" key takes a full if:/if_any:/unless: conditions Hash β€” the same language reactive_show speaks β€” so a cross-root target can read a combination of owned fields (the case that used to force a bespoke two-field JS listener):

reactive_show_targets(
  "#trade-warning" => { if: { type: "trade", price: ..0 } },  # type == "trade" AND price <= 0
  mode: { "#advanced-tab" => "advanced" }                     # field-keyed entries mix in the ONE call
)

The fold is identical to an in-root reactive_show (each term reads its own field; a missing owned field reads as blank β€” fail-closed). Every referenced field must be owned by the declaring root; a target whose fields are all unowned is left alone, like the single-field skip. A target made only of checked: terms is the exception: a group with no owned boxes counts 0, so "#bulk-bar" => { if: { "ids[]" => { checked: 1.. } } } hides once the last box is removed, as an in-root reactive_show does. A target that mixes a checked: term with a value field keeps the skip while none of its fields is owned.

Bulk selection (reactive_select_all, reactive_count, reactive_enable)

A list with a checkbox per row, a "select all" header, a "Delete (2)" count and actions that stay disabled until something is ticked β€” with no per-list JavaScript controller:

class PostsTable < Phlex::HTML
  include Phlex::Reactive::ClientBindings

  def initialize(posts:) = @posts = posts

  # First paint: nothing ticked (an Array of checked values, or a count).
  def reactive_values = { "ids[]" => [] }

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

        span(**reactive_count("ids[]")) { "0" }                     # ticked count
        fieldset(**reactive_enable(if: { "ids[]" => { checked: 1.. } })) { bulk_fields }
        button(type: "button",
               **mix(reactive_enable(if: { "ids[]" => { checked: 1.. } }),
                     on_client(:click, js.submit("#bulk", submitter: "#delete-submit"),
                               confirm: "Delete the selected posts?"))) { "Delete" }
        button(type: "submit", name: "bulk_action", value: "delete", hidden: true, id: "delete-submit")
      end
    end
  end
end
  • reactive_select_all(group) β€” the header box. Its edit ticks or unticks every owned box named group and dispatches input + change on each one it flips, so computes, shows and completions re-run. Its own state follows the group: checked when all are ticked, indeterminate when some are, unchecked when none are. Give the header no name (it would post).

  • Nested groups β€” reactive_select_all(group, under: "library.books") (#398). A tree of headers over leaves that still post as one array. Each leaf names the path of its nearest parent with data-reactive-group; a header with under: owns the boxes of the name whose group is that path or below it (library owns library and library.books, never libraryx). The header ticks or unticks those leaves (with input + change on each one it flips) and is checked / indeterminate / unchecked from them, so a parent follows its nested headers. The header without under: is still every box of the name. Headers have no name, so they never post, and reactive_count / reactive_enable keep counting the leaves. A blank under:, an empty segment ("a..b"), or a space-padded segment ("a .b") raises at render.

    input(type: "checkbox", **reactive_select_all("perms[]"))                         # everything
    input(type: "checkbox", **reactive_select_all("perms[]", under: "library"))       # a branch
    input(type: "checkbox", name: "perms[]", value: "notes", data: { reactive_group: "library" })
    input(type: "checkbox", **reactive_select_all("perms[]", under: "library.books")) # a sub-branch
    input(type: "checkbox", name: "perms[]", value: "view", data: { reactive_group: "library.books" })
  • reactive_count(group) β€” writes the ticked count via textContent (change-guarded). Seed the first paint in its block.

  • { checked: n } / { checked: 1.. } β€” a term in the shared conditions language: how many owned boxes of the group are ticked, with the same Integer and Range shapes as length:. It works in reactive_show, reactive_enable, reactive_on_complete, reactive_show_targets and a conditional confirm:, and it is evaluated on the server for first paint from reactive_values (an Array of the checked values is counted; an Integer is the count).

  • reactive_enable(if:/if_any:/unless:) β€” reactive_show's sibling: the same conditions, the same first paint (disabled:) and the same re-evaluation triggers, but it flips the element's own disabled. An explicit disabled: wins.

  • Rows added or removed later (a Turbo stream append/remove, a morph) re-sync the header, the count, reactive_enable and reactive_show (and its targets): a root with a group binding watches its subtree and re-syncs when a checkbox is added or removed.

  • Ownership is the usual rule (#15): a nested reactive root's boxes are never counted or flipped by the outer root.

  • The group is a field name resolved like any reactive_show field: a bare name takes reactive_scope, a bracketed one ("ids[]") is used verbatim.

  • js.submit(to, submitter:) submits the form through the hidden submit button, so the POST carries bulk_action=delete (see the op vocabulary).

  • js.check_group(group, checked = true) ticks (or, with false, unticks) every owned box of the group from any other control β€” the "Clear selection βœ•" of a bulk-action bar β€” the way the header does: every box first, then input

    • change on each one it flipped, then one re-sync of the header, the count, the shows and the enables. global: true is for a trigger outside the root that owns the group: every connected reactive root on the page flips the boxes of that name it owns (a root not yet connected takes no part). Actor-only: a broadcast refuses it.
    button(type: "button", **on_client(:click, js.check_group("ids[]", false))) { "βœ•" }
  • A form reset inside the root re-syncs the group bindings once the reset has applied, so button(type: "reset") never leaves a stale count.

A bulk bar outside the list's root (reactive_group_targets)

When the list is its own reactive root (an action filters, sorts or paginates it), its boxes are not the page's (#15), so a bulk bar the page renders outside it cannot count them. The root that owns the boxes declares the outside ids it drives instead, next to reactive_show_targets:

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

# The page, outside the list's root (first paint from ITS reactive_values):
span(id: "bulk-count") { reactive_group_target_attrs("ids[]", :count).to_s }
button(id: "bulk-archive", **reactive_group_target_attrs("ids[]", :enable, 1..)) { "Archive" }
  • Targets are id selectors only (a raise at render, a warn-skip on the client), resolved document-wide: inside another root or outside every root. A missing one is skipped. count: takes one id or an Array of them.
  • An enable: value is an Integer (an exact count), a Range (a threshold) or a full { if:/if_any:/unless: } conditions Hash.
  • They re-sync on every change, on the select-all header's flip, on a morph of the owning root, and when rows are added or removed.
  • One call per root; several groups go in the hash form: reactive_group_targets("ids[]" => { count: "#c" }, "tags[]" => { enable: { "#t" => 1.. } }).
  • reactive_group_target_attrs(group, :count) is the Integer seed; (group, :enable, condition) is { disabled: } (or {} when reactive_values does not cover the group, and the client seeds it).

Sortable lists (reactive_sortable)

Drag rows to reorder a list β€” with a mouse, a finger or the keyboard β€” and one declared action receives the row and where it landed. No Stimulus controller, no endpoint of your own: the drop goes through the action pipeline (signed token, request queue, busy:, the reply).

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

  reactive_record :list
  action :move, params: { item: :integer, position: :integer }

  def move(item:, position:)
    authorize! @list, :update?
    # insert_at is acts_as_list's; any reorder that honours `position` works.
    @list.tasks.find(item).insert_at(position + 1)
    reply.morph
  end

  def view_template
    ul(**mix(reactive_root, reactive_sortable(:move, handle: "[data-drag-handle]"))) do
      @list.tasks.each do |task|
        li(id: dom_id(task), **reactive_sortable_item(task.id)) do
          span(data: { drag_handle: true }) { "β Ώ" }
          plain task.title
        end
      end
    end
  end
end

On drop the action gets item (the row's key), position (its 0-based index after the drop), after (the key of the row it now follows, nil when first) and from ("self" for a reorder) β€” declare the ones you want. The row moves at once and goes back if the action fails or its reply does not render the list; a reply that morphs the list puts the rows in the server's order.

  • Drag in from another list: reactive_sortable_source(group:, name:) rows are copied into any sortable of the same group (the source needs no token β€” it works on a ClientBindings root). Route a drag-in to its own action with from: { templates: :insert }. Source rows are pointer-only: pass tabindex: -1 (via mix) so they stay out of the tab order.
  • Between lists (a kanban): sortables of one group: exchange rows; name: is what a row dragged out reports as from, and static params: (e.g. { lane: "doing" }) tell the action which list it landed in.
  • Keyboard: a row is focusable; Space/Enter picks it up, the arrows move it (← β†’ into the previous/next list of the group), Space/Enter drops it, Escape puts it back. Each step is announced through a live region; translate the words with messages: ({position}, {count}).
  • Styling: data-reactive-sortable-active (the list), -dragging (the row) and -placeholder (the gap) are yours to style.
  • Guards: disabled: true on the list or a row, and a request in flight on the root, refuse a drag. A drag starts after a 4 px move, or a 200 ms touch press, so clicks, taps and scrolling still work.

Full guide: Sortable lists.

Draggable regions (reactive_box, reactive_point)

Move and resize a box, or move a point, over an image, a chart or a diagram β€” with a mouse, a pen, a finger or the keyboard β€” and its fields always hold where it is, as fractions of the frame (0..1 on each axis) or, with scale: 100, as percentages. A crop box, a focal point, a hotspot, a selection: no Stimulus controller of your own.

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

  reactive_record :photo
  action :crop, params: { x: :float, y: :float, width: :float, height: :float }

  def crop(x:, y:, width:, height:)
    authorize! @photo, :update?
    # The schema casts; it does not range-check. A forged box outside the frame changes nothing.
    return reply.morph unless inside?(x, width) && inside?(y, height)

    @photo.update!(crop: { x:, y:, width:, height: })
    reply.morph
  end

  def view_template
    div(**reactive_root) do
      div(**mix(reactive_frame, class: "relative")) do
        img(src: @photo.url, alt: "", class: "block w-full")
        div(**reactive_box(fields: { x: "crop[x]", y: "crop[y]", width: "crop[width]", height: "crop[height]" },
                           value: @photo.crop, aspect: "aspect", min: 0.05, action: :crop)) do
          %i[nw n ne e se s sw w].each { span(**reactive_box_handle(it)) }
          span(**reactive_point(fields: { x: "focus[x]", y: "focus[y]" }, value: @photo.focus,
                                within: "[data-reactive-box]"))
        end
      end
      # the fields: hidden inputs named crop[x] … focus[y], and the aspect radios
    end
  end

  private

  # The fields round to 4 decimals, so x + width may reach 1.0001.
  def inside?(at, size) = at.between?(0, 1) && size.positive? && at + size <= 1.0001
end

The helpers render the box's left/top/width/height (and the point's left/top) as percentages from value:, so the first paint is right with no JavaScript and a re-render with new values moves them. The frame must be positioned (relative, or absolute for an overlay); its padding box is the coordinate space.

  • Pointer: a press on the box moves it, a press on a handle resizes from that edge or corner; the box stays inside the frame and never goes below min:. A drag starts after a 4 px move; touch-action: none keeps a finger drag from scrolling the page.
  • Fields: written as set_value writes them (the live .value, rounded to 4 decimals β€” or, with scale:, those 4 decimals times the scale β€” a bubbling input at most once per field per frame), so reactive_show, compute reducers and other bindings follow the box. One change fires on release.
  • scale:: scale: 100 makes the fields hold percentages (25, 12.35) instead of fractions (0.25, 0.1235), for number inputs a person reads; value:, the rendered position, the action: params and the announcements stay fractions. Render each field's first value on the scale yourself (input(type: "number", name: "crop[x]", value: (@photo.crop[:x] * 100).round(2))), or the first drag or morph rewrites it. reactive_point takes it too.
  • aspect:: a Numeric (16/9r) or the name of a field holding "16:9", a decimal or "" (free), read at drag start. When the field changes (a preset radio), the box re-fits to the new ratio around its centre.
  • action: (optional): sent once on release through the root's pipeline, with x, y, width, height (a point: x, y). Without it the binding is client-only and works on a ClientBindings root. The :float schema casts but does not range-check: refuse values outside 0..1, a width or height of 0, and x + width or y + height past 1.0001 (the fields' rounding) before you store them, since reactive_box refuses to render such a box.
  • Points: reactive_point moves within the frame, or β€” placed inside a box with within: β€” within that box, its fractions of the box.
  • Keyboard: the box and the point are focusable; arrows move 1% (Shift: 10%), Alt+arrows resize from the bottom-right corner or the focused handle, Escape puts it back. Each step is announced through a live region; translate the words with messages: ({x}, {y}, {width}, {height} as whole percents). Letting go of the key is the release.
  • Styling: data-reactive-frame-active (the frame) and data-reactive-dragging (the pressed box, handle or point). A morph that lands mid-drag does not interrupt it.

Full guide: Draggable regions.

Presence (reactive_presence)

Who is viewing a stream, one entry per browser tab, each tab with a small declared state (the item it has selected), on a roster component that re-renders itself in every viewer's tab when a tab joins, leaves, goes silent or changes its state. No controller code, no Stimulus controller.

class Boards::Viewers < Phlex::HTML
  include Phlex::Reactive::Component

  reactive_record :board
  reactive_presence :viewers, state: { selected: :integer }, ttl: 45

  def initialize(board:) = @board = board

  def view_template
    div(**reactive_root) do
      presence.members.each do |member|                 # one per person
        span(**reactive_presence_member(member)) { member.metadata["name"] }
      end
      @board.cards.each do |card|
        li(**on_client(:click, js.presence(selected: card.id))) do
          plain card.title
          presence.where(selected: card.id).each do |peer| # who is on this card
            span(**reactive_presence_peer(peer), class: "dot")
          end
        end
      end
    end
  end
end
  • The stream is [record, :presence, name] (a record-less roster passes key: ["lobby"]), named "<16-char digest of the record (or the key: value)>:presence:<name>", so any model name and id fit pgbus's stream-name budget (41 chars with the default queue prefix). That leaves 15 characters for the presence name; a longer one raises Phlex::Reactive::ConfigurationError at render. The root subscribes to it by itself: the client mints a <pgbus-stream-source> from the root's signed src, so the page must load the pgbus client element (import "pgbus/stream_source_element", which any pgbus_stream_from on the page already does).

  • presence is the roster as this render reads it: peers (one per tab: seat, member_id, metadata, state, joined_at), members (grouped by person: id, metadata, seats, state), where(**state), count, empty?, and in an action me and set(**state).

  • State is declared like action params; undeclared keys are dropped. js.presence(selected: 7) sends it from the browser through the generated presence_set action; presence.set(selected: 7) writes it from an action of your own. Either way only this tab's row changes and the other viewers get a morph. The generated action replies reply.morph for the actor; an action of your own must return reply.morph itself.

  • A state-backed roster (reactive_state): its state is stored on each seat row in pgbus_presence_members (in the note the re-render reads), and the roster renders as one piece of markup under one id for every viewer. So its reactive_state must be the same for every viewer; per-viewer state belongs in presence state (state:).

  • Authorization: presence_set skips verify_authorized. The signed token proves the actor rendered this root, and the write is typed, ephemeral, and lands only on the actor's own row, keyed by its SSE connection id, which only that tab knows. To check the value (the card must belong to the board), define authorize_presence_state!(**state) and raise; it runs first. That hook guards only the generated presence_set: an action of your own that calls presence.set authorizes its state itself. The client also sends presence_set with no state on every connect (the announce): that writes no value, so the hook is not called for it.

  • presence.me is the acting tab, and during an action it stays the actor while the roster renders for the other viewers too. Never branch the shared output on it; the client's -me marks are per tab. (Likewise reactive_connection_id is the first <pgbus-stream-source> on the page, so one loaded later, ahead of it in the DOM, can change it; presence writes use their own connection and are unaffected.)

  • "Me": the roster renders once for everyone, so the client marks this tab's element data-reactive-presence-me and the same person's other elements data-reactive-presence-same-member after every morph. Style them. Both need this tab's reactive_presence_peer element on the roster: a roster of members only has none, so nothing is marked.

  • Events: reactive:presence fires on the root with { joined: [seat…], left: [seat…], me: seat } when a morph changes who is there; a replayed frame that changes nothing fires nothing. The root names the seats its render saw (data-reactive-presence-roster), so this works for a roster that renders people, not tabs.

  • Leaving: a closed tab leaves within about a second (pgbus watches each SSE socket for the browser closing it). A tab that crashes or loses its network, so nothing closes the socket cleanly, leaves on the next failed heartbeat write (streams_heartbeat_interval, 15 s by default) or expires after ttl: seconds. A reconnect keeps the tab's seat id and re-sends its state, but pgbus drops the row on the disconnect, so unless the reconnect lands within one coalesce window (150 ms) peers see that seat leave and join again (reactive:presence reports both). A row that expires while its socket stays open (the tab missed its beats) is re-joined by the heartbeat without its state, until the tab's next js.presence. ttl: must be above the transport's heartbeat interval, or a live tab would expire between two beats: reactive_presence raises otherwise.

  • Transport: needs pgbus 0.18.1 or later (presence keyed by SSE connection, pgbus:connected only after the tab's seat joined, config.streams_authorize). Create the table with rails g pgbus:add_presence, or upgrade an older one with rails g pgbus:add_presence_seats. ttl: (45 by default) is written into pgbus's streams_presence_ttl under the stream's pattern. Action Cable has no connection identity: rendering a presence root without it raises Phlex::Reactive::ConfigurationError. Every other component is unaffected.

  • Identity: pgbus takes each tab's person from what config.streams_authorize returns for the stream. nil or false answers 403; true authorizes with no identity, and pgbus joins such a connection to no roster (the tab then warns in the console that it never appeared). Per-record authorization already happens at render: the signed src is minted only into a roster root your app chose to render for this user. streams_authorize is the revocation layer (the signed name proves pgbus minted it, not that this viewer may still read it: a removed member keeps a working name until the secret rotates). For a strict per-record check at connect time, compare stream_name with the presence key of each record the user may view (or give a record-less roster a key: with a prefix you can parse):

    # config/initializers/pgbus.rb
    Pgbus.configure do |config|
      config.streams_authorize = ->(env, stream_name) {
        user = env["warden"]&.user
        next unless user&.active?
        next unless user.may_stream?(stream_name) # keep your app's own check for every stream
    
        if stream_name.end_with?(":presence:viewers")
          next unless user.boards.any? { Boards::Viewers.reactive_presence_stream_name_for(it) == stream_name }
        end
        { id: user.id, metadata: { name: user.name } }
      }
    end

    A presence stream's name is a digest ("<16 hex>:presence:<name>"), so the record cannot be read back from it: reactive_presence_stream_name_for computes it from the record. A User works too (pgbus reads its #id); shape the member from it with config.streams_presence_member = ->(user) { { id: user.id, metadata: { name: user.name } } }.

  • Jobs: a join, a leave and an expiry re-render the roster in Phlex::Reactive::PresenceRenderJob (on defer_job_queue), so the app needs an ActiveJob adapter that runs jobs. A state change renders in the action.

Client-only drafts (reactive_persist)

"Don't make me start over": a public application form, a multi-step wizard, a long comment box β€” the user types, navigates away, comes back, and expects their draft. Nothing the server needs until submit, no signed-in user to autosave for. reactive_persist (#239) is the reactive_show-shaped answer: a declared, client-only binding over the fields the root owns, no token, no POST, no expression surface β€” the generic controller keeps a localStorage draft and every hand-rolled "local save" Stimulus controller goes away.

class ApplicationForm < ApplicationComponent
  include Phlex::Reactive::ClientBindings     # or the full Component
  reactive_scope :apply

  def view_template
    form(action: "/applications", method: "post") do
      div(**mix(reactive_root(id: "apply"), reactive_persist(key: "village-apply", ttl: 7.days))) do
        input(**reactive_field(:name))                              # persisted
        textarea(**reactive_field(:bio))                            # persisted
        input(name: "fuckery", **reactive_persist_skip)             # honeypot β€” never
        input(type: "hidden", name: "apply[tz]")                    # hidden β€” never (default)
        button(**on_client(:click, js.persist_state(step: 2))) { "Next" }
        button(**on_client(:click, js.persist_clear)) { "Discard draft" }
        button(type: "submit") { "Apply" }
      end
    end
  end
end

Spread it on the root (mix with reactive_root), once per root. One wire attr: data-reactive-persist='{"key":"village-apply","ttl":604800,"debounce":300}'.

  • Write β€” on input (trailing-edge debounce, debounce: ms, default 300) and immediately on change; a pending write is flushed on disconnect, so a fast Turbo navigation never loses the last keystrokes. The snapshot is a full pass over the owned controls: radios store the checked value, checkboxes the checked state, <select multiple> an array, everything else .value.
  • Restore β€” on connect, first among the client bindings, so a reactive_show section, reactive_on_complete (armed, never fired), reactive_filter and a reactive_compute root all read the restored values on first paint β€” no synthetic events. A morph or broadcast re-render is server truth and is never re-restored. (With the opt-in split client the first reactive_persist root of a page load restores a moment after it connects, once the draft module has arrived; that section lists what holds in between.)
  • restore: :blank (default) β€” a draft value lands only in a control the server rendered blank, so a 422 re-render's submitted values beat an older draft. restore: :always lets the draft win.
  • storage: :local (default) β€” the draft lives in localStorage: every tab of the origin sees it, and it outlives them until ttl. storage: :session (#397) keeps it in sessionStorage instead: the same tab restores it on reload, another tab never sees it, and closing the tab drops it. (A tab opened FROM this one with an opener β€” window.open, a link without noopener β€” or a duplicated tab starts with a copy of the browser's session store, draft included: a one-time snapshot, after which the two tabs' drafts are separate.) Same key, same exclusions, same restore:, same clears. The two stores are read separately β€” a :local draft is never restored by :session. "local" / "session" strings are accepted; anything else raises at render.
  • Clear β€” a successful turbo:submit-end of the form that contains the root, ttl expiry (checked on read; default 7.days), or js.persist_clear. A successful reactive action does not clear on its own β€” chain reply.js(js.persist_clear) from the action when it should.
  • fields: narrows the set to declared names (scope-aware symbols): reactive_persist(key: "k", fields: %i[name bio]).
  • Never persisted: type=hidden/file/password/submit/button/reset/image, anything carrying reactive_persist_skip, and a nested reactive root's controls. autocomplete="off" is not an implicit skip β€” a wizard often sets it form-wide. Honeypots must opt out (reactive_persist_skip) or sit outside the root: an invisible-captcha text input looks like any other field.
  • Rich editors (#241) β€” a named lexxy-editor, trix-editor or bare [contenteditable] is drafted and restored through its own value surface: the editor's value getter/setter (its serialized HTML, replayed through the editor's own sanitizing import β€” Trix's HTMLParser, Lexxy's $generateNodesFromDOM + sanitizer), a bare contenteditable's textContent. The name is the element's name attribute, or β€” Trix in the Rails rich_text_area shape β€” the input=-paired hidden input's name (the hidden input itself is never a separate key). restore: :blank asks the editor whether it is empty (Lexxy isEmpty, Trix editor.getDocument().isEmpty()), so an attachment-only server body is never overwritten. An editor that hasn't upgraded yet at connect (Trix defines its elements in a setTimeout; a lazily imported Lexxy) is restored once customElements.whenDefined resolves. The editors' own lexxy:change / trix-change events are the keystroke signal for the draft write (neither editor lets its native input bubble) and also fire on restore (no native input/change is ever synthesized). Their toolbar chrome (Lexxy's code-language select and link href input, Trix's trix-toolbar) is never drafted. Put reactive_persist_skip on the editor element to opt one out.
  • State bag β€” js.persist_state(step: 2) merges a flat hash of scalars into the same draft (a wizard's current step). On restore the root carries data-reactive-persist-state='{"step":2}' and dispatches a bubbling reactive:persist-restored event (detail: { key, fields, state }) β€” the hook for your own wizard controller to jump to the saved step.
  • Storage failures are silent β€” a private window, a quota error or blocked storage degrades to "no draft". Under Phlex::Reactive.debug the controller prints one console.info naming the failure so a dev sees why nothing came back.

Threat model: phlex-reactive never writes markup into the DOM β€” native controls are replayed via .value/.checked/.selected, a bare contenteditable via textContent, and a rich editor through its own value setter (the same sanitizing import a paste takes). A tampered draft can only produce what the user could type or paste into that field. PII sits in this browser's localStorage (or, with storage: :session, this tab's sessionStorage) for ttl; the submit-clear, ttl and storage: :session (the draft goes with the tab) are the shared-computer mitigation. persist_state/persist_clear are actor-only ops (refused by broadcast_to(js:)). See the security page.

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(**reactive_root(compute: :preview)) do
  input(**reactive_field(:title, value: @post.title))
  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`,
}))
  • reactive_root(compute: :name) binds AND listens at the root. It emits the compute descriptors plus the input->reactive#recompute delegation on the root element, so no field needs any per-field compute wiring. nil (e.g. reactive_root(compute: (:preview unless @post.persisted?))) collapses to no binding at all β€” one expression for conditional compute.

  • 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. The permit-style form (inputs: [:qty, title: :string]) combines both in one declaration β€” bare symbols default to :number, a trailing hash types the exceptions.

  • Checkboxes and radios read their checked state. A checkbox's .value is a constant, so a compute reads whether the box is ticked instead, coerced by the declared type: 1/0 as a :number (and in the array form), "true"/"false" as a :string, true/false as a :boolean. The checkbox wins over the hidden companion Rails' check_box renders before it. A radio group reads its checked radio's value ("", or 0 as a number, when none is checked). A :boolean on any other control is false for "", "0" and "false".

    reactive_compute :total, inputs: [:price, { gift_wrap: :boolean }], outputs: %i[total]
    setComputeReducer("total", ({ price, gift_wrap }) => ({ total: price + (gift_wrap ? 25 : 0) }))

    An output that resolves to a checkbox sets checked from the result's truthiness, and one that resolves to a radio group checks the radio carrying the result (a value no radio carries clears the group). Neither rewrites a value attribute, so what the control submits is unchanged. A []-named checkbox group is not a compute input β€” compute values are scalars; declare each box under its own name.

  • reactive_text(:name, initial) mirrors a value into a text node via textContent (XSS-safe by construction). Every reducer-result key paints any matching sink: an owned field if declared in outputs:, any owned reactive_text node by presence, or a declared mirror: id β€” so an outputs: entry that exists only to reach a text node is redundant (existing declarations keep working; it's a widening, not a breaking change). It carries no name, so it's never collected or POSTed as a param.

  • Reducer-less mirrors, and reactive_values seeding. 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. And reactive_text(:name) called with no explicit initial seeds its first paint from reactive_values when the component declares one and covers that name β€” the same no-flash first-paint contract reactive_show uses.

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

Reducer-emitted ops ($ops) β€” commit when complete. A reducer's outputs write fields and text; the reserved $ops key lets it emit a conditional side effect β€” the missing piece that used to force a bespoke controller next to an otherwise-declarative compute. Return an op chain (the ops builder mirrors the Ruby js verbs β€” minus paste_into, deliberately: a reducer runs on every input event, and a clipboard read per keystroke would spam permission prompts; ops.set_value(selector, value) is included, but its input re-runs the reducer, so a value derived from the field it writes changes the chain on every pass and fires again each time β€” return a field the reducer owns as an output instead β€” or use a raw [[op, args], …] array) and the controller runs it through the same frozen op whitelist on_client uses, as a final phase after the field writes, text sinks, and their dispatched input events settle. The canonical one-time-code field:

import { setComputeReducer, ops } from "phlex/reactive/compute"

setComputeReducer("otp", ({ code }) => {
  const digits = code.replace(/\D/g, "").slice(0, 6)
  return { code: digits, $ops: digits.length === 6 ? ops.submit() : null }
})
form(action: "/verify", method: "post",
  **mix(reactive_root(compute: :otp), on(:verify, event: "submit"))) do
  input(name: "code", inputmode: "numeric", autocomplete: "one-time-code")
end

Typing, pasting 123-456, or platform SMS autofill all arrive as input events β†’ the reducer normalizes, and at six digits submit requestSubmits the form β€” which on(:verify, event: "submit") intercepts into one signed action POST. The contract that makes this safe:

  • Rising edge, keyed on content. The chain runs only when it differs from the previous pass's chain (including from "absent"). Returning the same chain again is settled β€” a 7th keystroke capped back to the same six digits can't re-submit β€” while a different chain fires again (a multi-box reducer advancing focus emits a new ops.focus target per digit). A pass returning null/no $ops re-arms.
  • Event-gated. The connect/morph seed pass arms without firing β€” a form re-rendered with an already-complete value (a validation-error morph, a browser restore) never auto-fires, which is what breaks the submit β†’ error re-render β†’ re-seed β†’ submit loop.
  • Whitelisted. $ops is consumed before the write phases (never painted as a field/text/mirror), and unknown ops warn-and-skip while siblings apply.
  • Multi-input works with the same machinery: declare all boxes as inputs, join + redistribute in the reducer (a paste into any box fans out one digit per box), mirror the joined value into a hidden field, advance focus with a per-digit ops.focus, and ops.submit() on completion. See spec/dummy/app/components/split_code_component.rb for the full six-box example.

When you don't want to auto-submit, the same slot dispatches a completion event (ops.dispatch("code:complete")) for a sibling to react to, or enables the submit button (ops.remove_attr("[type=submit]", "disabled")) and leaves the commit to the user.

Declarative completion (reactive_on_complete)

The $ops escape hatch puts the condition in the reducer; when the condition is expressible in the conditions language, reactive_on_complete declares the whole binding in Ruby β€” zero JavaScript, no reducer:

class CodeCompleteComponent < ApplicationComponent
  include Phlex::Reactive::Streamable
  include Phlex::Reactive::Component

  reactive_state :code
  action :verify, params: { code: :string }

  reactive_on_complete if: { code: { length: 6 } }, run: js.dispatch("code:complete")

  def view_template
    div(**mix(reactive_root, on(:verify, event: "code:complete"))) do
      input(name: "code")
    end
  end
end

The generic controller evaluates the conditions over the owned fields on every input/change (scope-aware, same resolver as reactive_show) and runs the declared ops on the rising edge β€” once, when the conditions first become true; going false re-arms; the connect/morph pass arms without firing, so a re-render with already-satisfied conditions never self-fires. The pieces:

  • Conditions are the same if: / if_any: / unless: kwargs reactive_show takes β€” including the length: form above, which is what makes "exactly six characters" declarable.
  • run: is a js chain (available at class level) or a raw op list (re-checked through the attribute allowlist). run: js.submit auto-commits; run: js.dispatch(...) lets a sibling on(:action, event: "...") turn completion into a signed action, as above.
  • Several bindings coexist under names: reactive_on_complete :commit, if: …, run: js.submit β€” each latches independently; redeclaring a name overrides it (normal registry inheritance).
  • Prefer $ops when completion needs normalization first (strip separators, cap length) β€” the reducer already knows the cleaned value; prefer reactive_on_complete when the raw field value is the truth.

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(
  :q,                               # the FIELD that drives the filter β†’ [name="q"] (scope-aware)
  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.
  # name: "q" is the field reactive_filter(:q) binds to; option: defaults to [role=option].
  input(name: "q", 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 matching it against its data-reactive-filter-text haystack (falling back to the option's own text) β€” a substring match by default, or the mode match: names (below); 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.

How the query matches β€” match: and fold:. A substring match is right for a short catalog with packed synonyms. For a long alphabetical list (countries, cities, time zones, people) it is the wrong default once the query is a letter or two: n keeps every name that merely contains an n. Two opt-in kwargs change that; the defaults emit nothing, so an existing filter's markup is unchanged:

reactive_filter(:q, match: :word_start, fold: true)
match: keeps an option when…
:substring (default) the haystack contains the query
:prefix the haystack starts with the query
:word_start the haystack starts with the query, or the query sits right after whitespace or one of - ' ( ) , . / (the straight apostrophe only, not ’)

fold: true folds diacritics on both sides before comparing (Unicode NFD, combining marks stripped), so zur finds ZΓΌrich and a query typed with the accent, zΓΌr, still does. Only letters that decompose into a base letter and a combining mark fold: ΓΈ, Γ¦, Ε‚, Δ‘ and ß stay as they are, so pack an ASCII spelling into data-reactive-filter-text when a list needs one ("Ørsted Orsted"). Use :word_start for a list people scan by its first letters: n keeps Nantes and New York, ar keeps Γ…rhus (with fold:) but not Santander, and a multi-word query works as typed (new y β†’ New York). An unknown match: raises at render. A tag picker (reactive_tags, below) takes the same two kwargs on the reactive_filter it mixes in.

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.

The escape hatch β€” a name-less input, targeted by id. Inside a real POST/GET form, a named query input submits a stray param alongside your value. A form builder (phlex-forms' tag_field) therefore renders the query input with an id and no name β€” which the field form can't express. Pass input: (a raw CSS selector, verbatim β€” never re-scoped) instead of the field; exactly one of the two per call:

reactive_filter(input: "#user_tags_query")   # the input carries id, NO name

Tag-chip input (reactive_tags)

The composed combobox/tags widget: preload suggestions, type to narrow, Enter or click to add, remove chips β€” the classic bespoke tags Stimulus controller, gone. The value is form state (like text in an input), never component state: it lives in a hidden comma-joined field inside your form, the client maintains it with zero round trips, and the surrounding form submit carries it to the server (tags.split(",") on your side). Because nothing here needs a signed action, the widget works in a token-less ClientBindings component too.

div(**mix(reactive_root, reactive_tags(:tags), reactive_filter(:tag_query))) do
  input(type: :hidden, **reactive_field(:tags), value: @post.tags.join(","))

  div(data: { reactive_tags_list: true }) do        # chips render here
    @post.tags.each { chip(it) }                    # server-rendered first paint
  end
  template(data: { reactive_tags_template: true }) { chip }  # the chip markup, server-owned

  # listnav FIRST, tags_add second: Enter picks the highlighted option;
  # free text adds only when nothing is highlighted. Never submits the form.
  input(name: "tag_query", **mix(reactive_listnav, reactive_tags_add))

  SUGGESTIONS.each do |tag, haystack|
    button(**mix(reactive_tags_option(tag),          # click β†’ add (client-only)
      data: { reactive_filter_text: haystack })) { tag }
  end
end

# One chip method serves both forms: with a tag it's the server-rendered chip,
# without it's the template prototype (the client fills text + remove param).
def chip(tag = nil)
  span(class: "chip", data: { reactive_tag: tag }) do
    span(data: { reactive_tag_text: true }) { tag }
    button(**(tag ? reactive_tags_remove(tag) : reactive_tags_remove)) { "Γ—" }
  end
end

The chip list is a client projection of the hidden field β€” the field is the single source of truth. Every change re-clones your <template> per tag, writing the tag through textContent only (never innerHTML β€” the XSS-safe reactive_text posture) and stamping the remove button's tag param. Tags dedupe case-insensitively (first casing wins); a comma-separated paste splits into individual tags; a declared tag containing a comma raises at render (it would corrupt the joined value). An already-selected suggestion is hidden and marked data-reactive-tags-selected β€” reactive_filter keeps it hidden through re-filters β€” and resurfaces when its chip is removed. A server re-render/morph re-projects the chips from the field's fresh server value, and each write dispatches a real input event on the hidden field, so reactive_dirty, reactive_show, and reactive_compute all see it.

The styled, form-builder-integrated version of this widget (label/errors/ theming) belongs in your form layer β€” these helpers are deliberately the unstyled primitives, like reactive_filter before them. That form layer is exactly who needs the escape hatches: a form builder's wire name is computed per instance (user[tags], from the builder's object name), which the class-level reactive_scope compile can't express. name: takes the wire name verbatim (never re-scoped), and the query input goes name-less, targeted by id via reactive_filter(input:) β€” so a real form submit carries user[tags] and nothing else:

div(**mix(reactive_root(id: "user_tags_widget"),
  reactive_tags(name: "user[tags]"),               # verbatim β€” never re-scoped
  reactive_filter(input: "#user_tags_query"))) do  # id-targeted, name-less input
  input(type: :hidden, name: "user[tags]", id: "user_tags", value: @tags.join(","))
  # …chips/template/suggestions as above…
  input(id: "user_tags_query", type: "search", **mix(reactive_listnav, reactive_tags_add))
end

Draft rows for a new parent (reactive_nested_*)

The "new order + line items" form: the user builds up child rows before the parent exists. An unsaved parent has no GlobalID to sign, so this pre-save window can't be a reactive collection β€” and every app ends up hand-writing the same imperative JS (clone a row, renumber indexes, serialize on submit). reactive_nested_* is that pattern as a primitive, the reactive_tags posture: the rows are form state, add/remove run entirely client-side (zero round trips, no token β€” works in a ClientBindings component), and the surrounding real form submit posts standard Rails parent[assoc_attributes][<index>][field] names, so accepts_nested_attributes_for creates the parent + rows in one request.

class DraftOrderForm < ApplicationComponent
  include Phlex::Reactive::ClientBindings

  reactive_scope :order   # names post as order[line_items_attributes][…]

  def view_template
    div(**reactive_root(id: "draft_order_form")) do
      form(action: orders_path, method: "post", data: { turbo: "false" }) do
        input(**reactive_field(:total, type: "number", value: "0"))

        div(**reactive_nested_list(:line_items)) { }             # rows land here
        template(**reactive_nested_template(:line_items)) { row_fields }
        button(**reactive_nested_add(:line_items)) { "Add item" }

        button(type: "submit") { "Create order" }
      end
    end
  end

  private

  # ONE row's markup, authored once. The default renders the template
  # prototype (NEW_ROW placeholder names β€” the client swaps in a fresh index
  # per add); pass index: to server-render an edit form's persisted rows.
  def row_fields(index: nil)
    kwargs = index.nil? ? {} : { index: }
    div(**reactive_nested_row) do
      input(name: nested_field_name(:line_items, :quantity, **kwargs), type: "number")
      input(name: nested_field_name(:line_items, :price, **kwargs), type: "number")
      button(**reactive_nested_remove) { "Γ—" }
    end
  end
end
# The model + controller side is plain Rails β€” the reconcile is one create:
class Order < ApplicationRecord
  has_many :line_items, dependent: :destroy
  accepts_nested_attributes_for :line_items, allow_destroy: true
end

Order.create!(params.require(:order).permit(:total,
  line_items_attributes: %i[id quantity price _destroy]))

Clicking add clones your <template> row and swaps every NEW_ROW in the clone's name/id/for attributes for a fresh unique index (clock-seeded and strictly monotonic β€” server-rendered 0..n indexes and same-millisecond double clicks can't collide), then focuses the new row's first field. Remove on a draft row deletes it from the DOM β€” it was never persisted, so removing its fields is the removal. Remove on a row carrying a hidden [_destroy] input (an edit form's persisted row, rendered with nested_field_name(:line_items, :_destroy, index: i)) marks it "1" and hides the row instead β€” Rails destroys it on save. Several collections can share one root (everything is keyed by the association name); nesting a collection inside another's template is not supported.

nested_field_name is scope-aware (reactive_scope :order β†’ the order[…] wrap above). When the parent prefix is per-instance β€” a form builder's object name, possibly itself bracketed ("user[profile]") β€” pass scope: per call instead; it's used verbatim and wins over the class-level scope: nested_field_name(:line_items, :quantity, scope: "order").

Two boundaries to respect: the DOM is the single source of truth for unsent draft rows, so a server re-render of the root replaces them β€” keep replace-shaped actions out of a root holding unsent rows. And once the parent is saved, the persisted flow takes over: the same row markup renders with real indexes, or the list graduates to a reactive collection (reactive_collection + reply.append/reply.remove).

JSON mode β€” one hidden field instead of accepts_nested_attributes_for. If your controller already parses a serialized JSON param (the app-rolled "stuff the rows into a hidden field, JSON.parse on submit" pattern) rather than nested attributes, opt the list into as: :json and keep your persistence path exactly as it is:

div(**reactive_nested_list(:line_items, as: :json)) { }   # + a hidden field to sync
# the hidden field the client keeps in sync (seed "[]" so an empty submit posts one):
input(type: "hidden", **reactive_field(:line_items), value: "[]")

# Form-builder escape hatch: name the hidden field VERBATIM (never re-scoped):
div(**reactive_nested_list(:line_items, as: :json, name: "order[line_items]")) { }
# The controller stays hand-rolled β€” NO accepts_nested_attributes_for:
rows = JSON.parse(params.require(:order).permit(:line_items)[:line_items].presence || "[]")
order = Order.create!(total: params[:order][:total])
rows.each { order.line_items.create!(quantity: it["quantity"], price: it["price"]) }

Everything else is identical β€” the same <template> row, the same nested_field_name, the same add/remove triggers. In JSON mode the client mirrors the surviving rows into that one hidden field as a JSON array on every add / remove / keystroke (the set-value + dispatch contract, so dirty tracking and compute still see it), inferring each JSON key from the trailing bracket segment of a row input's name (order[line_items_attributes][3][quantity] β†’ "quantity"). A removed row simply leaves the array (JSON carries no _destroy marker β€” an absent row is the removal). The per-row _attributes names still render but are ignored by a controller that doesn't permit them; the JSON field is the single source of truth.

Fill-then-add β€” snapshot add controls that live OUTSIDE the row. The default reactive_nested_add is inline-edit: it clones the template and focuses the new row's first field, so you type INTO the row. A common alternative puts the add controls outside the row β€” a preset <select>, a typeahead, plain inputs β€” and "Add" snapshots those values into a new row, then clears them for the next entry. Pass from: (a map of row-field β†’ source-control selector) and optionally clear::

input(id: "item-name",  type: "text")     # the add controls, OUTSIDE the row
input(id: "item-qty",   type: "number")

button(**reactive_nested_add(:line_items,
  from: { name: "#item-name", quantity: "#item-qty" },  # row field => source selector
  clear: true)) { "Add item" }

On click the client clones the template, fills each cloned-row field from its source control's current value (matching the field by the same trailing bracket-segment key inference JSON mode uses), keeps focus on the sources (so you keep entering the next item β€” it does not steal focus into the row), and, with clear: true, resets the sources (each via the set-value + dispatch contract, so dirty tracking and compute see it). The from: values are raw CSS selectors resolved within this root (#15 ownership); a selector that resolves nothing, or a row-field key with no matching cloned field, is skipped (the row still adds β€” never a throw). It composes with both wire modes: the seeded values ride the renumbered _attributes names on submit (:attributes) and the end-of-add JSON sync serializes them (as: :json), with no extra wiring.

Relatedly, a draft parent can now run real server actions too (issue #208): an unsaved record signs a gid-less {c, state} token, and the endpoint rebuilds the component through the record kwarg's initialize default β€” def initialize(order: Order.new, …) β€” with the declared reactive_state riding the token. A component whose initialize requires the record kwarg raises a guided error on the first draft action, telling you to add the default.

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_field returns the attribute hash that carries the binding β€” spread it onto any control (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
    input(**reactive_field(:value, value: @record.name))    # <input name="value" …>
    select(**reactive_field(: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.

The resolver also gets an optional second argument β€” a context object β€” so a power-user override can build the string itself instead of relying on the message alone. It always carries { el } (the trigger element the confirm fired from); on a reactive_nested_remove it additionally carries { row, fields } (the row element and its { key => value } field map), so a themed dialog can render row-specific detail programmatically:

setConfirmResolver((message, ctx) => {
  // message is already interpolated (client-added rows resolve %{field}, see below)
  return myThemedDialog(message, { trigger: ctx.el, fields: ctx.fields })
})

The second argument is purely additive β€” a one-parameter resolver keeps working untouched. Unset, behavior is identical to the native window.confirm; the confirm: markup and on(...) API are unchanged. For per-row confirm messages on client-added draft rows, the message the resolver receives is already interpolated from the row's live field values (confirm: "Delete '%{name}'?" β†’ Delete 'Widget'?) β€” see Draft rows for a new parent.

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 (issues #28, #338) β€” even when
# the render differs (the model strips "Hello " to "Hello"). 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(page_heading: @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>/<textarea>, its caret AND what was typed in it (the render's value becomes its new default; other fields take the server's value; data-reactive-morph-value on a field lets the morph overwrite it while focused, #338; a held field whose render differs from its default is marked data-reactive-held + reactive:held, and the root's next action sees it in reactive_held_fields when its params: schema declares that field, #370); for per-field reactive editing (replace #28; update #113)
.also(target => content, …) UPDATE (inner HTML) companion elements by DOM id; content is a plain string (escaped) or a Phlex component. The argument type picks the action β€” pairs mean update
.also(component, morph: false) REPLACE another Streamable component at its own #id (morph: true morphs in place). A component argument means replace
.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")
.refused(*names) the reply answers the posted values without storing them: the client keeps the fields a morph held (data-reactive-held, #370), so the root's next action names them in reactive_held_fields again instead of saving over the other change. No names keeps every mark; names (the fields' name attributes) keep only those. Chain it on a morph (reply.morph.refused): a plain replace swaps the marked field out. Still a 200: no error marker, no reactive:error (#413)
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); a focus/focus_first/blur op and the ops after it land one frame later (a hidden tab: once visible), past Turbo's stream focus restore (#446)
.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.pending(records, in:, job:, args:, peers:) { … } the action enqueues the work: mark the targets pending now and let your own job settle them when the work is actually done (issue #248); see Async actions
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.

Load on first use β€” on: defers the request itself until an event reaches the shell, or until it scrolls into view (issue #276):

reactive_lazy on: "panel:opened"          # a DOM event on (or bubbling into) the shell
reactive_lazy on: :visible                # IntersectionObserver, no DOM event involved
reactive_lazy on: { visible: "200px" }    # …with a rootMargin

:visible fires when the shell first enters the viewport grown by the rootMargin, so a margin can load it slightly before it scrolls into view. In an engine without IntersectionObserver a :visible shell materializes right after connect, so it still loads, just not lazily.

The shell makes no request on page load and materializes exactly once; the real render has no trigger, so later events are no-ops. It carries the identity token (no expiry, unlike the defer token), so it works on a page left open past defer_token_ttl. The framework-owned __materialize act rides the action endpoint, runs no action, shares the defer endpoint's authorization step (registered error β†’ 403, render? false β†’ the shell stays), and answers 403 for any component that isn't reactive_lazy(on:). Like the defer endpoint it is a read: it skips around_actions, verify_authorized and the pgbus connection-id scope, so enforce tenant scoping or rate limits in your base controller or in the render.

A failed load is not retried on its own. A Turbo morph of the root handles the rest: real content morphed back into the shell re-materializes at once (one request), and a shell that is still unloaded is re-armed, which is also a failed load's retry path. Give the shell the real root's tag (tag: :ul for a <ul> root): with the default <div> shell on a <ul>/<tr> root, Turbo swaps the node instead of morphing it, and an already-open panel shows its skeleton until its event fires again. With matching tags, a page that refresh-morphs pays one request per loaded on: component per refresh; data-turbo-permanent on the real root opts it out. See the deferred-rendering docs page.

Reuse across page views β€” cache: makes the real render a GET the browser may keep privately, so a fragment that is the same on every page (a menu, an account summary) is not fetched and rendered again on every page view (issue #277):

class AccountMenu < ApplicationComponent
  include Phlex::Reactive::Component

  reactive_lazy cache: { max_age: 10.minutes }   # combine with on: / tag: freely

  def id = "account-menu"
  def reactive_cache_viewer  = Current.user&.id                   # who this render is for
  def reactive_cache_version = Current.user&.shortcuts_updated_at # optional: busts the URL
  def view_template = Current.user.shortcuts.each { |s| a(href: s.url) { s.label } }
end

The shell carries a stable signed URL (/reactive/fragment/<signed id>?v=…&u=…) instead of a per-render token; the client GETs it and the endpoint answers Cache-Control: max-age=600, private with an ETag. With on: this is "load on first open, reuse across page views" with no app JavaScript, and a refresh morph re-materializes from the browser cache instead of costing a request.

The shell stays in the page in the fragment's place: a record-less on: + cache: shell with a viewer and a version is about 290 B gzipped (it carries the URL and no token). Defer what is clearly bigger than that β€” roughly 1 KB gzipped or more β€” or slow to render; a dozen links is cheaper inline. See the deferred-rendering page for the numbers.

What keeps a private cache safe:

  • Never public. max_age is capped by Phlex::Reactive.fragment_cache_max_age_limit (1 hour). Every error β€” a 4xx, a 204, and anything your base controller answers first (401, a redirect) β€” is no-store.
  • One viewer's copy never reaches the next. By default the reply says Vary: Cookie, so a copy is reused only while the browser sends identical cookies. Rails' cookie session store issues a new session cookie on every response, so there that means "within one page" (a refresh morph), not across page views. Define reactive_cache_viewer to key the URL on the viewer instead: the endpoint re-computes it in the requesting session and makes the reply cacheable only when the URL names that viewer. Return everything the render depends on besides the identity and the version (user, tenant, locale). u is an opaque keyed digest. A blank value (nil, false, "", an Array with a blank part) names nobody and is never a shared "anonymous" key: that render falls back to Vary: Cookie, so Current.user&.id is safe as is. Return a value unique per viewer: it is expanded like a cache key, so 0, "0" and [0] are one viewer, and true β€” or an unsaved record β€” is everyone (return nil for guests).
  • Opt-in only. The fragment id is MACed under its own purpose: it is not an identity or defer token and those are not fragment ids (400 either way). A component without cache: is not reachable over GET (404). The id names no viewer, but it carries the signed state / record GlobalID in a URL β€” signed, not encrypted, so keep secrets out of reactive_state.
  • A read. No action, no around_actions, no transaction; v and u only shape the browser's cache key. Authorize in the render, as for any lazy component. A cacheable reply carries no Set-Cookie; a request during which any callback (before, around or after) changed the session, set or consumed a flash, minted a CSRF token, or wrote a cookie keeps that write and is answered no-store instead β€” so an app that writes the session on every request (Devise timeoutable) gets no caching. Session writes made outside the controller (Rack middleware, a CSP nonce generator that writes the session) are not seen, and are lost on a cacheable reply. The endpoint itself never reads the flash, so a notice waiting for the next page view survives a fragment load. A filter may tighten the policy (no_store, expires_now, a shorter max-age, must-revalidate), never loosen it. The ETag is the body only β€” etag { } blocks are not applied.
  • No CSRF tokens. A render that embeds a form authenticity token or csrf_meta_tags is served no-store with a warning rather than cached.
  • No CSP nonces. A nonce="…" inside a cached fragment is replayed from the cache and will not match the page's.
  • Nested lazy components render eagerly inside the fragment. The fragment is a real render, so a reactive_lazy child (whatever its own on:/cache:) renders its template as part of the parent's copy: no expiring defer token, but under the parent's URL, viewer key and max_age β€” so the parent's viewer and version must cover what the child varies on.

With on:, a cache: component loads on the defer lane rather than the action pipeline: reactive:before-dispatch still fires and can veto it (a vetoed shell, cached or not, ignores later events until the next morph), but a failed load emits reactive:error (kind: "defer") with a retry(). A reply that was redirected or is not a turbo-stream is a failed load, never rendered.

Within max_age the browser does not ask the server, so a revoked permission or a sign-out is not seen until the copy expires: keep max_age short for sensitive content and send Clear-Site-Data: "cache" on sign-out. If you move the endpoint (Phlex::Reactive.fragment_path), render <meta name="phlex-reactive-fragment-path" content="…"> in your layout's <head>; without it the client refuses the URL (reactive:error), and phlex_reactive:doctor says so.

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;
#    component content still bypasses it. flash_component is an app-owned
#    CALLABLE (issue #182): it maps (level, content) to your component β€”
#    the gem never guesses kwargs:
Phlex::Reactive.flash_component = ->(level, content) { MyFlash.new(level:, content:) }   # default nil β†’ the built-in wrapper

Server-pushed client ops (reply.js + broadcast_to(js:), 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

Focus ops stick (#446). Turbo renders every stream message inside its focus preservation: one animation frame after the render, it focuses the element that had focus before again when that element has an id and focus moved. So the first focus, focus_first or blur op in a reply's chain, and the ops after it, run on the next frame (in a hidden tab, once it is visible again), when Turbo has found nothing to restore. The ops before it still run in the render pass, and a separate .js stream in the same reply does not wait for them. After a morph or update that keeps the field, a field blurred by a reply stays blurred and its on_client(:focus, …) binding does not run again. A replace removes the focused field, so Turbo focuses the new one first (its own restore: that binding runs once); chain js.blur(…).hide(…) so the hide comes after it. on_client ops run in no stream render and stay synchronous.

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_to(user, :alerts,
  js: js.add_class("#bell", "has-unread"), exclude: reactive_connection_id)

broadcast_to with js: refuses the actor-only ops (focus/focus_first/blur/ show_modal/close_dialog/submit/paste_into/preview_file/persist_state/persist_clear/check_group/set_value raise ArgumentError): broadcasting focus (or a modal) would steal it in every subscriber's tab, a broadcast submit would force-submit every subscriber's form, and a broadcast clipboard read, draft write, selection change or field write would be hostile β€” these belong to the actor's own reply or gesture. It refuses keep: true on any op too (#369): kept state belongs to the tab that ran the op. Everything else is a fair broadcast (class and attribute toggles, text, 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. The exception is an op with keep: true (#369), whose result survives a morph of its target in the tab that ran it. State that must survive a replace, or that the server must know, 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. reply is the ONE documented door (issue #182): the old Phlex::Reactive::Response.replace(self) class verbs are removed and raise a guided ArgumentError naming the reply.<verb> rewrite; 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
feature a part of the client that loads on demand (a feature module, e.g. the reactive_persist drafts) could not be used on this root feature, phase, error; no action, no retry

feature only occurs with the opt-in split client: the default client has every feature inside its one file. The error carries the module's name and a phase: "load" (the import failed β€” it stays failed until the page is reloaded, because browsers cache a failed module), "timeout" (not there after 10 s; the root carried on without it, and it still connects if it arrives), "connect" (it threw while wiring the root) or "detect". The root's other behavior keeps working, and the root is marked data-reactive-error="feature" each time it connects for as long as the page lives. What a missing feature costs: without persist a form keeps no draft; without defer a plain reactive_lazy shell never loads (its pending marker is cleared, so it does not shimmer forever) while a reactive_lazy(on:) shell still loads through the plain action. The 10 s is <meta name="phlex-reactive-feature-timeout" content="ms"> in <head>.

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(todo, to: :notifications)   # append row + bump count + clear empty-state
  end

  def dismiss(id:)
    todo = Todo.find(id)
    todo.destroy!
    reply.remove(todo, from: :notifications)   # 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(model, to: name) append the row into the container + update the count + remove the empty-state when the list crosses 0β†’1
reply.prepend(model, to: name) as append, but the row goes to the top
reply.remove(model, from: name) 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(todo, from: :items) 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, broadcast the delta with NotificationsList.broadcast_collection_to(*keys, container: self, in: :notifications, append: model, exclude: reactive_connection_id) β€” that emits the row plus the count companion plus the empty-state toggle, through the same decisions the reply path uses. (Plain broadcast_to(append:) still works and still emits the bare row: it has no container instance, so it cannot resolve the declaration or run size:.)
  • When the work is asynchronous, an action that merely enqueues it cannot reply with a delta at all β€” the reply renders before the job touches the database. Use reply.pending and let the job settle the row.

Async actions (reply.pending + reactive_settle)

An action that does the work can reply honestly. An action that enqueues the work cannot β€” and until issue #248 every app worked around it the same way.

The endpoint runs your action inside a transaction and renders the reply there, while the queue adapter publishes on commit. So any reply.morph after an enqueue renders from a database the job has not touched yet, and is guaranteed to draw the pre-job world β€” rows still present, buttons still live, counts unchanged β€” sitting right beside the "Queued 177 transfers" flash the same reply emitted:

def restore_all
  count = BatchRestoreService.call(bulk_payment: @bulk_payment)   # fans out N jobs
  reply.morph.flash(:notice, "Putting #{count} back…")            # renders the PRE-job world
end

The usual fix is a queued: kwarg threaded into every row component with a second render branch, a @queued_* flag on the container, and the header button's count forced to 0 β€” ~60 lines of identical bookkeeping per screen. And it still never tells the operator the outcome: the page says "Queued" forever until someone reloads.

reply.pending replaces all of it:

class ReconcileQueue < ApplicationComponent
  include Phlex::Reactive::Component

  reactive_collection :unreconcilable,
    item: TransferRow, container: "unreconcilable",
    count: "unreconcilable-count", empty: NothingToReconcile,
    size: -> { @bulk_payment.transfers.unreconcilable.count }

  action :re_execute, params: {transfer_id: :integer}
  action :restore_all

  # ONE record β€” the job settles it, and the subscription tears itself down.
  def re_execute(transfer_id:)
    transfer = @bulk_payment.transfers.re_executable.find(transfer_id)
    authorize! transfer, :update?
    reply.pending(transfer, in: :unreconcilable, job: ReExecuteJob, args: [transfer.id])
  end

  # A FAN-OUT β€” the enqueue lives in the block, so the service object's own
  # perform_later calls capture the settle handle too.
  def restore_all
    authorize! @bulk_payment, :update?
    restorable = @bulk_payment.transfers.restorable.to_a
    reply.pending(restorable, in: :declined, peers: true) do
      BatchRestoreService.call(bulk_payment: @bulk_payment)
    end.flash(:notice, "Putting #{restorable.size} back…")
  end
end

and the job settles it when the work is actually done:

class ReExecuteJob < ApplicationJob
  include Phlex::Reactive::Settles

  def perform(transfer_id)            # signature UNCHANGED
    transfer = Transfer.find(transfer_id)
    result   = Transfers::ReExecuteService.call(transfer:)

    reactive_settle do |s|
      if result.success?
        s.remove(transfer)                     # row + count + empty-state
      else
        s.replace(transfer)                    # back to actionable
        s.flash(:alert, result.error)          # the operator learns the outcome
      end
    end
  end
end

and the case that motivated the whole thing β€” work that moves a record between two lists β€” is one call:

reactive_settle { |s| s.move(transfer, from: :declined, to: :unreconcilable) }

What the reply actually does

reply.pending deliberately does NOT re-render the container (that is the bug). It emits:

  1. a data-reactive-pending="true" + aria-busy="true" marker on every target, over the existing reactive:js op lane β€” style it in one CSS rule:

    [data-reactive-pending] { opacity: .5; pointer-events: none; }
  2. one subscription directive, anchored on the container, opening a single durable one-shot stream that all N settles share;

  3. an inert reactive:token refresh, so the container's signed token rolls forward and the list is not act-once-only.

Honest pending markup: pending_template (opt-in)

The markers dim a live row. When the pending state should be real markup β€” a "Queued" badge, a button that is genuinely gone rather than hidden by pointer-events: none β€” define pending_template on the row component (private is fine). reply.pending then also replaces each such row with it, just before marking it:

class TransferRow < ApplicationComponent
  include Phlex::Reactive::Streamable
  include Phlex::Reactive::Component

  def view_template
    li(**mix(reactive_attrs, id:)) { span { @transfer.label }; button(**on(:retry)) { "Retry" } }
  end

  private

  def pending_template
    li(**mix(reactive_attrs, id:)) { span { @transfer.label }; span(class: "badge") { "Queued" } }
  end
end

A row without the hook is byte-identical to the marker-only reply.

  • Keep the root's id. The settle targets it; a pending_template without id="<the row's id>" raises before anything is enqueued.
  • Keep reactive_attrs if the row carries its own token β€” the swapped row signs the row class's real name, so its next action verifies as usual.
  • Un-pending is the settle's job. Markers are attributes any settle strips; swapped markup is only undone by a settle that replaces (s.replace) or removes (s.remove) the row. A flash-only settle, finish: true, or a failed job clears the markers but leaves the pending markup in place.
  • It is a template method, unlike deferred_placeholder (which returns inner content for a shell the gem owns): here the row's own root is swapped.
reply.pending(...)
records one record, an enumerable of records, or built Streamable components
in: :name the reactive_collection the records live in β€” how their row DOM ids and the count/empty-state bookkeeping are resolved (required for records)
a block your enqueue. Anything ActiveJob-enqueued inside it captures the settle handle, including from a service object
job: / args: sugar for the common case. args: is an Array (one record) or a Proc called per record (args: ->(r) { [r.id] }); omitted means perform_later(record)
peers: true also broadcast each settle to the container's record stream, so a second operator watching the same batch sees it. Default is actor-only, matching reply.defer
reactive_settle yields a settle builder
s.replace(record) re-render the row in place (no count churn β€” a replace moves no boundary)
s.remove(record, from: :name) row + count + empty-state restore. from: defaults to the collection reply.pending named
s.append(record, to: :name) / s.prepend row + count + empty-state clear
s.move(record, from:, to:) ordered remove-then-append between two collections
s.count(:name) refresh only the count companion
s.flash(level, content) tell the operator the outcome
s.js(ops) / s.streams!(*raw) the reply.js / reply.streams escape hatches

The rules that make it safe

  • The handle rides ActiveJob metadata, not perform's arity. reply.pending installs it in a thread-local and runs your enqueue inside it; Settles#initialize captures it onto the job instance and #serialize copies it into the job's metadata. So every other caller of the same job β€” a nightly sweep, a webhook β€” keeps working unchanged, and reactive_settle is simply a no-op there. That is load-bearing: these jobs almost always have non-UI callers.
  • It works under enqueue_after_transaction_commit = true. Rails defers that enqueue to ActiveRecord.after_all_transactions_commit, and the endpoint runs your action inside a transaction β€” so the enqueue (and its serialize) happens after the block has exited. The capture is therefore at job instantiation, which is synchronous inside the block either way. Nothing to configure.
  • A job that raises still clears the pending state β€” when it can attribute it. The job:/args: form enqueues one job per record and narrows each job's handle to that record's target, so a failure clears exactly its row and the container, then re-raises for your retry policy. The block form cannot be narrowed (the gem cannot map an arbitrary enqueue back to a record), so a failure there clears nothing and logs why β€” un-dimming 176 rows that are still working would be a worse lie. Those markers clear at finish: true. The subscription is deliberately not torn down on failure β€” a retry must still be able to reach the actor.
  • Finishing clears every target, not just the container. A settle that only flashes emits no row stream, so nothing swaps that row's node β€” the finish sweep is what removes its markers.
  • ONE stream key per reply.pending call. A durable broadcast to a never-seen key creates a real PGMQ table (reclaimed by pgbus's hourly orphan sweep at a 24h threshold), so a key per record would leave 177 tables sitting for a day and open 177 SSE connections. All N settles share one key and one subscription.
  • Teardown is therefore explicit. Because the key is shared, tearing down on the first arrival would cut off the other Nβˆ’1. reactive_settle finishes automatically when reply.pending marked exactly one target; a fan-out passes finish: true from whatever knows it is last (a Pgbus::Batch on_finish callback, or the final job of a staggered sequence). Until then the subscription is superseded by the container's next reply.pending or closed when the page unloads.
  • reply.pending needs the defer PUSH lane (Phlex::Reactive.settle_capable? β€” pgbus reactive Streams + SignedName + ActiveJob, and defer_transport not forced to :fetch). A settle has no pull fallback: the client cannot poll "is the job done yet". Without it, reply.pending degrades rather than breaks β€” your enqueue still runs, no handle is installed, and no pending markers are emitted, so the UI shows the pre-job world (today's behavior) instead of a shimmer that could never resolve. A one-time warning says so.
  • The pgbus CLIENT must be on the page too. The server picks the push lane on server-side capability alone. If the browser has no <pgbus-stream-source> custom element registered, reply.defer degrades to its fetch token β€” but a settle has no fetch lane, so the subscription never opens and the pending markers would sit there. The client logs a loud console error naming the cause. Load pgbus's client wherever you use reply.pending; it is the same prerequisite the defer push lane already has.
  • Authorization is still yours. reply.pending signs the container's identity so the job can rebuild it; that is not permission to act. authorize! in the action, exactly as everywhere else.
  • One reply.pending per container, per reply. Every pending segment emits a directive targeting the container's id, and the client keys subscriptions by target β€” so a second call would supersede the first and orphan its jobs. The second call raises. Mark every target in one call; the settle names its own collection (s.remove(record, from: :name)).
  • Peer delivery is best effort. If a peer broadcast fails after the actor's message already went out, the job is not failed β€” retrying it would re-run perform and send the actor's settle (and its flash) a second time. The failure is logged instead.

Broadcasting a collection delta to peers

broadcast_to(append:) emits the bare row β€” it has no container instance, so it cannot resolve the declaration or run the size resolver. Its collection counterpart does:

ReconcileQueue.broadcast_collection_to(@bulk_payment, :transfers,
  container: self, in: :unreconcilable, remove: transfer,
  exclude: reactive_connection_id)

row: carries the row component's extra init kwargs β€” pass the same ones the actor got, or a peer whose row has a required kwarg raises instead of rendering. A remove: may be a record or an already-built dom-id string, matching reply.remove(id, from:).

Row plus count companion plus empty-state toggle, through the same Phlex::Reactive::Collections decisions the reply path uses β€” so the two can never drift. coalesce: (default Phlex::Reactive.settle_coalesce_window_ms, 50 ms) applies to the aggregate streams only: the count and empty-state are idempotent replaces of stable targets, so a 177-row fan-out collapses to a handful of them. The row stream is never coalesced (an append is not idempotent). Coalescing needs pgbus with zoolutions/pgbus#465; on anything older the window is ignored and every aggregate goes out β€” chattier, equally correct.

Settle configuration

Setting Default Purpose
Phlex::Reactive.settle_coalesce_window_ms 50 Window for the aggregate (count / empty-state) streams on the peers path.

There is deliberately no settle_token_ttl. A settle has no pull lane for a token to govern β€” the client cannot poll "is the job done yet", and redeeming such a token at the defer endpoint would render the pre-job component, i.e. the exact bug reply.pending fixes. The settle's wait is bounded by the job, not by a TTL.

Effects β€” animate enter/exit/update (opt-in)

Reactive updates land instantly and invisibly: a removed row pops out of existence, an appended row pops in, a cross-tab change gives no cue. Effects make the reactivity visible β€” rows fade/slide in and out, updates flash β€” with zero app JS. Strictly opt-in at three levels, most specific wins:

# 1. GLOBAL β€” setting it is the opt-in AND the app-wide default set:
Phlex::Reactive.effects = true   # { enter: :fade, exit: :fade, update: :highlight }
Phlex::Reactive.effects = { enter: :slide, exit: :fade, update: :highlight }

# 2. PER COMPONENT β€” refine or opt out (works standalone too; declaring on a
#    component opts it in even without the global switch):
class Notifications::Row < ApplicationComponent
  reactive_effects enter: :slide, exit: :fade   # built-ins (shipped CSS)
  # reactive_effects update: false              # disable one hook
  # reactive_effects false                      # opt this component out entirely
  # reactive_effects enter: :random             # a random built-in per application
end

# 3. PER CALL β€” the escape hatch for one stream:
reply.remove(effect: :shake)                    # a dramatic one-off exit
reply.append(item, to: :items, effect: :scale)  # this row only
Item.replace(@todo, effect: false)              # suppress a declared effect once
Row.broadcast_to(@list, :todos, append: todo, target: "rows", effect: :slide)

The hooks map to stream actions: enter (append/prepend) animates the arriving element, exit (remove) runs before the element leaves the DOM (the removal waits for the animation, capped at 1s so a missing stylesheet can never wedge it), update (replace/update, plain or morph) flashes the fresh render. Effects fire for the actor's own reply AND for broadcasts alike β€” if a debounced-save grid flashes too much, declare reactive_effects update: false on that component.

Link the shipped stylesheet once (five built-ins β€” :fade, :slide, :scale, :highlight, :shake β€” wrapped in prefers-reduced-motion: no-preference, tunable via --reactive-fx-* CSS custom properties):

<%= stylesheet_link_tag "phlex/reactive/effects" %>

Custom effects skip the stylesheet entirely: pass named class legs (the same { during:, from:, to: } vocabulary js.toggle(transition:) uses), perfect for Tailwind utilities:

reactive_effects enter: { during: %w[transition-all duration-300],
                          from: %w[opacity-0 translate-y-2],
                          to: %w[opacity-100 translate-y-0] }

Unknown effect names raise at class load (server) and warn-and-skip on the client (default-deny, two-sided). With effects off (the default) the wire is byte-identical to previous releases; with them on, the resolved hooks ride the component root as data-reactive-effect-* attributes β€” identity, never state, and identical on Action Cable and pgbus.

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).
# By name, like base_controller_name, it follows a code reload in development;
# a Class keeps rendering the class object it was given:
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 AND a 500 crash) 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, or :error for an action-body crash), 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

# Effects (issue #215): opt in globally + set the app-wide default hooks; see
# "Effects" above. Off (nil) by default β€” byte-identical wire when off.
Phlex::Reactive.effects = { enter: :fade, exit: :fade, update: :highlight }

# Turnkey APM integration. Names each action Component#action in AppSignal/Sentry/
# Datadog and reports action-body crashes with component/action tags. SDK is
# runtime-detected (no gem dependency); a custom object responding to
# record_action/record_error works too. See "Observability" above.
Phlex::Reactive.apm = :appsignal

# Report action-body crashes to any tracker yourself (the DIY escape hatch):
Phlex::Reactive.on_action_error do |error, ctx|
  Honeybadger.notify(error, context: { component: ctx[:component], action: ctx[:action] })
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

The configuration is finalized at boot. Once the app has booted (after your initializers and every after_initialize block), the engine calls Phlex::Reactive.finalize! for you: the param-type and named-schema registries freeze, and every setting is stored as a frozen, Ractor-shareable copy (the objects you handed in are not frozen). Append to authorization_errors in an initializer; after boot, assign a new list (<< raises FrozenError). A value no Ractor can share (a block written in an initializer, a lambda that closes over app state, a logger, an APM object, the verifier) is kept as it is and works on the main Ractor only: finalizing never raises for it. Booting without the engine? Call Phlex::Reactive.finalize! at the end of your own boot.

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.

The flag reaches the client too: reactive roots (and server-emitted reactive:js streams) carry data-reactive-verbose="true" when it is on, and the client op interpreter uses that gate to console.warn when a client op β€” on_client, a reducer's $ops, reply…js(...), broadcast_js_to, or a busy/optimistic hint β€” resolves zero targets. The warning names the op, the selector, and the scoping root, dedupes per unique case per page, and hints the documented escape (to: :root / global: true) when the element exists but was filtered out by root scoping or the nested-root ownership rule β€” the trap where the element is right there in the DOM, just outside the op's scope. A reactive:js stream whose target root id has left the DOM warns the same way instead of silently dropping its ops. Production (flag off) renders no attribute and stays byte-identical and silent.

See docs/security.md for the threat model and a checklist.


How it beats Stimulus + Turbo (same feature, less code)

A counter, today vs. with phlex-reactive:

Stimulus + Turbo phlex-reactive
// 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.


Live updates with pgbus (recommended)

pgbus replaces Action Cable's transport with Postgres SSE and fixes its reliability gaps. With it installed, broadcast_to and turbo_stream_from route over pgbus automatically:

class Message < ApplicationRecord
  broadcasts_to ->(m) { [m.room, :messages] }, durable: true
end
  • Transactional: a broadcast inside a transaction that rolls back never fires β€” and the DB change is undone. No "ghost" UI updates.
  • Reconnect-safe: a tab that dropped replays missed messages on reconnect (Last-Event-ID + PGMQ archive).
  • No race on subscribe: messages broadcast between render and subscribe are replayed, not lost.
  • No Redis, no Action Cable.

See docs/broadcasting.md and docs/transport-pgbus.md.


Observability

The hot paths emit ActiveSupport::Notifications events, so an APM (AppSignal, Datadog, Skylight) sees reactive traffic at the component level β€” which component/action a slow request was, render time, and broadcast fan-out. Three events, all under the phlex_reactive namespace:

Event Fires Payload
action.phlex_reactive once per request component, action, outcome (ok/denied_undeclared/invalid_token/not_found/unauthorized/unverified)
render.phlex_reactive per component render component, bytesize
broadcast.phlex_reactive per broadcast_to call (Action Cable and pgbus) component, stream_action, streamables
defer.phlex_reactive once per deferred render (the reply.defer pull/push endpoint) component, outcome (ok/no_content/invalid_token/not_found/unauthorized)

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.

Turnkey APM adapters (AppSignal, Sentry, Datadog)

Subscribing by hand is the DIY path. For the common trackers there's a one-liner that both names the transaction and reports errors β€” so reactive traffic stops rolling into one blurry ActionsController#create and a crash arrives at your tracker with context:

# config/initializers/phlex_reactive.rb
Phlex::Reactive.apm = :appsignal   # or :sentry, :datadog, or a custom object

With it set:

  • Each reactive action shows in the APM as its OWN transaction/span β€” Counter#increment, not Phlex::Reactive::ActionsController#create β€” tagged with the component, action, and outcome.
  • An action body that raises a genuine error (a 500, not a registered 4xx) is reported to the tracker with component/action tags, then re-raised unchanged so Rails' own error reporting still fires.

The SDK is runtime-detected β€” no gem dependency is added. If the named SDK isn't loaded, apm = logs one warning at boot and no-ops (the same optionality invariant as pgbus). A custom object works too β€” anything responding to record_action(payload, duration_ms) and record_error(error, payload).

For a tracker with no built-in adapter, report errors yourself β€” this hook fires on any previously-uncaught action-body error, with the name-only context:

Phlex::Reactive.on_action_error do |error, ctx|
  Honeybadger.notify(error, context: { component: ctx[:component], action: ctx[:action] })
end

And to show the user a flash when an action crashes (the SAME hook the 4xx errors already use β€” kind is :error for a crash):

Phlex::Reactive.error_flash = ->(kind) { "Something went wrong β€” please retry." }

See docs/observability.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(..., empty_groups: []) POST a signed token to Phlex::Reactive.action_path exactly as the client does; empty_groups: lists the groups the client cleared by name, without the [], the way a form body sends them. Token minting β€” reactive_token_for(component_or_class, payload = {}).

System helpers (Phlex::Reactive::TestHelpers::System, Capybara-gated) β€” wait_for_reactive, have_reactive_value, have_reactive_text, and request counting: reset_reactive_requests! baselines, have_reactive_requests(n, kind: nil) waits until nothing is in flight and asserts exactly n action (kind: :action) or deferred-render (kind: :defer) requests, and reactive_request_count returns { action:, defer: }. The client keeps the totals on <html data-reactive-requests> under the verbose gate only. The matcher waits for requests in flight, not ones yet to start (a debounced trigger, a defer the reply is about to start): wait for the UI outcome first. A defer pushed over a pgbus stream makes no request and is not counted. A reactive_lazy cache: fragment GET counts as :defer per fetch() call, even when the browser cache answers it: assert "no network request" with Resource Timing (transferSize === 0).

reset_reactive_requests!
find("#open-panel").click
expect(page).to have_reactive_requests(1)

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.