Architecture
A component owns a stable DOM id. Everything — a click, a form change, a background broadcast — reduces to “render this component into that id.” A click POSTs and gets a reply; a broadcast pushes the same render to other tabs; a morph re-renders in place without losing focus.
The mental model#
Client interactivity (Component) and server-pushed live updates (Streamable) converge on ONE re-render unit. A reactive component self-targets by its stable #id, so a click and a broadcast share the same destination.
Record-backed components default #id to dom_id(record) — the id nearly everyone wrote by hand. One include Phlex::Reactive::Component pulls in Streamable too.
The request/reply cycle#
A click or form change POSTs POST /reactive/actions with the signed token, the action name, and any params. The endpoint verifies the token (no client state is trusted), rebuilds the component (re-finding the record from the DB), runs the whitelisted action, and streams a reply back. Turbo applies it.
The default reply is a <turbo-stream action="replace" target="#id"> of the freshly rebuilt component — you never pick a target. To do more, an action returnsreply.<verb>. Returning anything else keeps the default, so existing actions are unaffected.
def toggle
# the token proves identity, not permission
authorize! @todo, :update?
@todo.toggle!(:done)
# no return → DEFAULT reply: replace #id in place
end
def rename(title:)
if title.blank?
return reply.replace.flash(:error, "Title required")
end
@todo.update!(title:)
reply.replace
end
# drop the element from the DOM
def approve
@row.approve!
reply.remove
end
# the slug changed → redirect the browser
def publish
@article.publish!
reply.redirect(article_url(@article))
endreply.replace/reply.update— re-render in place (replaceswaps outerHTML,updateswaps inner HTML; passmorph: trueto either to morph in place).reply.morph— re-render in place via Idiomorph; preserves the focused<input>, its caret and what was typed in it, even when the render differs (see below;data-reactive-morph-valueon a field lets the morph overwrite it).reply.remove— remove the element.reply.redirect(url)— client-sideTurbo.visit(a slug changed); rides a stream, not an HTTP 3xx.reply.streams(*streams)— a partial update: emit exactly these streams plus a tiny token refresh, so live inputs the user is mid-typing in survive.
broadcast_to — see The transport.Morphing — re-render without losing focus#
A plain reply.replace swaps the element’s outerHTML — fine for a counter, but it tears down a focused field. For per-field reactive editing (a “spreadsheet” grid where a debounced save fires while the user is still typing), reply.morph emits method="morph" so Turbo 8’s bundled Idiomorph reconciles the subtree in place — the focused <input> and its caret survive the re-render.
Under the hood reply.morph calls Streamable#to_stream_replace(morph: true), the morph variant of to_stream_replace. Broadcasts morph too: broadcast_to(..., replace: model, morph: true) keeps a peer tab’s focus on the morphed row.
# A debounced per-field save fires WHILE the
# user is still typing. Morph in place so the
# focused <input> and its value survive.
def update(name:)
@row.update!(name:)
# method="morph" — focus + caret preserved
reply.morph
endreply.replace is the swap you get.Knowing what was held
A morph keeps the value of the field being typed in and writes the render's value as the field's default (issue #338). When that render differs from the old default, the server value changed while the user typed (usually another tab's broadcast_to(..., morph: true)). The client then marks the field data-reactive-held (style it: "someone changed this") and dispatches a bubbling reactive:held event with { name, theirs }, the server's value. A render equal to the default, or to what the user typed, marks nothing (nor does a checkbox or radio: its value is the option, not typing).
The root's next action request names its held fields in an X-Reactive-Held header, and inside the action reactive_held_fields returns them ([] when nothing was held), so the action can refuse or merge instead of silently overwriting the other change. Only names the action declares in its params: schema reach it: declare the field you check.
action :save, params: { title: :string, body: :string }
def save(title: nil, body: nil)
authorize!(@note, to: :update?)
# Another tab changed the title while this user typed over it.
if reactive_held_fields.include?("title") && title != @note.title
@conflict = title # render "someone changed the title" with both versions
return reply.morph
end
@note.update!(title:, body:)
reply.morph
endThe marks clear once a successful reply to the root's own request is applied (that reply is the server's answer to exactly those values); an error reply never marks and leaves the marks in place. A field's mark clears when its value is set back to its default (the user took theirs). An unfocused field takes the server's value in a morph, so it is never held.
Broadcast with exclude: reactive_connection_id (pgbus) so the actor never morphs its own echo. On a transport without exclude: (Action Cable), an echo of an older save that lands after a newer reply renders a stale value over the field, and that marks it too.
The header is untrusted input like any request field: names only, capped, and filtered to the params the running action declares (title[en] counts for a declared title). It tells you what the user saw, not what they may do: authorize as always. In a unit test, run_reactive(form, :save, held: ["title"], title: "Draft") stands in for the header.
The transport — reaching OTHER tabs#
The reply above reaches only the actor. To update other tabs and other users, a model change (or a job) calls a class-level broadcast_to — the SAME render, pushed over the stream transport instead of returned as an HTTP reply.
The transport is whatever turbo-rails is configured with: Action Cable, or pgbus over Postgres SSE (transactional — no broadcast for a rolled-back change — and reconnect-safe). Pass exclude: reactive_connection_id to suppress the actor’s own echo — they already got the action’s reply.
# In a model callback or job — light up every
# OTHER viewer's tab with the same render,
# pushed over the transport (not returned).
Chat::Message.broadcast_to(
@room, append: message, target: "messages",
exclude: reactive_connection_id # skip actor
)A click and a broadcast produce the identical “replace #id with this render.” Live cross-tab updates are not a second mechanism — they’re the same re-render unit, delivered over the transport.
The layers#
- Client runtime — one generic Stimulus controller (dispatch, morph apply, busy/lifecycle events).
- Endpoint — verify token → rebuild component → run action → stream the reply.
- Component mixin — reactive_record/reactive_state, action, on, reply.<verb>, and on_client (declared client-only DOM ops via the js builder — zero round trip). Including it pulls in Streamable automatically (one include is enough).
- Streamable mixin — #id, replace/morph/append, broadcast_to over the transport. Record-backed components default #id to dom_id(record).
Signed identity, not state#
The DOM holds a signed identity — a MessageVerifier token, never raw state. Tampering fails verification.