Examples

Example: inline edit + dirty tracking

Click-to-edit that replaces a Stimulus controller plus three routes: Enter saves, Escape cancels, focus survives the morph. Plus dirty-field tracking — an “Unsaved” badge and a leave-guard — with ZERO state shipped to the client.

Try it — click to edit#

Click the value below to edit it, then Enter (or Save) to persist and Escape (or Cancel) to back out. The mode (editing) rides in the signed token alongside the record's GlobalID, so the show ↔ edit flip is one reactive round trip — no client state, no bespoke JS.

Click me to rename inline(click to edit)
app/components/inline_edit_component.rb
# frozen_string_literal: true

# Record-backed reactive component that ALSO carries transient mode as signed
# state (issue #6). `record` is re-found via GlobalID; `attribute` (which column)
# and `editing` (the show ↔ edit mode) are signed state that must survive every
# action round trip — the classic "click to edit, Enter to save, Escape to
# cancel" widget that would otherwise be a Stimulus controller plus three routes.
class InlineEditComponent < Phlex::HTML
  include Phlex::Reactive::Streamable
  include Phlex::Reactive::Component

  reactive_record :record
  reactive_state :attribute, :editing

  action :edit
  action :cancel
  action :save, params: { value: :string }

  def initialize(record:, attribute: :title, editing: false)
    @record = record
    @attribute = attribute.to_sym
    @editing = editing
  end

  def id = dom_id(@record, "inline_#{@attribute}")

  def edit = @editing = true
  def cancel = @editing = false

  def save(value:)
    @record.update!(@attribute => value)
    @editing = false
  end

  def view_template
    span(**reactive_root(class: 'inline-flex items-center gap-2')) do
      if @editing
        # The input only holds the value — the form fields it collects travel with
        # whichever action fires. `on(:save)` lives on the Save button, not the
        # input, so focusing the field doesn't dispatch save and collapse edit mode.
        # Escape in the field cancels — event: "keydown.esc" is Stimulus's native
        # keyboard filter, so ONLY Escape fires it (Enter is free to save elsewhere).
        input(**mix(on(:cancel, event: 'keydown.esc'),
                    name: 'value', value: current_value,
                    class: 'input input-bordered input-sm', data: { testid: 'field' }))
        button(**mix(on(:save), class: 'btn btn-sm btn-primary', data: { testid: 'save' })) { 'Save' }
        button(**mix(on(:cancel), class: 'btn btn-sm btn-ghost', data: { testid: 'cancel' })) { 'Cancel' }
      else
        span(**mix(on(:edit),
                   class: 'cursor-pointer border-b border-dashed border-base-content/40',
                   data: { testid: 'display' })) { current_value.presence || '—' }
        span(class: 'text-xs opacity-50') { '(click to edit)' }
      end
    end
  end

  private

  def current_value = @record.public_send(@attribute).to_s
end

How it works#

reactive_record :record signs the record's GlobalID; reactive_state :attribute, :editing signs which column and the mode. Every action re-finds the record server-side and re-renders. save lives on the Save button, not the input — so focusing the field doesn't dispatch save and collapse edit mode. Escape is bound with event: "click keydown.esc", Stimulus's native keyboard filter.

Try it — dirty tracking#

Edit the field: an “Unsaved” badge appears on the first keystroke and the browser warns before you navigate away. Save and it clears — all with no state shipped to the client. The browser already holds the last server-rendered value in input.defaultValue, so dirty = current ≠ default; the badge is revealed purely by CSS while the root carries data-reactive-dirty.

UnsavedEdit me to see the Unsaved badge
app/components/dirty_form_component.rb
# frozen_string_literal: true

# Dirty-field tracking (issue #103) with ZERO shipped state. The browser already
# holds the last server-rendered value: input.defaultValue IS the attribute from
# the last render, so dirty = current ≠ default — no state travels to the client.
#
#   * reactive_root(track_dirty: true, warn_unsaved: true) — every input on this
#     root re-scans on change (track_dirty mixes input->reactive#trackDirty onto
#     the root's data-action), and warn_unsaved arms a navigate-away guard gated
#     on the LIVE dirty count.
#   * reactive_field(:title, value:, dirty: true) — the field carries the
#     trackDirty descriptor (redundant with the root here, but shows the per-field
#     opt-in).
#   * The "Unsaved" badge is revealed purely by CSS ([data-reactive-dirty]) — no
#     Ruby, no per-field JS. It appears on the first keystroke and clears when the
#     morph reply writes a fresh defaultValue equal to the input's value.
#
# save morphs so the reply re-renders the field with the NEW value as its fresh
# default; the post-morph re-scan then finds it clean and the badge clears with no
# full-page reload.
class DirtyFormComponent < Phlex::HTML
  include Phlex::Reactive::Streamable
  include Phlex::Reactive::Component

  reactive_record :todo
  action :save, params: { title: :string }

  def initialize(todo:)
    @todo = todo
  end

  def id = dom_id(@todo, 'dirtyform')

  def save(title:)
    @todo.update!(title:) if title.present?
    reply.morph
  end

  def view_template
    # The badge is hidden until the root carries data-reactive-dirty (set live by
    # trackDirty when current ≠ default), then revealed — pure CSS, no Ruby.
    style do
      # Static CSS, no user input — Phlex safe(), not Rails html_safe.
      css = '[data-testid="dirty-badge"]{display:none} ' \
            '[data-reactive-dirty] [data-testid="dirty-badge"]{display:inline-flex}'
      raw(safe(css)) # rubocop:disable Rails/OutputSafety
    end
    div(**reactive_root(track_dirty: true, warn_unsaved: true, class: 'flex items-center gap-2')) do
      # The field's baseline is its own defaultValue (= @todo.title from this
      # render). dirty: true also wires trackDirty onto the field itself.
      input(**mix(reactive_field(:title, value: @todo.title, dirty: true),
                  class: 'input input-bordered input-sm', data: { testid: 'title' }))
      # Revealed purely by CSS while the root is dirty — no Ruby toggles it.
      span(class: 'badge badge-warning badge-sm', data: { testid: 'dirty-badge' }) { 'Unsaved' }
      button(**mix(on(:save), class: 'btn btn-sm btn-primary', data: { testid: 'save' })) { 'Save' }
      span(class: 'text-sm opacity-60', data: { testid: 'current' }) { @todo.title }
    end
  end
end

How dirty tracking works#

reactive_root(track_dirty: true, warn_unsaved: true) mixes input->reactive#trackDirty onto the root and arms a navigate-away guard gated on the live dirty count. reactive_field(:title, dirty: true) opts the field in too. On save the action morphs, so the reply re-renders the field with the NEW value as its fresh defaultValue — the post-morph re-scan finds it clean and the badge clears without a full-page reload.

Notes#

Both widgets bind to their own demo record, so editing them here won't touch the live todo list. In your app the same InlineEditComponent works against any record + attribute — render InlineEditComponent.new(record: @user, attribute: :name).