An open API service indexing awesome lists of open source software.

https://github.com/mhenrixon/phlex-reactive

Reactive Phlex components for Rails β€” Livewire-style actions and live cross-tab updates, without Stimulus boilerplate.
https://github.com/mhenrixon/phlex-reactive

hotwire pgbus phlex rails reactive ruby turbo

Last synced: about 6 hours ago
JSON representation

Reactive Phlex components for Rails β€” Livewire-style actions and live cross-tab updates, without Stimulus boilerplate.

Awesome Lists containing this project

README

          

# phlex-reactive

[![CI](https://github.com/mhenrixon/phlex-reactive/actions/workflows/main.yml/badge.svg)](https://github.com/mhenrixon/phlex-reactive/actions/workflows/main.yml)
[![Gem Version](https://img.shields.io/gem/v/phlex-reactive)](https://rubygems.org/gems/phlex-reactive)
[![Docs](https://img.shields.io/badge/docs-phlex--reactive.zoolutions.llc-blue)](https://phlex-reactive.zoolutions.llc)

**Reactive [Phlex](https://www.phlex.fun) components for Rails β€” Livewire-style
actions and live cross-tab updates, without writing Stimulus controllers or
hand-picking Turbo Stream targets.**

πŸ“– **[Full documentation](https://phlex-reactive.zoolutions.llc)**

```ruby
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 `` 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**](https://github.com/mhenrixon/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

```ruby
# Gemfile
gem "phlex-reactive"
```

```bash
bundle install
```

Then run the installer β€” it registers the client controller and writes a config
initializer:

```bash
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.

What the installer wires (or do it by hand)

```js
// app/javascript/controllers/index.js
import { application } from "controllers/application"
import ReactiveController from "phlex/reactive/reactive_controller"
application.register("reactive", ReactiveController)
```

Register eagerly (not lazily) so a click immediately after load is never missed.

### Scaffold a component

```bash
# 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:

```js
import { application } from "./application"
import ReactiveController from "phlex/reactive/reactive_controller"
application.register("reactive", ReactiveController)
```

The JS ships at `app/javascript/phlex/reactive/reactive_controller.js` in the
gem; point your bundler at the gem path or copy it in. See
[docs/installation.md](https://phlex-reactive.zoolutions.llc/docs/installation).

**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.
The gem now logs a warning for each, but 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:

```ruby
# 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):

```ruby
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:

```js
// 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 …`).

---

## 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 β†’ (default; an action
β”‚ may return reply. β€” see "Controlling the action's reply")
└──────── Turbo applies it in β—€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

...and for OTHER tabs/users:
model change β†’ Component.broadcast_replace_to(stream) β†’ pgbus SSE β†’ same morph
```

Client actions and server broadcasts **converge on one re-render unit**: the
component, targeted by its `id`.

---

## Quickstart: a live, cross-tab counter

```ruby
# 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.**

```ruby
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.

```ruby
reactive_state :count, :step # signed; rebuilt on each action
```

The [inline edit example](https://phlex-reactive.zoolutions.llc/docs/example-inline-edit) combines both: a
`reactive_record :record` plus `reactive_state :attribute, :editing`.

---

## Concrete examples

| Example | What it shows |
|---|---|
| [Counter](https://phlex-reactive.zoolutions.llc/docs/example-counter) | State-backed, the smallest reactive component |
| [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) |
| [Cross-tab chat](https://phlex-reactive.zoolutions.llc/docs/example-chat) | Record-backed action **+ pgbus broadcast** β†’ live sync across tabs/browsers |
| [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 |
| [Inline edit](https://phlex-reactive.zoolutions.llc/docs/example-inline-edit) | Show ↔ edit mode toggle, replacing a Stimulus controller + 3 routes |
| [Notifications / badges](https://phlex-reactive.zoolutions.llc/docs/example-notifications) | Pure broadcast (no client action) β€” a job pushes a re-render |

The cross-tab chat in ~60 lines of Ruby (and zero JS) is the showcase β€” see
[docs/examples/chat.md](https://phlex-reactive.zoolutions.llc/docs/example-chat).

---

## 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)` | `` of a freshly built component; `morph: true` adds `method="morph"` |
| `.update` / `.append(target:)` / `.prepend(target:)` / `.remove` | The other Turbo Stream actions |
| `.broadcast_replace_to(*streamables, model:, morph: false)` | Broadcast a replace over the stream transport (pgbus SSE / Action Cable); `morph: true` morphs in place |
| `.broadcast_append_to(*streamables, target:, model:)` / `_update_` / `_prepend_` / `_remove_` | The broadcast variants |
| `#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 |

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.** |
| `reactive_root(**overrides)` | Spread onto the root element: emits the component `id` **and** `reactive_attrs` together, so the controller root always carries `#id`. Preferred over `id:` + `reactive_attrs`. `**overrides` (`class:`/`data:`) deep-merge. |
| `reactive_attrs` | Marks an element reactive + carries the signed token (no `id`). Spread alongside `id:` on the **same** element: `div(id:, **reactive_attrs)`. Prefer `reactive_root`, which can't split them. |
| `on(:action, event: "click", **params)` | Spread onto a trigger element. Adds `type=button` for clicks. |
| `on(:action, event: "input", debounce: 300)` | Coalesce rapid events into one round trip after a quiet period (live-as-you-type). |
| `on(:action, event: "keydown.enter")` | Fire only on a specific key β€” Enter-to-submit / Escape-to-cancel β€” via Stimulus's native keyboard filter (`event:` passes straight through). See [Keyboard triggers](#keyboard-triggers-enter-to-submit--escape-to-cancel). |
| `on(:action, confirm: "Sure?")` | Gate a destructive trigger behind a confirmation. Defaults to `window.confirm`; override the dialog with [`setConfirmResolver`](#custom-confirmation-dialogs-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](#combobox-keyboard-navigation-listnav). |
| `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. |
| `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(:action, once: true)` | Fire at most once, then unbind (Stimulus's native `:once`). |
| `on_client(:click, js.toggle("#menu"))` | **Client-only** trigger: applies declared DOM ops with ZERO round trip β€” no token, no POST, ever. Takes the same `window:`/`once:`/`outside:` modifiers. See [Client-only ops](#client-only-ops-on_client--js--zero-round-trips). |
| `js` | The immutable op builder behind `on_client`: `show`/`hide`/`toggle` (the `hidden` attribute) and `add_class`/`remove_class`/`toggle_class`, chainable. |
| `reactive_input(:param, **attrs)` / `reactive_select(:param, **attrs)` | Render a control already bound to an action param (no magic `name:`). |
| `reactive_field(:param, **attrs)` | The attribute hash behind the above β€” spread onto any control. |
| `nested_update!(:assoc, attrs)` | Map a nested param onto `_attributes` with id preservation; update the record. |
| `reactive_collection :name, item:, container:, count:, empty:, size:` | Declare an add/remove-row list once; actions call `reply.append`/`prepend`/`remove`. See [Reactive collections](#reactive-collections-addremove-rows--count--empty-state). |
| `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). |
| `reply.append(name, model)` / `.prepend(...)` / `.remove(name, model)` | Add/remove a row in a declared `reactive_collection` (row + count + empty-state in one reply). |

Param types: `:string` (default), `:integer`, `:float`, `:boolean`, `:file`.
Anything not in the schema is dropped before reaching your method.

**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 ``, the client sends the action as multipart
`FormData` (instead of JSON) β€” `token` + `act` as fields, scalar params as fields,
any nested/array params bracket-expanded into `params[key][sub]` /
`params[key][index]` fields (the same Rails-form shape, so a JSON body and a
multipart body coerce identically β€” #39), and the file(s) appended; the endpoint
coerces `:file` to the `ActionDispatch::Http::UploadedFile`, passed through
untouched. A non-file value sent to a `:file` param is dropped (the keyword
default applies β€” never a fabricated file). Token threading and the
re-render/morph are identical; only the request encoding changes when a file is
present.

```ruby
reactive_record :document
action :upload, params: { file: :file, caption: :string } # single (has_one_attached)
action :upload_pages, params: { pages: [:file] } # multiple (has_many_attached)

def upload(file: nil, caption: nil)
@document.file.attach(file) if file
@document.update!(title: caption) if caption.present?
end

def view_template
form(**on(:upload, event: "submit")) do
input(type: "file", name: "file")
input(name: "caption")
button(type: "submit") { "Upload" }
end
end
```

> **One multipart caveat:** `FormData` can't carry an *empty* array or hash, so on
> the multipart (file-present) path an empty `[]`/`{}` param is **omitted** and the
> action's keyword default applies β€” it does **not** arrive as an explicit empty
> collection the way it does over JSON. If you rely on sending `tags: []` to clear
> a collection, send that action *without* a file (the JSON path). A non-empty
> nested/array param rides along fine next to a file.

**Array & nested params.** Wrap a type in an array for an array param, or a hash
schema in an array for Rails-style nested attributes β€” so one reactive action can
mirror a normal nested-attributes update instead of forcing a per-row component:

```ruby
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:

```ruby
action :save, params: {invoice: {date: :string, status: :string}}
# client posts { "invoice[date]": "…", "invoice[status]": "…" } β†’ save(invoice: { date:, status: })
```

> **A flat schema silently drops bracketed names (issue #67).** The schema must
> mirror the field *names*, not the conceptual params. Because the endpoint
> expands `invoice[date]` to `{ "invoice" => { "date" => … } }` **before**
> matching the schema, a flat `params: { date: :string }` matches nothing β€” the
> top-level key is now `invoice`, not `date`. There is no error: the action just
> receives its keyword defaults (`date` never set). If your inputs are named
> `invoice[…]` (any `Form(model:)`-style form), nest the schema under `invoice:`
> to match. When in doubt, read a field's real `name` attribute and shape the
> schema to it.

**Nested reactive components compose.** A reactive component rendered inside
another is its own root β€” field collection stops at nested
`data-controller="reactive"` roots, so an outer action collects only *its own*
named inputs, never a nested component's. An invoice editor's `save` sees its
flat fields; each line-item row's `quantity`/`price` belong to that row's own
action. No name-disjointness workarounds required.

**Debounced triggers (live-as-you-type).** Pass `debounce:` (milliseconds) to
coalesce rapid events β€” typically keystrokes on an `"input"` trigger β€” into a
single action round trip fired after the quiet period, instead of one POST per
keystroke. A blur flushes a pending dispatch so the last edit is never dropped.
Omit `debounce:` for the immediate-dispatch default.

```ruby
# Recompute a total live as the user types, without hammering the endpoint.
input(**mix(on(:update, event: "input", debounce: 300), name: "quantity", value: @item.quantity))
```

**Event modifiers β€” `outside:`, `window:`, `once:`, `throttle:`.** Four more
`on(...)` options cover the page-level trigger patterns that otherwise need a
hand-written Stimulus controller:

- `outside: true` fires the action only for events whose target is **outside**
this component's root β€” the close-a-dropdown-on-outside-click pattern. An
event inside the root is a complete client-side no-op. Implies `window:`.
- `window: true` binds the trigger to the window (Stimulus's native `@window`)
for page-level events like `scroll`/`resize`. Window-bound triggers are
**never `preventDefault`-ed** β€” a mounted dropdown must not kill link clicks
elsewhere on the page β€” and skip the forced `type="button"`.
- `once: true` fires at most once, then unbinds (Stimulus's `:once`).
- `throttle: 250` rate-limits **leading-edge**: the first event fires
immediately, further events are dropped until the window elapses. The mirror
of `debounce:` (trailing-edge) β€” passing both raises `ArgumentError`.

```ruby
# 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.

### 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.**

```ruby
def view_template
div(**mix(reactive_root, on_client(:click, js.hide("#menu"), outside: true))) do
# Tabs: one op chain per tab β€” hide all panels, show one, restyle the tabs.
button(**on_client(:click, js.hide(".panel").show("#panel-2")
.remove_class(".tab", "active").add_class("#tab-2", "active"))) { "Tab 2" }

# A menu that opens client-side and closes on ANY outside click (the root
# carries the window-bound trigger above).
button(**on_client(:click, js.show("#menu"))) { "Menu" }
div(id: "menu", hidden: true) { menu_items }
end
end
```

The `js` builder is immutable (each verb returns a new chain) and its
vocabulary is a fixed whitelist mirrored by the client: `show`/`hide`/`toggle`
flip the `hidden` attribute; `add_class`/`remove_class`/`toggle_class` take one
or more classes. Targets are CSS selectors resolved **within the component's
root** (nested reactive components are never touched β€” same ownership rule as
field collection); `:root` targets the root element itself; `global: true` on
an op escapes to the whole document. An op name the client doesn't recognize
logs a warning and is skipped β€” the rest of the chain still applies.

`window:`, `once:`, and `outside:` compose exactly like `on(...)`'s event
modifiers: the dropdown above closes on any click outside the component, and
window-bound triggers never `preventDefault`, so links elsewhere keep working.

**Client ops are ephemeral UI β€” the one contract to internalize.** Any server
re-render of the component (an action reply, a broadcast, a morph) rebuilds
from server state and resets whatever the ops toggled: the menu closes, the tab
snaps back. That is by design β€” the same caveat LiveView's JS commands carry.
For state that must survive a re-render (an edit mode, a selection the server
should know about), use a signed `action` instead; `on_client` is for state the
server should never care about.

**Auto-collected sibling fields β€” the read contract.** A reactive action doesn't
just receive its own trigger's value: the client gathers **every named control**
in the reactive root (`input[name]`, `select[name]`, `textarea[name]`, and named
rich-text/`contenteditable` editors) and merges them under the action's params,
so one action reads the whole form. Explicit `on(:act, x: …)` params win over a
collected field of the same name; collection stops at nested reactive roots (see
*Nested reactive components compose* above). Two things worth pinning down:

- **Timing β€” params reflect the DOM at dispatch, not a pre-event snapshot
(issue #65).** Field values are read when the request is sent (after the
debounce quiet period, if any), so a `change`/`input` trigger sees **its own
field's new value and every peer's current value.** There is no capture of the
values as they were *before* the interaction β€” if your computation needs a
peer's prior value (e.g. a spill-back that folds an overflow into the edited
field), that peer's current DOM value *is* the prior value only because nothing
else has changed it yet. Read at dispatch time, trust the current DOM.
- **Disabled fields ARE collected (issue #66) β€” deliberately different from a
native form.** A `` 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](https://stimulus.hotwired.dev/reference/actions#keyboardevent-filter)):

```ruby
# 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.

### 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:

```ruby
# 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.

**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 `mix`es id + token for you:

```ruby
# βœ… 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.warn`s on connect when a
> reactive root has no id.

**Binding inputs to action params (drop the magic `name:`).** A field's value
travels with an action only if its `name` equals the param. Hand-writing
`name: "value"` on every input is easy to forget β€” the action then silently gets
nothing. `reactive_input`/`reactive_select` emit the binding for you (the trigger
stays on the button, so focusing the field doesn't dispatch and collapse edit
mode):

```ruby
action :save, params: { value: :string, status: :string }

def view_template
span(**reactive_root) do
reactive_input(:value, value: @record.name) #
reactive_select(:status) do # …
%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 `_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):

```ruby
# 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`, backed by a styled
modal), reuse that exact dialog for reactive triggers with one line at boot:

```js
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 `await`s it, then runs
the action only on a truthy result; a falsy result (or a rejected promise β€” e.g.
the user dismissed the dialog) cancels the action, exactly like declining the
native prompt. The native default is always prevented up front, so a `submit`
trigger never navigates while the dialog is open.

Unset, behavior is identical to the native `window.confirm` β€” the `confirm:`
markup and `on(...)` API are unchanged; only the client's resolution strategy
gains a seam.

### `reply` β€” controlling the action's reply

By default an action re-renders its component in place. To do more, **return**
`reply.` β€” 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):

```ruby
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
# and its in-progress value survive the re-render (issue #28). Note the action is
# named `update`, yet `reply.morph` is unambiguous β€” the verb is on `reply`:
def update(name:) = (@row.update!(name:); reply.morph)

# Re-render a COMPANION element (a heading mirroring the edited name) alongside self:
def rename(value:) = (@account.update!(name: value); reply.replace.also_update("page_heading", html: @account.name))

# Update ONLY part of the component (issue #30): re-stream just the total cell,
# NOT the whole row. reply.streams emits exactly your streams plus a tiny
# token-only refresh β€” no full-self replace β€” so a sibling 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` | re-render in place (default; `replace` is an outerHTML swap, `update` morphs inner HTML) |
| `reply.morph` / `reply.replace(morph: true)` | re-render in place via Idiomorph (`method="morph"`) β€” preserves the focused `` + caret; for per-field reactive editing (issue #28) |
| `.also_update(target, html:)` | also re-render a companion element by DOM id; `html` is a plain string (escaped) or a Phlex component |
| `.also_replace(component, morph: false)` | also re-render another Streamable component, targeting its own `#id`; `morph: true` morphs it in place |
| `.flash(level, content, target: …)` | append a flash; `content` is a plain string (escaped, wrapped in a level-carrying `

` β€” see [Flash levels](#flash-levels)) or a Phlex component (rendered verbatim; off-request β€” no Rails `flash`); target defaults to `Phlex::Reactive.flash_target` (`"flash"`) |
| `reply.remove` | remove the element (backed by `Streamable#to_stream_remove`) |
| `reply.redirect(url)` | client-side `Turbo.visit` (pass a `*_url`); rides a `reactive:visit` turbo-stream, not an HTTP 3xx |
| `reply.streams(*streams)` | **partial update** β€” emit exactly these streams (no full-self replace) + a tiny token-only refresh, so live inputs survive; for per-field grid editing (issue #30) |
| `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.

#### Flash levels

The level reaches the wire (issue #77). **String** content is wrapped in a
level-carrying `

`, so `:error` and `:notice` are styleable:

```html


Save failed

```

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:

```ruby
# 1. Pass a Phlex component as the content β€” rendered VERBATIM, no wrapper
# (you own the markup entirely, including the level styling):
reply.replace.flash(:error, Alert.new(level: :error, message: msg))

# 2. Or configure a flash component ONCE β€” string flashes render through it
# (instantiated new(level:, content:)); component content still bypasses it:
Phlex::Reactive.flash_component = MyFlash # default nil β†’ the built-in wrapper
```

#### 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:

```ruby
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.` returns a `Phlex::Reactive::Response` β€” the
> immutable value object the endpoint reads. You can build one directly
> (`Phlex::Reactive::Response.replace(self)`) and it still works, but `reply` is
> the preferred surface; treat `Response` as an internal detail.
> **`html:`/`content` escaping.** A plain string is **HTML-escaped** by Turbo, so
> `html: @account.name` is safe even for user-supplied values. To emit intentional
> markup, pass a **Phlex component** (`html: Heading.new(name: @record.name)`) β€”
> rendered and auto-escaped through the renderer β€” or an `html_safe` string for
> raw HTML you control.

### Failure UX & lifecycle events

The generic controller dispatches three bubbling, composed `CustomEvent`s
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` |
| `network` | `fetch` itself rejected (offline, DNS, connection reset) β€” the server never saw the request | `retry` |
| `apply` | the server processed the action successfully, but something AFTER the fetch threw (a malformed response, a Turbo render error) | no `retry` |

`apply` covers a throw in the controller's own post-fetch code β€” not a
throwing listener on `reactive:applied` itself. Per the DOM spec,
`EventTarget#dispatchEvent` never propagates a listener's exception back to
its caller (it's reported to the console instead), so a listener that throws
can't surface as `reactive:error` at all β€” it just logs and the round trip is
otherwise unaffected.

`detail.retry()` re-enters the controller's request queue: it re-reads the
**freshest** signed token and re-collects the component's fields at send time,
so nothing stale is replayed. It fires no second `reactive:before-dispatch`
(one veto per user gesture), and it no-ops with a `console.warn` once the
component has left the DOM. The existing `console.error` logging is unchanged β€”
the events add hooks, they don't replace the log.

**`kind: "apply"` carries no `retry()` at all** β€” by the time this fires the
server has already completed the mutation, so retrying would re-POST an
action that already succeeded (potentially a non-idempotent one). Only the
four fetch/response-shaped kinds above are retriable.

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:

```html

```

```js
// 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:

```js
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).

### 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:

```ruby
class NotificationsList < ApplicationComponent
include Phlex::Reactive::Component

reactive_collection :notifications,
item: NotificationRow, # the per-row Streamable component
container: "notifications", # the DOM id rows live in
count: "notifications-count", # optional companion id (the size badge)
empty: NotificationsEmpty, # optional empty-state component
size: -> { Todo.count } # resolves the live size (re-counted, never client state)

action :add, params: {title: :string}
action :dismiss, params: {id: :integer}

def add(title:)
todo = Todo.create!(title:)
reply.append(:notifications, todo) # append row + bump count + clear empty-state
end

def dismiss(id:)
Todo.find(id).destroy!
reply.remove(:notifications, id) # remove row + bump count + restore empty-state at 0
end

# view_template renders the count, the container

    , and the empty-state on
    # first paint β€” the same components the helper streams in/out on each delta.
    end
    ```

    | Builder | Reply (one `Response`) |
    |---|---|
    | `reply.append(name, model)` | append the row into the container + update the count + remove the empty-state when the list crosses 0β†’1 |
    | `reply.prepend(name, model)` | as `append`, but the row goes to the top |
    | `reply.remove(name, model)` | remove the row by its `dom_id` + update the count + append the empty-state back when the list crosses β†’0 |

    - **`size:` is the source of truth** β€” it's *re-counted* server-side after the
    mutation, so the badge and the empty-state are correct-by-construction (no
    off-by-one, no client-held count). `count:`, `empty:`, and `size:` are all
    optional: omit them and only the row stream is emitted.
    - **Repeated add/remove just works** β€” each reply rolls the **container's** signed
    token forward (via the inert `reactive:token` refresh), so the second click from
    the list root is accepted. Without this an add/remove list would be add-once-only
    (correct on the first click, silently rejected after); the helper bakes the
    refresh in so you never hit it.
    - **`remove` takes the record or its `dom_id` string** β€” a just-destroyed
    ActiveRecord still answers `dom_id` correctly, so `reply.remove(:items, todo)`
    works; pass the raw id only if your row `#id` matches `ActiveRecord::RecordIdentifier`.
    - **Reply governs the actor's HTTP response only.** For a *cross-tab* live list
    (other viewers see the row appear) keep broadcasting the row with
    `NotificationRow.broadcast_append_to(..., exclude: reactive_connection_id)` β€”
    `reactive_collection` is the per-actor add/remove + count + empty-state wrapper,
    not a replacement for the broadcast.

    ### Configuration (`config/initializers/phlex_reactive.rb`)

    ```ruby
    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]

    # Use your ApplicationController to render components (app helpers / Current):
    Phlex::Reactive.renderer = ApplicationController

    # Sign tokens with a dedicated key instead of secret_key_base:
    Phlex::Reactive.verifier = ActiveSupport::MessageVerifier.new(ENV["REACTIVE_KEY"])

    # Change the endpoint path (default "/reactive/actions"):
    Phlex::Reactive.action_path = "/_r/actions"

    # Diagnostic error bodies + dropped-param logging (default: Rails.env.local? β€”
    # on in development AND test, off in production):
    Phlex::Reactive.verbose_errors = true
    ```

    If you set a custom `action_path`, expose it to the client:

    ```erb

    ```

    ---

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

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

    See [docs/security.md](https://phlex-reactive.zoolutions.llc/docs/security) for the threat model and a checklist.

    ---

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

    A counter, today vs. with phlex-reactive:

    Stimulus + Turbophlex-reactive

    ```js
    // 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() },
    })
    }
    }
    ```
    ```erb
    <%# _counter.html.erb %>


    βˆ’
    <%= @counter.value %>
    +

    ```
    ```ruby
    # 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
    ```

    ```ruby
    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](https://github.com/mhenrixon/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:

    ```ruby
    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](https://phlex-reactive.zoolutions.llc/docs/broadcasting) and
    [docs/transport-pgbus.md](https://phlex-reactive.zoolutions.llc/docs/transport-pgbus).

    ---

    ## Documentation

    - [Installation & bundler setups](https://phlex-reactive.zoolutions.llc/docs/installation)
    - [Mental model & architecture](https://phlex-reactive.zoolutions.llc/docs/architecture)
    - [Security & threat model](https://phlex-reactive.zoolutions.llc/docs/security)
    - [Broadcasting & live updates](https://phlex-reactive.zoolutions.llc/docs/broadcasting)
    - [Transport: pgbus vs Action Cable](https://phlex-reactive.zoolutions.llc/docs/transport-pgbus)
    - [Testing reactive components](https://phlex-reactive.zoolutions.llc/docs/testing)
    - [Performance & benchmarking](https://phlex-reactive.zoolutions.llc/docs/performance)
    - Examples: [counter](https://phlex-reactive.zoolutions.llc/docs/example-counter) Β·
    [chat](https://phlex-reactive.zoolutions.llc/docs/example-chat) Β· [todo list](https://phlex-reactive.zoolutions.llc/docs/example-todo-list) Β·
    [inline edit](https://phlex-reactive.zoolutions.llc/docs/example-inline-edit) Β·
    [notifications](https://phlex-reactive.zoolutions.llc/docs/example-notifications)

    ## Credits & prior art

    The mental model is stolen, gratefully, from
    [Laravel Livewire](https://livewire.laravel.com) (public method = action) and
    [Phoenix LiveView](https://www.phoenixframework.org) (a component is a re-render
    unit). The transport and reliability come from
    [pgbus](https://github.com/mhenrixon/pgbus). The rendering is all
    [Phlex](https://www.phlex.fun).

    ## License

    [MIT](LICENSE.txt).