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.
Press Enter to add · click the checkbox to toggle · Enter in a row saves the rename.
# 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
endOne 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'spreventDefaultwould leave the box unticked until the morph arrives.optimistic: { hide: true, to: :root }on archive hides the row immediately;reply.removethen 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 plainreplacewould 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.
# 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
endThe 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. Anappendinserts a child — it is not id-deduped the way areplace/morphof an existing element is. Withoutexclude:, 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.
# 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
endBroadcasting: 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 withoutexclude:. Passingexclude: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 viareply.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 viaon_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.
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.