Architecture & mental model
phlex-reactive is small. Understanding it fully takes five minutes.
The one idea
A component owns a stable DOM
id. Everything — a click, a form change, a background broadcast — reduces to “render this component into that id.”
Client interactivity and server-pushed live updates are the same operation, which is why the library is so small: there’s only one re-render unit and one way to target it.
The two halves
Server → client: Streamable
A component that implements #id can render itself as a Turbo Stream:
Counter.replace(c) → <turbo-stream action="replace" target="<c.id>">…</turbo-stream>
Counter.broadcast_replace_to(stream, model: c) → same, pushed over the transport
c.to_stream_remove → <turbo-stream action="remove" target="<c.id>"> (backs reply.remove)
c.to_stream_token → <turbo-stream action="reactive:token" target="<c.id>"
data-reactive-token-value="<fresh>"> (backs reply.streams)
Rendering goes through a real controller renderer (Phlex::Reactive.renderer),
so dom_id, url_for, t(), CSRF, etc. work during a re-render or broadcast.
This is deliberate: re-rendering a component without a view context is the #1
footgun in server-driven UIs, and we avoid it by always rendering through the
controller.
Client → server: Component
A component declares actions and emits, on its root element:
<div id="counter"
data-controller="reactive"
data-reactive-token-value="<signed { c, gid|state }>">
<button type="button"
data-action="click->reactive#dispatch"
data-reactive-action-param="increment"
data-reactive-params-param="{}">+</button>
</div>
The generic reactive Stimulus controller turns a click into:
POST /reactive/actions
{ "token": "<signed>", "act": "increment", "params": { ...collected fields... } }
Accept: text/vnd.turbo-stream.html
The endpoint verifies the token, rebuilds the component, and runs the action. If
the action returns reply.<verb>
(a replace/update, a remove, a redirect, or multiple streams — optionally with a
flash), the endpoint renders that; otherwise it falls back to the implicit single
component.to_stream_replace (the legacy contract). For a self-rendering reply
(reply.replace / .morph / .update / .with) the component’s own replace is
guaranteed present so its token refreshes — the one exception is reply.streams
(below), which opts out of the self-replace and refreshes via to_stream_token
instead. Turbo applies it in: a plain replace is an outerHTML swap; reply.morph
(or reply.update) morphs the subtree in place, preserving the focused input +
caret for per-field editing (issue #28).
reply.streams (issue #30) is the partial-update path: the action re-renders
only the targets it names and opts out of the full-self replace. The token can’t
ride a render that isn’t happening, so the endpoint appends to_stream_token
instead — a tiny reactive:token stream that carries the fresh token and is
applied by an inert client action (a pure attribute write on the root). The token
rolls forward; the component’s live inputs are never re-rendered, so a field the
user is typing in survives.
reply.<verb> returns a Phlex::Reactive::Response — the immutable value object
the endpoint reads (response_streams). It’s an internal detail; you build it
through reply.
What collected fields are
On a button click or form submit, the controller auto-collects named fields inside the component so the action receives them without you wiring anything:
- Standard controls —
input[name],select[name],textarea[name](checkboxes send theircheckedstate; radios send the checked value). - Rich-text / custom editors — named
lexxy-editor,trix-editor, and[contenteditable]elements with anameattribute (the editable ones:contenteditable,="true", or="plaintext-only"— an explicitcontenteditable="false"is skipped). These aren’t standard controls, so they’re read explicitly (serialized.value, else the contenteditable text). Without this a reactive save would post an empty value and silently wipe the field.
Only fields that exist in the DOM are sent; a declared param with no matching
field is simply absent (the action’s keyword default applies). Explicit params
from on(:save, extra: ...) always win over a collected field of the same name.
A rich editor that mirrors its value into a hidden
input[name](e.g. Trix) is already covered by the standard query; the editor read only fills a name the standard controls left absent or empty, so it never clobbers a populated input.
Nested reactive roots collect independently
A reactive component can be rendered inside another — each **reactive_root
is its own data-controller="reactive" element. Field collection stops at
nested roots: an action collects only the named inputs whose nearest
[data-controller~="reactive"] ancestor is its own root. A descendant
reactive component’s inputs belong to that component, not the outer one.
# Outer editor (its own root) containing N reactive line-item rows (each a root).
class InvoiceEditor < ApplicationComponent
# ... reactive_record :invoice; action :save, params: { notes: :string }
def view_template
div(**reactive_root) do # root carries #id + token
input(name: "notes", value: @invoice.notes) # collected by `save`
@invoice.items.each { |item| render LineItem.new(item:) } # each row is its own root
button(**on(:save)) { "Save" }
end
end
end
A save on the editor receives { notes: } only — never the rows’ bare
quantity/price inputs. A quantity change inside a row dispatches on that
row’s controller and updates only that row. So outer flat fields and per-row
reactive editing compose without name-collision workarounds (issue #15).
The reactive root must carry
#id(issue #48). The server targets the component’s#id, and the client self-matches its next signed token by the root element’sid.reactive_attrsdoes not emit the id, so spreading it onto a wrapper while puttingid:on a child (e.g. the rows container) leaves the root’s id empty — token threading then falls back to the first token in the response (a child row’s) and the next action silently 403s. Preferdiv(**reactive_root): it emits id + token on one element, so the id can’t land on the wrong node.reactive_root(class:, data: {...})deep-merges viamix, so a nested root’s owndata-testidno longer clobbersdata-controller. If you spreadreactive_attrsdirectly, keepid:on the same element. The controllerconsole.warns on connect when a reactive root has no id.
Why state isn’t in the browser
Livewire ships a snapshot of component state to the client and trusts it back (with a checksum). That’s an attacker-controlled mass-assignment surface and forces a re-signing protocol for two-way binding. We don’t do that.
Instead the DOM carries a signed identity:
- Record-backed:
{ c: "Todos::Item", gid: "gid://app/Todo/42" }. The server re-finds the record from the database. State = the DB. The client can’t forge the class or swap the record (signature), and can’t see or change the record’s columns (they’re never in the token). - State-backed:
{ c: "Counter", s: { count: 3 } }. For record-less widgets. The state is signed, so the client can’t tamper with it, but keep it small and non-sensitive. - Record + state:
{ c: "Fields::InlineEdit", gid: "gid://app/User/7", s: { attribute: "name", editing: true } }. When a component declares bothreactive_recordandreactive_state, the record’s GlobalID and the declared state are signed into one token and both are restored on each action — so transient mode (which field, edit/show) is tamper-proof alongside the record. See the inline edit example.
The token is a Rails MessageVerifier token bound to the purpose
"phlex-reactive/identity".
The re-render is whole-component, and that’s fine
We re-render and replace the entire component, then let idiomorph (Turbo 8 morphing) patch only what actually changed in the DOM — preserving focus, scroll, and unchanged nodes. We do not compute server-side diffs or maintain a template AST (Phlex has neither, and doesn’t need them). For the vast majority of components, “render the component, morph it in” is the right trade: tiny code, no stale-cache hazards, payload bounded to one component.
If a single component grows large and chatty, split it into smaller components and broadcast the inner one. That’s the idiomatic answer, not a diff engine.
Concurrency: the in-flight token race
Because state lives in the token and the token is rewritten by each response, two requests in flight at once would both read the old token and clobber each other (last-write-wins). The client runtime prevents this two ways:
- Per-component queue —
dispatchchains on a per-controller promise, so requests for one component run one at a time. - Synchronous token threading — each response’s new token is parsed out of the returned HTML immediately and used for the next queued request, without waiting for the async DOM morph.
Result: click + five times fast → 5, never 1 or 2.
Debounced triggers
A trigger may opt into a quiet-period debounce with
on(:update, event: "input", debounce: 300). The controller reads the debounce
(a Stimulus data-reactive-debounce-param) in dispatch and, instead of
enqueuing the round trip immediately, resets a per-trigger-element timer;
only after debounce ms of quiet does it enqueue onto the same per-component
queue (so token threading still holds). A blur on the element flushes a pending
dispatch immediately, so tabbing away never drops the last edit. preventDefault
still fires synchronously, so a debounced submit trigger won’t navigate
(issue #11). On disconnect (the element leaves the DOM via a Turbo morph or
navigation) every pending debounce timer is cleared, so a deferred round trip
never fires against a detached controller. Without debounce:, dispatch is
immediate — unchanged behavior.
Result: type c-a-t-s fast into a debounced input → ONE search round trip, not
four.
What runs where
| Concern | Where |
|---|---|
| Action declarations, identity signing, re-render | Ruby (Component, Streamable) |
| Token verification, record re-find, action dispatch, param coercion | ActionsController |
| Event binding, request serialization, token threading, morph | One Stimulus controller (~150 LOC) |
| Transport (SSE/Action Cable), reliability, presence | pgbus / turbo-rails |
| DOM patching | idiomorph (via Turbo) |
What we deliberately did NOT build
- No template AST / parser. Phlex compiles to a string buffer; we don’t need template structure to know what to re-render — the component is the unit.
- No stateful process per client (the LiveView model). The server stays stateless; identity travels in the signed token. Scales like any Rails request.
- No client state snapshot. State is the DB behind a signed identity.
- No bespoke transport or big client framework. Turbo + idiomorph already morph; pgbus already delivers. We add ~150 lines of glue, not a framework.