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

Browser

click · input

POST /reactive/actions

signed token + action

Endpoint

1

verify token — no client state trusted

2

rebuild component — re-find record

3

run whitelisted action (default-deny)

→ reply.replace / morph / …

Actor’s tab

replace #id in place

turbo-stream reply

broadcast_*_to — same render

Other tab 1

Other tab 2

over the transport (Cable / pgbus)

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 **returns**`reply.<verb>`. Returning anything else keeps the default, so existing actions are unaffected.

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

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

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

> **Tip:** 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.

What's in the token?