Example: live todo list

Per-row reactive components with add / toggle / rename / delete, broadcasting on change so the list stays in sync across tabs and users. This is the canonical “list of records” pattern.

Model

class Todo < ApplicationRecord
  belongs_to :list
  # Live cross-client sync over pgbus SSE — transactional, reconnect-safe.
  broadcasts_to ->(t) { [t.list, :todos] }, durable: true
end

broadcasts_to here only fires Turbo’s default per-record broadcasts. We drive the precise UI updates from the reactive actions below, which is clearer for a component-rendered list — so you can also omit broadcasts_to and broadcast explicitly (shown inline).

One row (record-backed, all the actions)

class Todos::Item < ApplicationComponent
  include Phlex::Reactive::Streamable
  include Phlex::Reactive::Component

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

  def initialize(todo:) = @todo = todo
  def id = dom_id(@todo)

  def toggle
    authorize! @todo, :update?
    @todo.toggle!(:done)
    broadcast
  end

  def rename(title:)
    authorize! @todo, :update?
    @todo.update!(title: title)
    broadcast
  end

  def destroy
    authorize! @todo, :destroy?
    list = @todo.list
    @todo.destroy!
    # Remove from every other tab; this tab's own response also removes it.
    Todos::Item.broadcast_remove_to(list, :todos, model: @todo)
  end

  def view_template
    li(id:, **reactive_attrs, class: ("done" if @todo.done?)) do
      button(**on(:toggle)) { @todo.done? ? "✓" : "○" }
      input(name: "title", value: @todo.title, **on(:rename, event: "change"))
      button(**on(:destroy)) { "✕" }
    end
  end

  private

  # Re-render this row into every other subscribed tab.
  def broadcast
    Todos::Item.broadcast_replace_to(@todo.list, :todos, model: @todo)
  end
end

Note destroy returns a replace of a now-deleted row in the action response. Render to_stream_replace of a tombstone, or override the endpoint to send a remove — the simplest path is to also handle removal client-side via the broadcast and let the action response be a no-op replace of an empty row. For clarity most apps make destroy an action whose response removes the element; see docs/broadcasting.md.

The list + composer

class Todos::List < ApplicationComponent
  include Phlex::Reactive::Streamable
  include Phlex::Reactive::Component

  reactive_record :list
  action :add, params: { title: :string }

  def initialize(list:) = @list = list
  def id = dom_id(@list, :todos_panel)

  def add(title:)
    authorize! @list, :update?
    title = title.to_s.strip
    return if title.blank?

    todo = @list.todos.create!(title:)
    Todos::Item.broadcast_append_to(@list, :todos, target: dom_id(@list, :todos), model: todo)
  end

  def view_template
    div(id:, **reactive_attrs) do
      turbo_stream_from @list, :todos                       # subscribe to live updates

      ul(id: dom_id(@list, :todos)) do
        @list.todos.order(:created_at).each { |t| render Todos::Item.new(todo: t) }
      end

      div do
        input(name: "title", placeholder: "New todo…", autocomplete: "off")
        button(**on(:add)) { "Add" }
      end
    end
  end
end

What you get

  • Toggle, rename (on change), add, delete — all without a Stimulus controller or a .turbo_stream.erb.
  • Every change broadcasts the affected row (not the whole list) over pgbus SSE, so other tabs/users update with a minimal payload.
  • dom_id(@todo) is the single source of truth for the row’s identity: the morph target for actions AND broadcasts.

Keyed lists & morphing

Give each row a stable id (here dom_id(@todo)) so idiomorph can match rows across renders — that preserves focus in the rename input and avoids re-creating unchanged rows. Don’t render list items without a stable id.


This site uses Just the Docs, a documentation theme for Jekyll.