Guide

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#

One re-render unit: a click and a broadcast both resolve to “render this component into that id.”

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.

app/components/todos/item.rb
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))
end
  • reply.replace / reply.update — re-render in place (replace swaps outerHTML, update swaps inner HTML; pass morph: true to 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-side Turbo.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.
The reply is the actor’s reply
It governs only the clicking user’s HTTP response. Cross-tab updates still go out via 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
end
Requires Turbo 8+
Morphing is a Turbo 8 feature. On older Turbo, reply.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.

pgbus needs no Redis and no Action Cable — one Postgres, and your reactive code is identical.

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.

Never trust client input
The signature proves the token is ours, NOT that this user may act. Authorize inside the action.