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>and its caret (see below).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_morph, the morph variant of to_stream_replace. Broadcasts morph too: broadcast_replace_to(..., 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.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_append_to(
@room, target: "messages", model: message,
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.