Example: inline edit (show ↔ edit)
The classic “click to edit a field in place” pattern. In plain Hotwire this is a
Stimulus controller plus three routes (inline_edit, inline_update,
inline_cancel) and partials. Here it’s one component with two actions.
class Fields::InlineEdit < ApplicationComponent
include Phlex::Reactive::Streamable
include Phlex::Reactive::Component
reactive_record :record
reactive_state :attribute, :editing # which field, and the mode
action :edit
action :cancel
action :save, params: { value: :string }
def initialize(record:, attribute:, 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:)
authorize! @record, :update?
@record.update!(@attribute => value)
@editing = false
end
def view_template
span(**reactive_root) do
if @editing
input(name: "value", value: current_value, autocomplete: "off")
button(**on(:save)) { "Save" }
button(**on(:cancel)) { "Cancel" }
else
span(**on(:edit), class: "editable") { current_value.presence || "—" }
end
end
end
private
def current_value = @record.public_send(@attribute)
end
Render it for any field:
render Fields::InlineEdit.new(record: @user, attribute: :name)
render Fields::InlineEdit.new(record: @user, attribute: :email)
Notes
- Two pieces of identity:
reactive_record :record(the row, re-found via GlobalID) andreactive_state :attribute, :editing(which field, what mode). Both are signed; the client can’t switch@attributeto a column it shouldn’t edit because the value is signed into the token. - Mode is server state.
edit/cancel/saveflip@editingand re-render the same element — no separate “edit” route or partial. - Authorize on save.
edit/cancelare harmless view toggles;savemutates, so it authorizes. - The
span(**on(:edit))turns the display text into the click target. Addtabindex/ keyboard handling if you need a11y on non-button triggers. - Rich-text fields work too. A named
lexxy-editor,trix-editor, or[contenteditable]is auto-collected on submit alongside plain inputs — sosavereceives its value, not a blank. See what gets auto-collected.
Surfacing a validation error
save above uses update!, which raises on invalid input. To show the error
instead, use non-bang update and return reply with a flash:
def save(value:)
authorize! @record, :update?
if @record.update(@attribute => value)
@editing = false
reply.replace
else
reply.replace.flash(:error, @record.errors.full_messages.to_sentence)
end
end
The flash content is supplied explicitly (off-request — no Rails flash) and
appends into Phlex::Reactive.flash_target (default <div id="flash">); pass a
Phlex component instead of a string for rich markup. See
Controlling the action’s reply.
Live-as-you-type (a spreadsheet-like grid)
For per-field editing where a debounced save fires while the user is still
typing or tabbing, a plain reply.replace is wrong: it’s an outerHTML swap
that destroys the <input> you’re typing in — focus and the in-progress value
vanish. Return reply.morph instead. It emits
<turbo-stream action="replace" method="morph">, so Turbo 8’s bundled Idiomorph
morphs the subtree in place — the focused field and its caret survive the save.
action :update, params: { name: :string }
def update(name:)
authorize! @record, :update?
@record.update!(name:) if name.present?
reply.morph # morph in place — keep focus + caret. The action is named
# `update`, but `reply.morph` is unambiguous (the verb is on reply).
end
def view_template
div(**reactive_root) do
# The field both holds the value AND triggers the debounced save.
input(**mix(on(:update, event: "input", debounce: 300),
name: "name", value: @record.name))
end
end
reply.replace(morph: true) is the same thing via the opt-in flag; the morphed
root still carries a fresh signed token, so the next edit verifies.
Want it to update other viewers too?
Broadcast on save:
def save(value:)
authorize! @record, :update?
@record.update!(@attribute => value)
@editing = false
Fields::InlineEdit.broadcast_replace_to(
@record, model: @record, attribute: @attribute
)
end
Now everyone viewing that record sees the new value land in place.