Examples

# Example: live todo list

Per-row reactive components with add / toggle / rename / archive — optimistic toggle and delete, Enter-to-add, a morph-preserved inline rename, a client-only tips toggle, and a broadcast on every change so the list stays in sync across tabs. The canonical "list of records" pattern.

## Try it

This is a **real** reactive list rendered right here — not a screenshot. Add a row (Enter or the button), toggle it, rename it inline, or archive it. Each interaction POSTs to `/reactive/actions`, the server rebuilds the component from its signed token, runs the action, and re-renders in place — no Stimulus controller and no `.turbo_stream.erb` anywhere. Turn on the latency toggle to watch the optimistic toggle/hide and the `disable_with:` guard.

Simulate network latency

Delay every action by 600 ms so the loading states are visible.

- archive
- archive
- archive
- archive
- archive
- archive

Add

Tips

Press Enter to add · click the checkbox to toggle · Enter in a row saves the rename.

```ruby
# frozen_string_literal: true

# The live todo list: a record-backed list whose `add` action creates a Todo,
# appends the new row's component to the actor's own reply, AND broadcasts that
# row to every OTHER subscribed tab so two open windows stay in sync. Each row is
# its own reactive component (TodoItemComponent), self-targeting by GlobalID.
#
# State-backed root (a stable placeholder token) — the rows carry the real record
# identity. `disable_with:` on Add stops a rapid double-click from creating two
# todos, and `on_client` clears the composer with zero round trips.
class TodoListComponent < Phlex::HTML
  include Phlex::Reactive::Streamable
  include Phlex::Reactive::Component
  include Phlex::Rails::Helpers::TurboStreamFrom

  reactive_state :build_token # stable placeholder state for the list root
  action :add, params: { title: :string }

  def initialize(build_token: 'list')
    @build_token = build_token
  end

  def id = 'todo-list'

  # Create the todo, append the new row to the OTHER tabs, then re-render self so
  # the actor sees the row + the empty-state clears + the composer resets. An
  # append INSERTS a child (not id-deduped like a replace), so exclude: the actor
  # or their own subscribed tab would get the row a second time on top of this
  # self re-render.
  def add(title:)
    title = title.to_s.strip
    return if title.blank?

    todo = Todo.create!(title:)
    TodoItemComponent.broadcast_append_to(*Todo.stream_key, target: 'todos',
                                                            model: todo, exclude: reactive_connection_id)
  end

  def view_template
    div(**reactive_root(class: 'flex flex-col gap-3')) do
      turbo_stream_from(*Todo.stream_key) # subscribe to cross-tab updates

      ul(id: 'todos', class: 'flex flex-col') do
        if Todo.exists?
          Todo.order(:created_at, :id).each { render TodoItemComponent.new(todo: it) }
        else
          render TodoEmptyComponent.new
        end
      end

      div(class: 'flex gap-2') do
        # Enter in the field adds the todo — the same :add action the button fires
        # on click. event: "keydown.enter" is Stimulus's native keyboard filter,
        # so only Enter dispatches. The field's value rides as the `title` param.
        input(**mix(on(:add, event: 'keydown.enter'),
                    name: 'title', placeholder: 'New todo…', autocomplete: 'off',
                    class: 'input input-bordered flex-1', data: { testid: 'new-todo' }))
        # disable_with: stops a rapid double-click from creating two todos.
        button(**mix(on(:add, disable_with: 'Adding…'),
                     class: 'btn btn-primary', data: { testid: 'add' })) { 'Add' }
        # Client-only tips toggle: show/hide the hint with ZERO round trip — no
        # token, no POST, ever. Pure UX polish the one generic controller applies
        # locally (js.toggle flips the [hidden] flag).
        button(**mix(on_client(:click, js.toggle('#todo-tips')),
                     class: 'btn btn-ghost', data: { testid: 'tips-toggle' })) { 'Tips' }
      end

      p(id: 'todo-tips', hidden: true, class: 'text-xs opacity-60',
        data: { testid: 'todo-tips' }) do
        'Press Enter to add · click the checkbox to toggle · Enter in a row saves the rename.'
      end
    end
  end
end
```

## One row (record-backed, all the actions)

Each row is `TodoItemComponent` — record-backed via `reactive_record :todo`, so its identity is the Todo's GlobalID and the token re-finds the record server-side (never trusts client state). Its `#id` is `dom_id(@todo)`, the single source of truth for the row: the morph target for its own actions AND for broadcasts.

- **`optimistic: { checked: :keep }`** on the toggle flips the checkbox the instant you click — without it the reactive controller's `preventDefault` would leave the box unticked until the morph arrives. `optimistic: { hide: true, to: :root }` on archive hides the row immediately; `reply.remove` then drops it, so it never flashes back. Hints are cosmetic and always reversible — a failed round trip replays the inverse.
- **`reply.morph`** (rename) re-renders via Idiomorph, preserving the focused input + caret — a plain `replace` would tear the field out from under the typist. **`reply.remove`** (archive) drops the actor's own row with no doomed self re-render.
- **`disable_with: "…"`** disables the archive button while the action is in flight, so a rapid double-click fires archive exactly once.

```ruby
# frozen_string_literal: true

# Record-backed reactive row. Identity is the Todo's GlobalID (reactive_record),
# so the signed token re-finds the record server-side — never trusts client state.
#
# Demonstrates the full per-row toolkit, all without a Stimulus controller:
#   * an OPTIMISTIC toggle (checked: :keep) that flips the checkbox the instant
#     you click, before the round trip; the morph reconciles from server truth, a
#     failure snaps it back;
#   * an inline rename that saves on Enter and morphs in place so the field keeps
#     focus + caret (reply.morph over reply.replace);
#   * an OPTIMISTIC archive (hide: true) that hides the row NOW; reply.remove then
#     drops it, so it never flashes back;
#   * a broadcast on every change so the OTHER tabs see the same row update
#     (exclude: reactive_connection_id suppresses the actor's own echo).
class TodoItemComponent < Phlex::HTML
  include Phlex::Reactive::Streamable
  include Phlex::Reactive::Component

  reactive_record :todo
  action :toggle
  action :rename, params: { title: :string }
  action :archive

  def initialize(todo:)
    @todo = todo
  end

  # Streamable#dom_id is render-context-free.
  def id = dom_id(@todo)

  # Toggle done, then broadcast the reconciled row to the other tabs. A replace is
  # keyed by dom id, so idiomorph dedupes it for the actor even without exclude:;
  # passing exclude: keeps every action on one consistent pattern.
  def toggle
    @todo.update!(done: !@todo.done?)
    broadcast
  end

  # Morph in place so the rename input keeps focus + caret after an Enter save,
  # then broadcast the new title to the other tabs.
  def rename(title:)
    @todo.update!(title:) if title.present?
    broadcast
    reply.morph
  end

  # Destroy the record, drop this tab's row in place (reply.remove — no doomed
  # self re-render), and broadcast the removal to the other tabs. Remove is
  # idempotent, so exclude: only spares the actor a redundant echo.
  def archive
    @todo.destroy!
    TodoItemComponent.broadcast_remove_to(*Todo.stream_key, model: @todo, exclude: reactive_connection_id)
    reply.remove
  end

  def view_template
    li(**mix(reactive_root, class: 'flex items-center gap-2 py-1',
                            data: { testid: 'todo', done: @todo.done?.to_s })) do
      # The optimistic checkbox: checked: :keep lets the native flip happen NOW
      # (it skips the unconditional preventDefault), so the box ticks in the same
      # gesture; the morph then overwrites with server truth, a failure reverts it.
      input(type: 'checkbox', checked: @todo.done?,
            **mix(on(:toggle, event: 'change', optimistic: { checked: :keep }),
                  class: 'checkbox checkbox-sm', data: { testid: 'toggle' }))
      # Enter saves the rename (event: "keydown.enter" is Stimulus's native
      # keyboard filter, so ONLY Enter fires it). The Todo's title travels as the
      # `title` param (a named control of this row).
      input(**mix(on(:rename, event: 'keydown.enter'),
                  name: 'title', value: @todo.title,
                  data: { testid: 'todo-title' },
                  class: ['input input-sm flex-1', ('line-through opacity-60' if @todo.done?)]))
      # Instant delete: hide the row NOW (optimistic), remove it on the reply.
      # disable_with: guards against a rapid double-click firing archive twice.
      button(**mix(on(:archive, disable_with: '…', optimistic: { hide: true, to: :root }),
                   class: 'btn btn-xs btn-ghost', data: { testid: 'archive' })) { 'archive' }
    end
  end

  private

  # Re-render this row into every OTHER subscribed tab — exclude the actor, whose
  # own reply.morph / self-replace already updated them.
  def broadcast
    TodoItemComponent.broadcast_replace_to(*Todo.stream_key, model: @todo, exclude: reactive_connection_id)
  end
end
```

## The list + composer

`TodoListComponent` renders one `TodoItemComponent` per row plus the composer. Its `add` action creates the Todo, **broadcasts the new row's component to every OTHER subscribed tab**, and re-renders self so the actor sees the row and the empty-state clears.

- **`broadcast_append_to(..., exclude: reactive_connection_id)`** is the load-bearing detail. An `append` *inserts* a child — it is not id-deduped the way a `replace`/`morph` of an existing element is. Without `exclude:`, the actor's own subscribed tab would receive the broadcast and append the row a **second** time, on top of the copy the self re-render already rendered.
- **`disable_with: "Adding…"`** stops a rapid double-click on **Add** from creating two todos.
- **`on_client(:click, js.toggle("#todo-tips"))`** on the Tips button shows/hides the hint client-side with no token, no POST — a zero-round-trip DOM op the one generic controller applies locally.

```ruby
# frozen_string_literal: true

# The live todo list: a record-backed list whose `add` action creates a Todo,
# appends the new row's component to the actor's own reply, AND broadcasts that
# row to every OTHER subscribed tab so two open windows stay in sync. Each row is
# its own reactive component (TodoItemComponent), self-targeting by GlobalID.
#
# State-backed root (a stable placeholder token) — the rows carry the real record
# identity. `disable_with:` on Add stops a rapid double-click from creating two
# todos, and `on_client` clears the composer with zero round trips.
class TodoListComponent < Phlex::HTML
  include Phlex::Reactive::Streamable
  include Phlex::Reactive::Component
  include Phlex::Rails::Helpers::TurboStreamFrom

  reactive_state :build_token # stable placeholder state for the list root
  action :add, params: { title: :string }

  def initialize(build_token: 'list')
    @build_token = build_token
  end

  def id = 'todo-list'

  # Create the todo, append the new row to the OTHER tabs, then re-render self so
  # the actor sees the row + the empty-state clears + the composer resets. An
  # append INSERTS a child (not id-deduped like a replace), so exclude: the actor
  # or their own subscribed tab would get the row a second time on top of this
  # self re-render.
  def add(title:)
    title = title.to_s.strip
    return if title.blank?

    todo = Todo.create!(title:)
    TodoItemComponent.broadcast_append_to(*Todo.stream_key, target: 'todos',
                                                            model: todo, exclude: reactive_connection_id)
  end

  def view_template
    div(**reactive_root(class: 'flex flex-col gap-3')) do
      turbo_stream_from(*Todo.stream_key) # subscribe to cross-tab updates

      ul(id: 'todos', class: 'flex flex-col') do
        if Todo.exists?
          Todo.order(:created_at, :id).each { render TodoItemComponent.new(todo: it) }
        else
          render TodoEmptyComponent.new
        end
      end

      div(class: 'flex gap-2') do
        # Enter in the field adds the todo — the same :add action the button fires
        # on click. event: "keydown.enter" is Stimulus's native keyboard filter,
        # so only Enter dispatches. The field's value rides as the `title` param.
        input(**mix(on(:add, event: 'keydown.enter'),
                    name: 'title', placeholder: 'New todo…', autocomplete: 'off',
                    class: 'input input-bordered flex-1', data: { testid: 'new-todo' }))
        # disable_with: stops a rapid double-click from creating two todos.
        button(**mix(on(:add, disable_with: 'Adding…'),
                     class: 'btn btn-primary', data: { testid: 'add' })) { 'Add' }
        # Client-only tips toggle: show/hide the hint with ZERO round trip — no
        # token, no POST, ever. Pure UX polish the one generic controller applies
        # locally (js.toggle flips the [hidden] flag).
        button(**mix(on_client(:click, js.toggle('#todo-tips')),
                     class: 'btn btn-ghost', data: { testid: 'tips-toggle' })) { 'Tips' }
      end

      p(id: 'todo-tips', hidden: true, class: 'text-xs opacity-60',
        data: { testid: 'todo-tips' }) do
        'Press Enter to add · click the checkbox to toggle · Enter in a row saves the rename.'
      end
    end
  end
end
```

## Broadcasting: suppress the actor echo

Every mutating action ends the same way: update the record, **broadcast to the other tabs, and reconcile the actor via the HTTP reply.** The one rule that keeps it correct is `exclude: reactive_connection_id` on the broadcast:

- **`add` → `broadcast_append_to`** — an append inserts a child, so the actor MUST be excluded or the row double-applies (append is not id-deduped).
- **`toggle` / `rename` → `broadcast_replace_to`** — a replace/morph is keyed by dom id, so idiomorph dedupes it for the actor even without `exclude:`. Passing `exclude:` anyway keeps every action on one consistent pattern and avoids a redundant echo.
- **`archive` → `broadcast_remove_to`** — remove is idempotent, so a double remove is invisible; `exclude:` still spares the actor a redundant echo they already handled via `reply.remove`.

> **Tip:** Treat `exclude: reactive_connection_id` as the default on **every** broadcast that pairs with an actor reply. It's mandatory for `append`/`prepend` and harmless-but-consistent for `replace`/`morph`/`remove`. See [Broadcasting & live updates](https://phlex-reactive.zoolutions.llc/docs/broadcasting).

## What you get

- Toggle, rename, add, archive — all without a Stimulus controller or a `.turbo_stream.erb`.
- **Optimistic delete + instant toggle** via `optimistic: { hide: true }` / `optimistic: { checked: :keep }` — the UI reacts in the same gesture, the morph reconciles from server truth, and a failure snaps it back.
- **Enter-to-add and Enter-to-save** via `on(:add, event: "keydown.enter")` — Stimulus's native keyboard filter, no custom JavaScript.
- **No double-submit:** `disable_with:` disables the Add / archive buttons while their action is in flight.
- **Focus-preserving inline rename** via `reply.morph` (Idiomorph keeps the caret in the field), and a client-only tips toggle via `on_client` + `js` — zero round trip.
- Every change broadcasts the affected **row** (not the whole list) with `exclude: reactive_connection_id`, so other tabs update with a minimal payload and the actor never doubles.

## Keyed lists & morphing

Give each row a stable `id` (here `dom_id(@todo)`) so idiomorph can match rows across renders — that's what lets `reply.morph` preserve focus in the rename input and avoids re-creating unchanged rows. Turbo 8 morph keeps the focused field's in-progress value while writing the fresh server value as the new default.

> **Don’t render list items without a stable id:** Without a stable per-row id, idiomorph can't key the rows: focus is lost in the rename input and unchanged rows are needlessly re-created. `reply.morph` only preserves the caret when the row it morphs has a stable id to match against.