{"id":51353759,"url":"https://github.com/mhenrixon/phlex-reactive","last_synced_at":"2026-07-02T18:02:05.815Z","repository":{"id":366598634,"uuid":"1275354658","full_name":"mhenrixon/phlex-reactive","owner":"mhenrixon","description":"Reactive Phlex components for Rails — Livewire-style actions and live cross-tab updates, without Stimulus boilerplate.","archived":false,"fork":false,"pushed_at":"2026-06-29T20:54:11.000Z","size":957,"stargazers_count":18,"open_issues_count":2,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-29T21:07:11.416Z","etag":null,"topics":["hotwire","pgbus","phlex","rails","reactive","ruby","turbo"],"latest_commit_sha":null,"homepage":"https://mhenrixon.github.io/phlex-reactive","language":"Ruby","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/mhenrixon.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE.txt","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"docs/security.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-06-20T15:26:19.000Z","updated_at":"2026-06-29T20:54:12.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/mhenrixon/phlex-reactive","commit_stats":null,"previous_names":["mhenrixon/phlex-reactive"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/mhenrixon/phlex-reactive","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mhenrixon%2Fphlex-reactive","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mhenrixon%2Fphlex-reactive/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mhenrixon%2Fphlex-reactive/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mhenrixon%2Fphlex-reactive/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/mhenrixon","download_url":"https://codeload.github.com/mhenrixon/phlex-reactive/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mhenrixon%2Fphlex-reactive/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35057450,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-02T02:00:06.368Z","response_time":173,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["hotwire","pgbus","phlex","rails","reactive","ruby","turbo"],"created_at":"2026-07-02T18:02:04.795Z","updated_at":"2026-07-02T18:02:05.773Z","avatar_url":"https://github.com/mhenrixon.png","language":"Ruby","funding_links":[],"categories":[],"sub_categories":[],"readme":"# phlex-reactive\n\n[![CI](https://github.com/mhenrixon/phlex-reactive/actions/workflows/main.yml/badge.svg)](https://github.com/mhenrixon/phlex-reactive/actions/workflows/main.yml)\n[![Gem Version](https://img.shields.io/gem/v/phlex-reactive)](https://rubygems.org/gems/phlex-reactive)\n[![Docs](https://img.shields.io/badge/docs-phlex--reactive.zoolutions.llc-blue)](https://phlex-reactive.zoolutions.llc)\n\n**Reactive [Phlex](https://www.phlex.fun) components for Rails — Livewire-style\nactions and live cross-tab updates, without writing Stimulus controllers or\nhand-picking Turbo Stream targets.**\n\n📖 **[Full documentation](https://phlex-reactive.zoolutions.llc)**\n\n```ruby\nclass Counter \u003c ApplicationComponent\n  include Phlex::Reactive::Component   # pulls in Streamable too\n\n  reactive_state :count\n  action :increment\n  action :decrement\n\n  def initialize(count: 0) = @count = count\n  def id = \"counter\"\n\n  def increment = @count += 1\n  def decrement = @count -= 1\n\n  def view_template\n    div(**reactive_root) do\n      button(**on(:decrement)) { \"−\" }\n      span { @count }\n      button(**on(:increment)) { \"+\" }\n    end\n  end\nend\n```\n\nThat's the whole counter. **No Stimulus controller. No `.turbo_stream.erb`. No\nroute. No hand-picked target.** Click `+` and the count updates in place.\n\n---\n\n## Why\n\nStimulus + Turbo are powerful but tedious. A single interactive widget means a\nStimulus controller, a `data-*` soup, a `.turbo_stream.erb` view, a controller\naction, and a hand-picked `dom_id` target — repeated for every feature. The\nmental model is \"wire everything by hand.\"\n\nphlex-reactive borrows the **mental model** that makes Livewire and Phoenix\nLiveView pleasant — *a component has state and actions; change state and the UI\nfollows* — and implements it the Rails way:\n\n- **Actions are Ruby methods.** Declare `action :increment`; the client calls it.\n- **Re-render is auto-targeted.** A component owns a stable `id`; the response is\n  a `\u003cturbo-stream\u003e` that replaces it. You never pick a target.\n- **The same unit re-renders for clicks AND broadcasts.** A click and a\n  background broadcast both produce \"replace the component by its id,\" so live\n  cross-tab updates are the same mechanism as local interactivity.\n- **State lives in your database, not the browser.** The DOM carries only a\n  *signed identity* (a record's GlobalID), not a snapshot of state — so there's\n  no mass-assignment surface and no re-signing protocol.\n- **One tiny client runtime.** A single generic Stimulus controller, registered\n  once, handles every reactive component. You don't write per-feature JS.\n\nPair it with [**pgbus**](https://github.com/mhenrixon/pgbus) and your live\nupdates become *transactional* (no broadcast for a rolled-back change) and\n*reconnect-safe* (missed messages replay) over Postgres SSE — **no Action Cable,\nno Redis.**\n\n---\n\n## Installation\n\n```ruby\n# Gemfile\ngem \"phlex-reactive\"\n```\n\n```bash\nbundle install\n```\n\nThen run the installer — it registers the client controller and writes a config\ninitializer:\n\n```bash\nbin/rails generate phlex:reactive:install\n```\n\nThat's all for **importmap** apps: the engine mounts the action endpoint at\n`/reactive/actions` and auto-pins (and preloads) the client runtime, and the\ninstaller adds the eager registration below to your Stimulus entrypoint.\n\n\u003cdetails\u003e\n\u003csummary\u003eWhat the installer wires (or do it by hand)\u003c/summary\u003e\n\n```js\n// app/javascript/controllers/index.js\nimport { application } from \"controllers/application\"\nimport ReactiveController from \"phlex/reactive/reactive_controller\"\napplication.register(\"reactive\", ReactiveController)\n```\n\nRegister eagerly (not lazily) so a click immediately after load is never missed.\n\u003c/details\u003e\n\n### Scaffold a component\n\n```bash\n# state-backed (record-less)\nbin/rails generate phlex:reactive:component Counter increment decrement\n\n# record-backed (signed GlobalID identity)\nbin/rails generate phlex:reactive:component Todos::Item toggle rename --record todo\n```\n\nGenerates the component (and an RSpec spec if your app uses RSpec).\n\n\u003cdetails\u003e\n\u003csummary\u003eesbuild / webpack / bun\u003c/summary\u003e\n\nImport and register it from your controllers entrypoint:\n\n```js\nimport { application } from \"./application\"\nimport ReactiveController from \"phlex/reactive/reactive_controller\"\napplication.register(\"reactive\", ReactiveController)\n```\n\nThe JS ships at `app/javascript/phlex/reactive/reactive_controller.js` in the\ngem; point your bundler at the gem path or copy it in. See\n[docs/installation.md](https://phlex-reactive.zoolutions.llc/docs/installation).\n\u003c/details\u003e\n\n**Requirements:** Rails 7.1+, Phlex 2 (`phlex-rails`), Turbo 8+ (for morphing),\nand a Phlex `ApplicationComponent` base class. pgbus is optional but recommended\nfor broadcasting.\n\n### Integration troubleshooting (silent \"nothing happens\")\n\nTwo host-app setups make the first reactive component *silently do nothing* —\ncomponents render, but no action ever fires, with no error pointing at the cause.\nThe gem now logs a warning for each, but here are the fixes:\n\n**A catch-all route shadows `POST /reactive/actions`.** The engine appends its\nroute *after* everything in your `config/routes.rb`, so a bottom-of-file\ncatch-all wins and every reactive POST 404s:\n\n```ruby\n# config/routes.rb — a catch-all like this shadows the engine's appended route\nmatch \"*path\", to: \"errors#not_found\", via: :all\n```\n\nExempt the reactive path from the catch-all (or set\n`Phlex::Reactive.action_path` to an unshadowed path):\n\n```ruby\nmatch \"*path\", to: \"errors#not_found\", via: :all,\n  constraints: -\u003e(req) { !req.path.start_with?(\"/reactive/\") }\n```\n\nAt boot the gem warns (`[phlex-reactive] POST /reactive/actions does not resolve\nto phlex/reactive/actions …`) when the route is shadowed.\n\n**The `reactive` controller isn't registered (`lazyLoadControllersFrom` apps).**\n`lazyLoadControllersFrom(\"controllers\", application)` only registers controllers\nunder `app/javascript/controllers/`. The gem's controller lives outside that dir,\nso `data-controller=\"reactive\"` does nothing until you register it explicitly:\n\n```js\n// app/javascript/controllers/index.js (or your Stimulus entrypoint)\nimport ReactiveController from \"phlex/reactive/reactive_controller\"\napplication.register(\"reactive\", ReactiveController)\n```\n\nIf reactive elements are on the page but the controller never connected, the\nruntime logs a console warning (`[phlex-reactive] found N element(s) with\ndata-controller=\"reactive\" but the reactive controller never connected …`).\n\n---\n\n## The mental model in one picture\n\n```\n   ┌── click / input ──────────────────────────────────────────┐\n   │                                                            ▼\n[ button(**on(:increment)) ]          POST /reactive/actions { token, act, params }\n   ▲                                                            │\n   │                                          verify signed token (no state trusted)\n   │                                          rebuild component (record from DB)\n   │                                          run the whitelisted action\n   │                                          re-render → \u003cturbo-stream replace id\u003e   (default; an action\n   │                                          may return reply.\u003cverb\u003e — see \"Controlling the action's reply\")\n   └──────── Turbo applies it in ◀──────────────────────────────┘\n\n   ...and for OTHER tabs/users:\n   model change → Component.broadcast_replace_to(stream) → pgbus SSE → same morph\n```\n\nClient actions and server broadcasts **converge on one re-render unit**: the\ncomponent, targeted by its `id`.\n\n---\n\n## Quickstart: a live, cross-tab counter\n\n```ruby\n# app/components/counter.rb  — see the top of this README for the full class\nrender Counter.new(count: 0)\n```\n\nOpen the page in two tabs, click `+` — done. To make it update across tabs when\nthe underlying record changes, use a record-backed component (below).\n\n---\n\n## Two kinds of reactive component\n\n### 1. Record-backed (the common case)\n\nState lives in an ActiveRecord row. The signed identity is the record's\nGlobalID; the server re-finds it on each action. **Always prefer this.**\n\n```ruby\nclass Todos::Item \u003c ApplicationComponent\n  include Phlex::Reactive::Component\n\n  reactive_record :todo             # identity AND the default #id: dom_id(@todo)\n  action :toggle\n  action :rename, params: { title: :string }\n\n  def initialize(todo:) = @todo = todo\n\n  def toggle\n    authorize! @todo, :update?      # YOU authorize — the token only proves identity\n    @todo.toggle!(:done)\n  end\n\n  def rename(title:)\n    authorize! @todo, :update?\n    @todo.update!(title:)\n  end\n\n  def view_template\n    li(**reactive_root(class: (\"done\" if @todo.done?))) do\n      button(**on(:toggle)) { @todo.done? ? \"✓\" : \"○\" }\n      span { @todo.title }\n    end\n  end\nend\n```\n\n\u003e **One include, default `#id` (issue #81).** `include Phlex::Reactive::Component`\n\u003e pulls in `Streamable` automatically (the explicit two-include form still works\n\u003e and is a harmless no-op). A record-backed component also gets `#id` for free —\n\u003e `dom_id(record)`, exactly the id nearly every one wrote by hand — so `def id`\n\u003e is only needed to override it, and an explicit `def id` always wins.\n\u003e **Caveat:** two *different* component classes rendering the *same* record on\n\u003e one page both default to the same `dom_id` and collide — give one an explicit\n\u003e prefixed id: `def id = dom_id(@todo, \"rich\")`. State-backed components still\n\u003e must define `#id` (they're frequently multi-instance, so a class-name default\n\u003e would silently collide; the loud `NotImplementedError` stays).\n\n### 2. State-backed (signed instance vars)\n\nSign small, JSON-serializable instance vars into the token. Use it **alone** for\na record-less widget (a counter, a wizard step), or **alongside `reactive_record`**\nto carry transient UI state — which field, what mode — next to the row. Both the\nrecord's GlobalID and the state are signed into one token and rebuilt on each\naction. Keep state small and JSON-serializable.\n\n```ruby\nreactive_state :count, :step       # signed; rebuilt on each action\n```\n\nThe [inline edit example](https://phlex-reactive.zoolutions.llc/docs/example-inline-edit) combines both: a\n`reactive_record :record` plus `reactive_state :attribute, :editing`.\n\n---\n\n## Concrete examples\n\n| Example | What it shows |\n|---|---|\n| [Counter](https://phlex-reactive.zoolutions.llc/docs/example-counter) | State-backed, the smallest reactive component |\n| [Payment split](https://phlex-reactive.zoolutions.llc/docs/example-payment-split) | Live sum-to-total rebalancer — nested bracketed params, a disabled computed field, auto-collected siblings (#64–#67) |\n| [Cross-tab chat](https://phlex-reactive.zoolutions.llc/docs/example-chat) | Record-backed action **+ pgbus broadcast** → live sync across tabs/browsers |\n| [Live todo list](https://phlex-reactive.zoolutions.llc/docs/example-todo-list) | Per-row components, add/toggle/rename/delete, Enter-to-add, broadcast on change |\n| [Inline edit](https://phlex-reactive.zoolutions.llc/docs/example-inline-edit) | Show ↔ edit mode toggle, replacing a Stimulus controller + 3 routes |\n| [Notifications / badges](https://phlex-reactive.zoolutions.llc/docs/example-notifications) | Pure broadcast (no client action) — a job pushes a re-render |\n\nThe cross-tab chat in ~60 lines of Ruby (and zero JS) is the showcase — see\n[docs/examples/chat.md](https://phlex-reactive.zoolutions.llc/docs/example-chat).\n\n---\n\n## API reference\n\n### `Phlex::Reactive::Streamable`\n\n| Method | Use |\n|---|---|\n| `#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. |\n| `.replace(model = nil, morph: false, **opts)` | `\u003cturbo-stream action=replace target=id\u003e` of a freshly built component; `morph: true` adds `method=\"morph\"` |\n| `.update` / `.append(target:)` / `.prepend(target:)` / `.remove` | The other Turbo Stream actions |\n| `.broadcast_replace_to(*streamables, model:, morph: false)` | Broadcast a replace over the stream transport (pgbus SSE / Action Cable); `morph: true` morphs in place |\n| `.broadcast_append_to(*streamables, target:, model:)` / `_update_` / `_prepend_` / `_remove_` | The broadcast variants |\n| `#to_stream_replace` / `#to_stream_morph` / `#to_stream_update` / `#to_stream_remove` | Stream the *already-built* instance (used internally after an action / by `reply`); `#to_stream_morph` morphs in place |\n\nUse in controllers: `render turbo_stream: Counter.replace(counter)`.\n\n### `Phlex::Reactive::Component`\n\n| Macro / helper | Use |\n|---|---|\n| `reactive_record :name` | Record-backed identity (GlobalID). State = the DB. Also defaults `#id` to `dom_id(record)`. |\n| `reactive_state :a, :b` | Signed instance-var identity. Standalone, or combined with `reactive_record` to sign transient UI state alongside the row. |\n| `action :name, params: { x: :integer }` | Declare a client-invokable action + its param schema. **Default-deny.** |\n| `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. |\n| `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. |\n| `on(:action, event: \"click\", **params)` | Spread onto a trigger element. Adds `type=button` for clicks. |\n| `on(:action, event: \"input\", debounce: 300)` | Coalesce rapid events into one round trip after a quiet period (live-as-you-type). |\n| `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](#keyboard-triggers-enter-to-submit--escape-to-cancel). |\n| `on(:action, confirm: \"Sure?\")` | Gate a destructive trigger behind a confirmation. Defaults to `window.confirm`; override the dialog with [`setConfirmResolver`](#custom-confirmation-dialogs-setconfirmresolver). |\n| `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](#combobox-keyboard-navigation-listnav). |\n| `on(:close_menu, outside: true)` | Fire only for events **outside** this component's root (close-a-dropdown-on-outside-click). Window-bound; never `preventDefault`s, so links elsewhere keep navigating. |\n| `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:`. |\n| `on(:action, once: true)` | Fire at most once, then unbind (Stimulus's native `:once`). |\n| `on_client(:click, js.toggle(\"#menu\"))` | **Client-only** trigger: applies declared DOM ops with ZERO round trip — no token, no POST, ever. Takes the same `window:`/`once:`/`outside:` modifiers. See [Client-only ops](#client-only-ops-on_client--js--zero-round-trips). |\n| `js` | The immutable op builder behind `on_client`: `show`/`hide`/`toggle` (the `hidden` attribute) and `add_class`/`remove_class`/`toggle_class`, chainable. |\n| `reactive_input(:param, **attrs)` / `reactive_select(:param, **attrs)` | Render a control already bound to an action param (no magic `name:`). |\n| `reactive_field(:param, **attrs)` | The attribute hash behind the above — spread onto any control. |\n| `nested_update!(:assoc, attrs)` | Map a nested param onto `\u003cassoc\u003e_attributes` with id preservation; update the record. |\n| `reactive_collection :name, item:, container:, count:, empty:, size:` | Declare an add/remove-row list once; actions call `reply.append`/`prepend`/`remove`. See [Reactive collections](#reactive-collections-addremove-rows--count--empty-state). |\n| `reply.replace` / `.morph` / `.update` / `.remove` / `.redirect(url)` / `.with(*)` | Return from an action to control the reply (flash, remove, redirect, multi-stream). See [Controlling the action's reply](#reply--controlling-the-actions-reply). |\n| `reply.append(name, model)` / `.prepend(...)` / `.remove(name, model)` | Add/remove a row in a declared `reactive_collection` (row + count + empty-state in one reply). |\n\nParam types: `:string` (default), `:integer`, `:float`, `:boolean`, `:file`.\nAnything not in the schema is dropped before reaching your method.\n\n**File uploads (`:file`).** Declare `:file` (or `[:file]` for multiple) to accept\nan uploaded file in a reactive action — attach a document/receipt/image to the\nrecord without dropping out to a bespoke controller. When the reactive root holds\na populated `\u003cinput type=\"file\"\u003e`, the client sends the action as multipart\n`FormData` (instead of JSON) — `token` + `act` as fields, scalar params as fields,\nany nested/array params bracket-expanded into `params[key][sub]` /\n`params[key][index]` fields (the same Rails-form shape, so a JSON body and a\nmultipart body coerce identically — #39), and the file(s) appended; the endpoint\ncoerces `:file` to the `ActionDispatch::Http::UploadedFile`, passed through\nuntouched. A non-file value sent to a `:file` param is dropped (the keyword\ndefault applies — never a fabricated file). Token threading and the\nre-render/morph are identical; only the request encoding changes when a file is\npresent.\n\n```ruby\nreactive_record :document\naction :upload, params: { file: :file, caption: :string } # single (has_one_attached)\naction :upload_pages, params: { pages: [:file] }           # multiple (has_many_attached)\n\ndef upload(file: nil, caption: nil)\n  @document.file.attach(file) if file\n  @document.update!(title: caption) if caption.present?\nend\n\ndef view_template\n  form(**on(:upload, event: \"submit\")) do\n    input(type: \"file\", name: \"file\")\n    input(name: \"caption\")\n    button(type: \"submit\") { \"Upload\" }\n  end\nend\n```\n\n\u003e **One multipart caveat:** `FormData` can't carry an *empty* array or hash, so on\n\u003e the multipart (file-present) path an empty `[]`/`{}` param is **omitted** and the\n\u003e action's keyword default applies — it does **not** arrive as an explicit empty\n\u003e collection the way it does over JSON. If you rely on sending `tags: []` to clear\n\u003e a collection, send that action *without* a file (the JSON path). A non-empty\n\u003e nested/array param rides along fine next to a file.\n\n**Array \u0026 nested params.** Wrap a type in an array for an array param, or a hash\nschema in an array for Rails-style nested attributes — so one reactive action can\nmirror a normal nested-attributes update instead of forcing a per-row component:\n\n```ruby\naction :save, params: {\n  date: :string,\n  bank_account_ids: [:integer],                         # array of scalar\n  invoice_items_attributes: [                            # array of hash\n    { id: :integer, quantity: :float, price: :float, _destroy: :boolean }\n  ]\n}\n\ndef save(date:, bank_account_ids:, invoice_items_attributes:)\n  @invoice.update!(date:, bank_account_ids:, invoice_items_attributes:)\nend\n```\n\nNested coercion recurses per field, drops undeclared nested keys, and accepts an\narray as either a JSON array or a Rails index hash (`{ \"0\" =\u003e …, \"1\" =\u003e … }`).\n\n**Model-scoped form fields just work.** A standard Rails `Form(model: @invoice)`\nnames its inputs `invoice[date]`, `invoice[status]`, … and the client posts those\nnames verbatim. A nested schema matches them with zero field renaming — the\nendpoint expands bracket notation before coercion, so `invoice[date]` nests under\n`invoice` and `invoice_items_attributes[0][qty]` becomes the index-hash form\nabove:\n\n```ruby\naction :save, params: {invoice: {date: :string, status: :string}}\n# client posts { \"invoice[date]\": \"…\", \"invoice[status]\": \"…\" }  → save(invoice: { date:, status: })\n```\n\n\u003e **A flat schema silently drops bracketed names (issue #67).** The schema must\n\u003e mirror the field *names*, not the conceptual params. Because the endpoint\n\u003e expands `invoice[date]` to `{ \"invoice\" =\u003e { \"date\" =\u003e … } }` **before**\n\u003e matching the schema, a flat `params: { date: :string }` matches nothing — the\n\u003e top-level key is now `invoice`, not `date`. There is no error: the action just\n\u003e receives its keyword defaults (`date` never set). If your inputs are named\n\u003e `invoice[…]` (any `Form(model:)`-style form), nest the schema under `invoice:`\n\u003e to match. When in doubt, read a field's real `name` attribute and shape the\n\u003e schema to it.\n\n**Nested reactive components compose.** A reactive component rendered inside\nanother is its own root — field collection stops at nested\n`data-controller=\"reactive\"` roots, so an outer action collects only *its own*\nnamed inputs, never a nested component's. An invoice editor's `save` sees its\nflat fields; each line-item row's `quantity`/`price` belong to that row's own\naction. No name-disjointness workarounds required.\n\n**Debounced triggers (live-as-you-type).** Pass `debounce:` (milliseconds) to\ncoalesce rapid events — typically keystrokes on an `\"input\"` trigger — into a\nsingle action round trip fired after the quiet period, instead of one POST per\nkeystroke. A blur flushes a pending dispatch so the last edit is never dropped.\nOmit `debounce:` for the immediate-dispatch default.\n\n```ruby\n# Recompute a total live as the user types, without hammering the endpoint.\ninput(**mix(on(:update, event: \"input\", debounce: 300), name: \"quantity\", value: @item.quantity))\n```\n\n**Event modifiers — `outside:`, `window:`, `once:`, `throttle:`.** Four more\n`on(...)` options cover the page-level trigger patterns that otherwise need a\nhand-written Stimulus controller:\n\n- `outside: true` fires the action only for events whose target is **outside**\n  this component's root — the close-a-dropdown-on-outside-click pattern. An\n  event inside the root is a complete client-side no-op. Implies `window:`.\n- `window: true` binds the trigger to the window (Stimulus's native `@window`)\n  for page-level events like `scroll`/`resize`. Window-bound triggers are\n  **never `preventDefault`-ed** — a mounted dropdown must not kill link clicks\n  elsewhere on the page — and skip the forced `type=\"button\"`.\n- `once: true` fires at most once, then unbinds (Stimulus's `:once`).\n- `throttle: 250` rate-limits **leading-edge**: the first event fires\n  immediately, further events are dropped until the window elapses. The mirror\n  of `debounce:` (trailing-edge) — passing both raises `ArgumentError`.\n\n```ruby\n# A dropdown that closes itself on any click outside — no Stimulus controller.\ndiv(**mix(reactive_root, on(:close_menu, outside: true))) do\n  button(**on(:toggle_menu)) { \"Menu\" }\n  ul { menu_items } if @open\nend\n\n# Throttled page-scroll tracking.\ndiv(**mix(reactive_root, on(:track, event: \"scroll\", window: true, throttle: 500)))\n```\n\nThese four (like `debounce:`/`confirm:`/`listnav:`) are **reserved keyword\nnames** on `on(...)` — no longer usable as free action params.\n\n### Client-only ops (`on_client` + `js`) — zero round trips\n\nNot every interaction needs the server. A tab switch, a dropdown, an accordion\n— purely visual state — used to mean either a wasteful signed round trip or the\nvery Stimulus controller this gem exists to eliminate. `on_client` binds a DOM\nevent to a chain of **declared DOM operations** that the one generic controller\napplies locally: **no token, no params, no POST, ever.**\n\n```ruby\ndef view_template\n  div(**mix(reactive_root, on_client(:click, js.hide(\"#menu\"), outside: true))) do\n    # Tabs: one op chain per tab — hide all panels, show one, restyle the tabs.\n    button(**on_client(:click, js.hide(\".panel\").show(\"#panel-2\")\n      .remove_class(\".tab\", \"active\").add_class(\"#tab-2\", \"active\"))) { \"Tab 2\" }\n\n    # A menu that opens client-side and closes on ANY outside click (the root\n    # carries the window-bound trigger above).\n    button(**on_client(:click, js.show(\"#menu\"))) { \"Menu\" }\n    div(id: \"menu\", hidden: true) { menu_items }\n  end\nend\n```\n\nThe `js` builder is immutable (each verb returns a new chain) and its\nvocabulary is a fixed whitelist mirrored by the client: `show`/`hide`/`toggle`\nflip the `hidden` attribute; `add_class`/`remove_class`/`toggle_class` take one\nor more classes. Targets are CSS selectors resolved **within the component's\nroot** (nested reactive components are never touched — same ownership rule as\nfield collection); `:root` targets the root element itself; `global: true` on\nan op escapes to the whole document. An op name the client doesn't recognize\nlogs a warning and is skipped — the rest of the chain still applies.\n\n`window:`, `once:`, and `outside:` compose exactly like `on(...)`'s event\nmodifiers: the dropdown above closes on any click outside the component, and\nwindow-bound triggers never `preventDefault`, so links elsewhere keep working.\n\n**Client ops are ephemeral UI — the one contract to internalize.** Any server\nre-render of the component (an action reply, a broadcast, a morph) rebuilds\nfrom server state and resets whatever the ops toggled: the menu closes, the tab\nsnaps back. That is by design — the same caveat LiveView's JS commands carry.\nFor state that must survive a re-render (an edit mode, a selection the server\nshould know about), use a signed `action` instead; `on_client` is for state the\nserver should never care about.\n\n**Auto-collected sibling fields — the read contract.** A reactive action doesn't\njust receive its own trigger's value: the client gathers **every named control**\nin the reactive root (`input[name]`, `select[name]`, `textarea[name]`, and named\nrich-text/`contenteditable` editors) and merges them under the action's params,\nso one action reads the whole form. Explicit `on(:act, x: …)` params win over a\ncollected field of the same name; collection stops at nested reactive roots (see\n*Nested reactive components compose* above). Two things worth pinning down:\n\n- **Timing — params reflect the DOM at dispatch, not a pre-event snapshot\n  (issue #65).** Field values are read when the request is sent (after the\n  debounce quiet period, if any), so a `change`/`input` trigger sees **its own\n  field's new value and every peer's current value.** There is no capture of the\n  values as they were *before* the interaction — if your computation needs a\n  peer's prior value (e.g. a spill-back that folds an overflow into the edited\n  field), that peer's current DOM value *is* the prior value only because nothing\n  else has changed it yet. Read at dispatch time, trust the current DOM.\n- **Disabled fields ARE collected (issue #66) — deliberately different from a\n  native form.** A `\u003cform\u003e` submit omits `disabled` controls; reactive collection\n  does **not** check `disabled`, so a disabled field that carries a\n  computed/display value (a read-only `total` the client keeps in sync) reaches\n  the action. This is intentional — it's what makes \"read a computed disabled\n  field\" work. If you need form-submit parity (drop the disabled value), give the\n  control no `name`, or make it `readonly` instead of `disabled` when you *do*\n  want it collected by both paths.\n\n**Keyboard triggers (Enter-to-submit / Escape-to-cancel).** `event:` is\ninterpolated straight into the Stimulus action descriptor, so any Stimulus event\nstring works — including its **native keyboard filters**. Pass `event:\n\"keydown.enter\"` to fire only on Enter, `event: \"keydown.esc\"` for Escape — the\nclassic \"Enter adds the row\", \"Escape cancels the edit\" interactions. The action\nruns *only* on that key, not on every keypress — no client JavaScript, no\n`event.key` check of your own, and no new option to learn (it's Stimulus's own\n[keyboard-filter syntax](https://stimulus.hotwired.dev/reference/actions#keyboardevent-filter)):\n\n```ruby\n# Enter in the composer adds the todo (same action as the Add button).\ninput(**mix(on(:add, event: \"keydown.enter\"), name: \"title\", placeholder: \"New todo…\"))\n\n# Inline editor: Enter on the field saves; a separate control cancels on Escape.\ninput(**mix(on(:save, event: \"keydown.enter\"), name: \"title\", value: @todo.title))\nbutton(**on(:cancel, event: \"keydown.esc\")) { \"Cancel\" }\n```\n\nThe filter tokens are Stimulus's (`enter`, `esc`, `space`, `up`, `down`, a bare\nletter, …). Because a keyboard trigger isn't a click, it does **not** get the\n`type=\"button\"` a click trigger does. Folding the key into `event:` keeps `key`\nfree as an ordinary action-param name (`on(:switch, key: \"pgbus\")` still passes\n`key` through as a param).\n\n\u003e **One action per element.** Each trigger element carries a single reactive\n\u003e action (its `data-reactive-action-param`), so you can't put `on(:save, event:\n\u003e \"keydown.enter\")` *and* `on(:cancel, event: \"keydown.esc\")` on the **same**\n\u003e input — the second would overwrite the first's action name. Bind each key\n\u003e trigger to its own element (the field saves on Enter; a Cancel button — or the\n\u003e field's own blur — handles Escape), as above.\n\n### Combobox keyboard navigation (`listnav:`)\n\nA searchable list needs Arrow keys to move a highlight, Enter to pick, Escape to\nclose — interactions that are *ephemeral client UI state* (a highlight per\nkeystroke would be absurd as a server round trip). Pass `listnav:` (a CSS\nselector for the option elements) to a search trigger and the generic controller\nhandles all of it client-side, with no bespoke Stimulus controller:\n\n```ruby\n# The search input: debounced live search + keyboard list navigation.\ninput(**mix(\n  on(:search, event: \"input\", debounce: 200, listnav: \"[role=option]\"),\n  name: \"query\", value: @query\n))\n\n# Each option is BOTH a listnav target (role=option) and its own reactive\n# select trigger — Enter just clicks the highlighted one.\nbutton(**mix(on(:select, name: opt), role: \"option\")) { opt }\n```\n\n`listnav:` appends Stimulus's native keyboard filters\n(`keydown.down/up/enter/esc`) to the input's `data-action`. Arrow Up/Down move a\n`data-reactive-highlighted` marker among the options **with no round trip**;\nEnter **clicks the highlighted option** — so selection runs through its normal\n`on(:select)` reactive action (signed, default-deny, authorized like any other);\nEscape clears the highlight. Only the highlight is client-side — the selection\nstays a real signed action, and the highlight is never shipped as trusted state.\n\n**Combining `on(...)` / `reactive_attrs` with your own attributes.** Both return\na hash that includes a `data:` key. Spreading them *and* passing another `data:`\n(or `class:`, `id:`) would clobber it — use Phlex's `mix` to deep-merge. For the\n**root**, prefer `reactive_root`, which already `mix`es id + token for you:\n\n```ruby\n# ✅ merges cleanly (data-action survives, your data-testid/class are added)\nbutton(**mix(on(:increment), class: \"btn\", data: { testid: \"inc\" })) { \"+\" }\ndiv(**reactive_root(class: \"card\", data: { testid: \"root\" })) { ... }   # id + token + your attrs\n\n# ❌ the extra data: overwrites on()'s data:, so the action never binds\nbutton(**on(:increment), data: { testid: \"inc\" }) { \"+\" }\n```\n\n\u003e **The reactive root must carry `#id` (issue #48).** The server targets your\n\u003e component's `#id` and the client self-matches its next signed token by the root\n\u003e element's `id`. `reactive_attrs` does **not** emit the id — so if you put `id:`\n\u003e on a **child** instead of the `**reactive_attrs` element, the root's id is empty,\n\u003e token threading falls back to the first token in the response, and the *next*\n\u003e action silently fails with **HTTP 403**. Use `div(**reactive_root)` (it emits id\n\u003e + token on one element) so the id can't land on the wrong node; if you spread\n\u003e `reactive_attrs` directly, keep `id:` on the **same** element\n\u003e (`div(id:, **reactive_attrs)`). The controller `console.warn`s on connect when a\n\u003e reactive root has no id.\n\n**Binding inputs to action params (drop the magic `name:`).** A field's value\ntravels with an action only if its `name` equals the param. Hand-writing\n`name: \"value\"` on every input is easy to forget — the action then silently gets\nnothing. `reactive_input`/`reactive_select` emit the binding for you (the trigger\nstays on the button, so focusing the field doesn't dispatch and collapse edit\nmode):\n\n```ruby\naction :save, params: { value: :string, status: :string }\n\ndef view_template\n  span(**reactive_root) do\n    reactive_input(:value, value: @record.name)            # \u003cinput name=\"value\" …\u003e\n    reactive_select(:status) do                            # \u003cselect name=\"status\"\u003e…\u003c/select\u003e\n      %w[open closed].each { |s| option(value: s, selected: s == @record.status) { s } }\n    end\n    button(**mix(on(:save), data: { testid: \"save\" })) { \"Save\" }\n  end\nend\n```\n\n`reactive_field(:value, **attrs)` returns just the attribute hash if you'd rather\nspread it onto a control yourself. An explicit `name:` still wins (escape hatch).\n\n**Editing an associated record (`accepts_nested_attributes_for`).** `nested_update!`\nmaps a declared nested param straight onto `\u003cassoc\u003e_attributes` and carries the\nexisting record's id, so `update_only:` matches it in place instead of building a\nsecond `has_one` (the boilerplate that's easy to get subtly wrong):\n\n```ruby\n# Account has_one :address; accepts_nested_attributes_for :address, update_only: true\naction :save, params: { address: { street: :string, city: :string } }\n\ndef save(address:)\n  nested_update!(:address, address)   # update!(address_attributes: address.merge(id: @account.address\u0026.id))\nend\n```\n\n`nested_attributes(:address, address)` returns the id-merged hash without\nupdating, if you need to combine it with other attributes.\n\n### Custom confirmation dialogs (`setConfirmResolver`)\n\n`on(:action, confirm: \"Really delete this?\")` gates a destructive trigger behind\na confirmation. Because the reactive controller preempts the event (its own\n`preventDefault` + POST), Hotwire's `data-turbo-confirm` — which routes through\n`Turbo.config.forms.confirm` — never runs for a reactive trigger. So by default\nthe gate uses the browser-native `window.confirm` (synchronous, no dependency,\nscreen-reader friendly).\n\nIf your app already themes confirmations (the common Hotwire setup —\n`Turbo.config.forms.confirm = (message) =\u003e Promise\u003cboolean\u003e`, backed by a styled\nmodal), reuse that exact dialog for reactive triggers with one line at boot:\n\n```js\nimport { setConfirmResolver } from \"phlex/reactive/confirm\"\n\n// Reuse the same themed dialog the rest of the app already uses.\nsetConfirmResolver((message) =\u003e window.Turbo.config.forms.confirm(message))\n```\n\nThe resolver receives the `confirm:` message and returns `true`/`false` (or a\n`Promise` of one). It may be **async** — the controller `await`s it, then runs\nthe action only on a truthy result; a falsy result (or a rejected promise — e.g.\nthe user dismissed the dialog) cancels the action, exactly like declining the\nnative prompt. The native default is always prevented up front, so a `submit`\ntrigger never navigates while the dialog is open.\n\nUnset, behavior is identical to the native `window.confirm` — the `confirm:`\nmarkup and `on(...)` API are unchanged; only the client's resolution strategy\ngains a seam.\n\n### `reply` — controlling the action's reply\n\nBy default an action re-renders its component in place. To do more, **return**\n`reply.\u003cverb\u003e` — a subject-bound builder available in every component. It governs\nonly the actor's HTTP reply (cross-tab updates still use\n`broadcast_*_to(..., exclude: reactive_connection_id)`). Returning anything else\nkeeps the default, so existing actions are unaffected.\n\n`reply` reads cleanly: the component is the implicit subject (no `self` to\nthread) and there's no constant to qualify (it's a method, so a namespaced\ncomponent needs no alias):\n\n```ruby\ndef rename(title:)\n  return reply.replace.flash(:error, @todo.errors.full_messages.to_sentence) unless @todo.update(title:)\n  reply.replace\nend\n\ndef approve   = (@row.approve!; reply.remove)          # drop the element\ndef publish   = (@article.publish!; reply.redirect(article_url(@article)))  # slug changed → Turbo.visit\ndef add(item:) = reply.replace.stream(Totals.update(@order))               # multi-stream\n\n# Per-field reactive editing (a \"spreadsheet\" grid): a debounced save fires\n# while the user is still typing/tabbing. Morph in place so the focused \u003cinput\u003e\n# and its in-progress value survive the re-render (issue #28). Note the action is\n# named `update`, yet `reply.morph` is unambiguous — the verb is on `reply`:\ndef update(name:) = (@row.update!(name:); reply.morph)\n\n# Re-render a COMPANION element (a heading mirroring the edited name) alongside self:\ndef rename(value:) = (@account.update!(name: value); reply.replace.also_update(\"page_heading\", html: @account.name))\n\n# Update ONLY part of the component (issue #30): re-stream just the total cell,\n# NOT the whole row. reply.streams emits exactly your streams plus a tiny\n# token-only refresh — no full-self replace — so a sibling \u003cinput\u003e the user is\n# mid-typing in is never torn down. The signed token still rolls forward.\ndef update(quantity:, price:) = (@item.update!(quantity:, price:); reply.streams(Totals.update(@item)))\n```\n\n| Builder | Reply |\n|---|---|\n| `reply.replace` / `reply.update` | re-render in place (default; `replace` is an outerHTML swap, `update` morphs inner HTML) |\n| `reply.morph` / `reply.replace(morph: true)` | re-render in place via Idiomorph (`method=\"morph\"`) — preserves the focused `\u003cinput\u003e` + caret; for per-field reactive editing (issue #28) |\n| `.also_update(target, html:)` | also re-render a companion element by DOM id; `html` is a plain string (escaped) or a Phlex component |\n| `.also_replace(component, morph: false)` | also re-render another Streamable component, targeting its own `#id`; `morph: true` morphs it in place |\n| `.flash(level, content, target: …)` | append a flash; `content` is a plain string (escaped, wrapped in a level-carrying `\u003cdiv\u003e` — see [Flash levels](#flash-levels)) or a Phlex component (rendered verbatim; off-request — no Rails `flash`); target defaults to `Phlex::Reactive.flash_target` (`\"flash\"`) |\n| `reply.remove` | remove the element (backed by `Streamable#to_stream_remove`) |\n| `reply.redirect(url)` | client-side `Turbo.visit` (pass a `*_url`); rides a `reactive:visit` turbo-stream, not an HTTP 3xx |\n| `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) |\n| `reply.with(*streams)` / `#stream(*more)` | multi-stream (self re-render still injected for the token) |\n\n`.flash`/`.stream`/`.also_*` are additive on a self-replace, so the component's\nsigned token always refreshes. **`reply.streams`** is the exception that proves\nthe rule: it deliberately skips the full-self replace (so your hand-built streams\nupdate only the targets you name) and refreshes the token via a tiny inert\n`reactive:token` stream instead — the token rolls forward without re-rendering\n(and clobbering) the component's live inputs.\n\n#### Flash levels\n\nThe level reaches the wire (issue #77). **String** content is wrapped in a\nlevel-carrying `\u003cdiv\u003e`, so `:error` and `:notice` are styleable:\n\n```html\n\u003cdiv class=\"reactive-flash reactive-flash--error\" data-reactive-flash-level=\"error\"\u003e\n  Save failed\n\u003c/div\u003e\n```\n\nStyle against `.reactive-flash--{level}` (the class) and hook scripts/tests on\n`data-reactive-flash-level` (the data attribute). The string keeps the same\ninjection contract as before, applied inside the wrapper: a plain string is\nHTML-escaped (a model value can't inject markup); an `html_safe` string passes\nverbatim.\n\nPrefer your own markup? Two escape hatches:\n\n```ruby\n# 1. Pass a Phlex component as the content — rendered VERBATIM, no wrapper\n#    (you own the markup entirely, including the level styling):\nreply.replace.flash(:error, Alert.new(level: :error, message: msg))\n\n# 2. Or configure a flash component ONCE — string flashes render through it\n#    (instantiated new(level:, content:)); component content still bypasses it:\nPhlex::Reactive.flash_component = MyFlash   # default nil → the built-in wrapper\n```\n\n#### Record-authorized, transient-state actions (issue #64)\n\nA `reactive_record` component isn't obligated to persist or broadcast — the\nrecord can be there purely for **identity + authorization** while the action's\nreal job is to recompute **live, unsaved form values** the user is mid-edit. The\nrecord is re-located and instantiated on each action (`from_identity`), never\nauto-saved and never auto-broadcast; persistence and cross-tab broadcast are both\nopt-in (you call `record.update!` / `broadcast_*_to` yourself). Pair that with\n`reply.streams` and you get a first-class \"authorize via the row, compute over\nthe params, stream a partial update, touch neither the DB nor peer tabs\" action:\n\n```ruby\nclass Invoice::PaymentFields \u003c ApplicationComponent\n  include Phlex::Reactive::Component\n\n  reactive_record :invoice   # identity + authorization ONLY — not persisted here\n  action :rebalance, params: { invoice: { field_a: :integer, field_b: :integer,\n                                          field_c: :integer, total: :integer } }\n\n  def rebalance(invoice:)\n    authorize! @invoice, :update?          # the token proves identity, not permission\n    result = recompute(invoice)            # pure computation over the collected params\n    reply.streams(*set_value_streams(result))  # NO persist, NO broadcast\n  end\nend\n```\n\nThis is deliberate, not a misuse: `reply.streams` is exactly the reply for \"emit\nthese targeted updates, roll the token forward, and leave everything else — the\nDB, the other tabs, the sibling inputs the user is typing in — untouched.\"\nBroadcasting is deliberately omitted so peer tabs with their own in-flight edits\naren't clobbered. Authorize the record as always — identity is never permission.\n\n\u003e **Under the hood.** `reply.\u003cverb\u003e` returns a `Phlex::Reactive::Response` — the\n\u003e immutable value object the endpoint reads. You can build one directly\n\u003e (`Phlex::Reactive::Response.replace(self)`) and it still works, but `reply` is\n\u003e the preferred surface; treat `Response` as an internal detail.\n\u003e **`html:`/`content` escaping.** A plain string is **HTML-escaped** by Turbo, so\n\u003e `html: @account.name` is safe even for user-supplied values. To emit intentional\n\u003e markup, pass a **Phlex component** (`html: Heading.new(name: @record.name)`) —\n\u003e rendered and auto-escaped through the renderer — or an `html_safe` string for\n\u003e raw HTML you control.\n\n### Failure UX \u0026 lifecycle events\n\nThe generic controller dispatches three bubbling, composed `CustomEvent`s\naround every action round trip, so an app can toast an error, instrument\nlatency, veto a dispatch, or build retry UI **without forking the controller**:\n\n| Event | When | `event.detail` |\n|-------|------|----------------|\n| `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) |\n| `reactive:applied` | after the response's token was captured and the streams were handed to `Turbo.renderStreamMessage` | `{ action, params, html }` |\n| `reactive:error` | in every failure branch of the round trip | `{ action, params, kind, status?, body?, retry }` |\n\n`reactive:error`'s `kind` tells you **what** failed:\n\n| `kind` | Meaning | Extra detail |\n|--------|---------|--------------|\n| `redirected` | the POST was redirected (an auth `before_action` / CSRF guard bounced it) | `status`, `retry` |\n| `http` | non-2xx response (403 default-deny/authorization, 400 bad token, 404 record gone, 500 …) | `status`, `body`, `retry` |\n| `content-type` | 200, but not a turbo-stream (an HTML error page, a misconfigured route) | `status`, `retry` |\n| `network` | `fetch` itself rejected (offline, DNS, connection reset) — the server never saw the request | `retry` |\n| `apply` | the server processed the action successfully, but something AFTER the fetch threw (a malformed response, a Turbo render error) | no `retry` |\n\n`apply` covers a throw in the controller's own post-fetch code — not a\nthrowing listener on `reactive:applied` itself. Per the DOM spec,\n`EventTarget#dispatchEvent` never propagates a listener's exception back to\nits caller (it's reported to the console instead), so a listener that throws\ncan't surface as `reactive:error` at all — it just logs and the round trip is\notherwise unaffected.\n\n`detail.retry()` re-enters the controller's request queue: it re-reads the\n**freshest** signed token and re-collects the component's fields at send time,\nso nothing stale is replayed. It fires no second `reactive:before-dispatch`\n(one veto per user gesture), and it no-ops with a `console.warn` once the\ncomponent has left the DOM. The existing `console.error` logging is unchanged —\nthe events add hooks, they don't replace the log.\n\n**`kind: \"apply\"` carries no `retry()` at all** — by the time this fires the\nserver has already completed the mutation, so retrying would re-POST an\naction that already succeeded (potentially a non-idempotent one). Only the\nfour fetch/response-shaped kinds above are retriable.\n\nThe events bubble from the component's root element (or from `document` when\nthe root was detached by the failing round trip), so they compose with plain\nStimulus listening — a global toaster is one attribute on an ancestor:\n\n```html\n\u003cbody data-controller=\"toast\" data-action=\"reactive:error-\u003etoast#show\"\u003e\n```\n\n```js\n// toast_controller.js\nshow(event) {\n  const { kind, status, retry } = event.detail\n  this.flash(`Action failed (${kind}${status ? ` ${status}` : \"\"})`, { onRetry: retry })\n}\n```\n\nOr veto/instrument at the document level:\n\n```js\ndocument.addEventListener(\"reactive:before-dispatch\", (event) =\u003e {\n  if (offline) event.preventDefault()           // cancel: nothing is enqueued\n})\ndocument.addEventListener(\"reactive:applied\", ({ detail }) =\u003e {\n  metrics.count(`reactive.${detail.action}.ok`)\n})\n```\n\nOne honest caveat on timing: `reactive:applied` means the turbo-streams were\n**handed to Turbo** — `renderStreamMessage` applies them asynchronously, so the\nDOM mutation may complete a tick later. If you need post-morph timing, listen\nto Turbo's own events (`turbo:before-stream-render` and friends).\n\n### Reactive collections (add/remove rows + count + empty-state)\n\nAn add/remove-row list — line items, attachments, tags, comments, a\nnotifications list — is one of the most common reactive surfaces, and every one\nre-implements the same orchestration by hand: append the row to the right\ncontainer, remove it on delete, keep a **count badge** in sync, and swap an\n**empty-state** in/out as the list crosses 0↔1. `reactive_collection` declares\nthat contract **once** on the container so each action is a single call.\n\nDeclare the collection on the container component, then `reply.append` /\n`reply.prepend` / `reply.remove` in the actions:\n\n```ruby\nclass NotificationsList \u003c ApplicationComponent\n  include Phlex::Reactive::Component\n\n  reactive_collection :notifications,\n    item: NotificationRow,        # the per-row Streamable component\n    container: \"notifications\",    # the DOM id rows live in\n    count: \"notifications-count\",  # optional companion id (the size badge)\n    empty: NotificationsEmpty,     # optional empty-state component\n    size: -\u003e { Todo.count }        # resolves the live size (re-counted, never client state)\n\n  action :add, params: {title: :string}\n  action :dismiss, params: {id: :integer}\n\n  def add(title:)\n    todo = Todo.create!(title:)\n    reply.append(:notifications, todo)   # append row + bump count + clear empty-state\n  end\n\n  def dismiss(id:)\n    Todo.find(id).destroy!\n    reply.remove(:notifications, id)     # remove row + bump count + restore empty-state at 0\n  end\n\n  # view_template renders the count, the container \u003cul\u003e, and the empty-state on\n  # first paint — the same components the helper streams in/out on each delta.\nend\n```\n\n| Builder | Reply (one `Response`) |\n|---|---|\n| `reply.append(name, model)` | append the row into the container + update the count + remove the empty-state when the list crosses 0→1 |\n| `reply.prepend(name, model)` | as `append`, but the row goes to the top |\n| `reply.remove(name, model)` | remove the row by its `dom_id` + update the count + append the empty-state back when the list crosses →0 |\n\n- **`size:` is the source of truth** — it's *re-counted* server-side after the\n  mutation, so the badge and the empty-state are correct-by-construction (no\n  off-by-one, no client-held count). `count:`, `empty:`, and `size:` are all\n  optional: omit them and only the row stream is emitted.\n- **Repeated add/remove just works** — each reply rolls the **container's** signed\n  token forward (via the inert `reactive:token` refresh), so the second click from\n  the list root is accepted. Without this an add/remove list would be add-once-only\n  (correct on the first click, silently rejected after); the helper bakes the\n  refresh in so you never hit it.\n- **`remove` takes the record or its `dom_id` string** — a just-destroyed\n  ActiveRecord still answers `dom_id` correctly, so `reply.remove(:items, todo)`\n  works; pass the raw id only if your row `#id` matches `ActiveRecord::RecordIdentifier`.\n- **Reply governs the actor's HTTP response only.** For a *cross-tab* live list\n  (other viewers see the row appear) keep broadcasting the row with\n  `NotificationRow.broadcast_append_to(..., exclude: reactive_connection_id)` —\n  `reactive_collection` is the per-actor add/remove + count + empty-state wrapper,\n  not a replacement for the broadcast.\n\n### Configuration (`config/initializers/phlex_reactive.rb`)\n\n```ruby\nPhlex::Reactive.configure do |c| end if false # (plain accessors below)\n\n# Inherit auth/CSRF/Current from your app on the action endpoint:\nPhlex::Reactive.base_controller_name = \"ApplicationController\"\n\n# Render your authorization library's error as 403:\nPhlex::Reactive.authorization_errors = [Pundit::NotAuthorizedError]\n# or: [ActionPolicy::Unauthorized]\n\n# Use your ApplicationController to render components (app helpers / Current):\nPhlex::Reactive.renderer = ApplicationController\n\n# Sign tokens with a dedicated key instead of secret_key_base:\nPhlex::Reactive.verifier = ActiveSupport::MessageVerifier.new(ENV[\"REACTIVE_KEY\"])\n\n# Change the endpoint path (default \"/reactive/actions\"):\nPhlex::Reactive.action_path = \"/_r/actions\"\n\n# Diagnostic error bodies + dropped-param logging (default: Rails.env.local? —\n# on in development AND test, off in production):\nPhlex::Reactive.verbose_errors = true\n```\n\nIf you set a custom `action_path`, expose it to the client:\n\n```erb\n\u003cmeta name=\"phlex-reactive-action-path\" content=\"\u003c%= Phlex::Reactive.action_path %\u003e\"\u003e\n```\n\n---\n\n## Security\n\nphlex-reactive is built so the easy path is the safe path — but the boundary is\nreal, so read this once.\n\n- **State is never trusted from the client.** The DOM holds a `MessageVerifier`-\n  signed identity — `{component, gid}` (record-backed), `{component, state}`\n  (state-backed), or `{component, gid, state}` when a component declares both —\n  not raw state. A tampered class, record, or state value fails signature\n  verification → 400.\n- **Actions are default-deny.** Only methods declared with `action :name` are\n  invokable. A public method without `action` is unreachable.\n- **You must authorize.** The signature proves the *token is yours*, not that\n  *this user may act on this record*. Call your authorizer inside the action\n  (`authorize! @todo, :update?`) and register its error in\n  `Phlex::Reactive.authorization_errors`.\n- **Params are schema-coerced.** Only declared params reach your method, each\n  cast to its declared type. No raw mass assignment.\n- **CSRF + auth are the host app's.** The endpoint inherits from your configured\n  `base_controller_name`. Inherit `ApplicationController` to get CSRF and auth —\n  but if you have *public* reactive components, ensure the action path isn't\n  force-redirected to a login page for logged-out users.\n\n### Debugging endpoint failures (`verbose_errors`)\n\nEvery endpoint failure is warn-logged as `[phlex-reactive] …` in **every**\nenvironment. With `Phlex::Reactive.verbose_errors` on (the default in\ndevelopment and test via `Rails.env.local?`; off in production), the failure\nresponse ALSO carries a plain-text diagnostic body — the client already prints\nit via `console.error` — and param coercion warn-logs every dropped key with\nits full bracketed path and reason (`undeclared` / `uncoercible`), including a\nhint when a flat name looks like the bracketed twin of a declared nested key\n(or vice versa). What each status means:\n\n- **400** — token signature invalid (stale token from before a deploy?\n  `secret_key_base` mismatch?), a token class that no longer resolves, or a\n  class that resolved but doesn't include `Phlex::Reactive::Component`\n- **403** — an undeclared action (the body lists the declared actions) or a\n  registered authorization error raised inside the action\n- **404** — the signed GlobalID no longer resolves (record deleted)\n\nThe flag never changes a status — only the body and the coercion log.\n\nSee [docs/security.md](https://phlex-reactive.zoolutions.llc/docs/security) for the threat model and a checklist.\n\n---\n\n## How it beats Stimulus + Turbo (same feature, less code)\n\nA counter, today vs. with phlex-reactive:\n\n\u003ctable\u003e\n\u003ctr\u003e\u003cth\u003eStimulus + Turbo\u003c/th\u003e\u003cth\u003ephlex-reactive\u003c/th\u003e\u003c/tr\u003e\n\u003ctr\u003e\u003ctd\u003e\n\n```js\n// counter_controller.js\nimport { Controller } from \"@hotwired/stimulus\"\nexport default class extends Controller {\n  static values = { url: String }\n  increment() { this.#post(\"increment\") }\n  decrement() { this.#post(\"decrement\") }\n  #post(op) {\n    fetch(`${this.urlValue}/${op}`, {\n      method: \"POST\",\n      headers: { \"X-CSRF-Token\": token() },\n    })\n  }\n}\n```\n```erb\n\u003c%# _counter.html.erb %\u003e\n\u003cdiv id=\"\u003c%= dom_id(@counter) %\u003e\"\n     data-controller=\"counter\"\n     data-counter-url-value=\"\u003c%= counter_path(@counter) %\u003e\"\u003e\n  \u003cbutton data-action=\"counter#decrement\"\u003e−\u003c/button\u003e\n  \u003cspan\u003e\u003c%= @counter.value %\u003e\u003c/span\u003e\n  \u003cbutton data-action=\"counter#increment\"\u003e+\u003c/button\u003e\n\u003c/div\u003e\n```\n```ruby\n# routes + controller\nresources :counters do\n  member { post :increment; post :decrement }\nend\ndef increment\n  @counter.increment!(:value)\n  render turbo_stream: turbo_stream.replace(\n    dom_id(@counter), partial: \"counter\",\n    locals: { counter: @counter })\nend\n```\n\n\u003c/td\u003e\u003ctd\u003e\n\n```ruby\nclass Counter \u003c ApplicationComponent\n  include Phlex::Reactive::Component\n\n  reactive_record :counter   # also defaults #id to dom_id(@counter)\n  action :increment\n  action :decrement\n\n  def initialize(counter:) = @counter = counter\n\n  def increment = @counter.increment!(:value)\n  def decrement = @counter.decrement!(:value)\n\n  def view_template\n    div(**reactive_root) do\n      button(**on(:decrement)) { \"−\" }\n      span { @counter.value }\n      button(**on(:increment)) { \"+\" }\n    end\n  end\nend\n```\n\n*One file. No JS. No routes. No partial. No hand-picked target.*\n\n\u003c/td\u003e\u003c/tr\u003e\n\u003c/table\u003e\n\n---\n\n## Live updates with pgbus (recommended)\n\n[pgbus](https://github.com/mhenrixon/pgbus) replaces Action Cable's transport\nwith Postgres SSE and fixes its reliability gaps. With it installed,\n`broadcast_*_to` and `turbo_stream_from` route over pgbus automatically:\n\n```ruby\nclass Message \u003c ApplicationRecord\n  broadcasts_to -\u003e(m) { [m.room, :messages] }, durable: true\nend\n```\n\n- **Transactional**: a broadcast inside a transaction that rolls back never\n  fires — *and* the DB change is undone. No \"ghost\" UI updates.\n- **Reconnect-safe**: a tab that dropped replays missed messages on reconnect\n  (`Last-Event-ID` + PGMQ archive).\n- **No race on subscribe**: messages broadcast between render and subscribe are\n  replayed, not lost.\n- **No Redis, no Action Cable.**\n\nSee [docs/broadcasting.md](https://phlex-reactive.zoolutions.llc/docs/broadcasting) and\n[docs/transport-pgbus.md](https://phlex-reactive.zoolutions.llc/docs/transport-pgbus).\n\n---\n\n## Documentation\n\n- [Installation \u0026 bundler setups](https://phlex-reactive.zoolutions.llc/docs/installation)\n- [Mental model \u0026 architecture](https://phlex-reactive.zoolutions.llc/docs/architecture)\n- [Security \u0026 threat model](https://phlex-reactive.zoolutions.llc/docs/security)\n- [Broadcasting \u0026 live updates](https://phlex-reactive.zoolutions.llc/docs/broadcasting)\n- [Transport: pgbus vs Action Cable](https://phlex-reactive.zoolutions.llc/docs/transport-pgbus)\n- [Testing reactive components](https://phlex-reactive.zoolutions.llc/docs/testing)\n- [Performance \u0026 benchmarking](https://phlex-reactive.zoolutions.llc/docs/performance)\n- Examples: [counter](https://phlex-reactive.zoolutions.llc/docs/example-counter) ·\n  [chat](https://phlex-reactive.zoolutions.llc/docs/example-chat) · [todo list](https://phlex-reactive.zoolutions.llc/docs/example-todo-list) ·\n  [inline edit](https://phlex-reactive.zoolutions.llc/docs/example-inline-edit) ·\n  [notifications](https://phlex-reactive.zoolutions.llc/docs/example-notifications)\n\n## Credits \u0026 prior art\n\nThe mental model is stolen, gratefully, from\n[Laravel Livewire](https://livewire.laravel.com) (public method = action) and\n[Phoenix LiveView](https://www.phoenixframework.org) (a component is a re-render\nunit). The transport and reliability come from\n[pgbus](https://github.com/mhenrixon/pgbus). The rendering is all\n[Phlex](https://www.phlex.fun).\n\n## License\n\n[MIT](LICENSE.txt).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmhenrixon%2Fphlex-reactive","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fmhenrixon%2Fphlex-reactive","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmhenrixon%2Fphlex-reactive/lists"}