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.

app/components/todo_list_component.rb
# 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.
app/components/todo_item_component.rb
# 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.
app/components/todo_list_component.rb
# 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:

  • addbroadcast_append_to — an append inserts a child, so the actor MUST be excluded or the row double-applies (append is not id-deduped).
  • toggle / renamebroadcast_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.
  • archivebroadcast_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.

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.

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.