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.
# 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
endHow 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.
# 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
endHow 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).