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) and reactive_state :attribute, :editing (which field, what mode). Both are signed; the client can’t switch @attribute to a column it shouldn’t edit because the value is signed into the token.
  • Mode is server state. edit/cancel/save flip @editing and re-render the same element — no separate “edit” route or partial.
  • Authorize on save. edit/cancel are harmless view toggles; save mutates, so it authorizes.
  • The span(**on(:edit)) turns the display text into the click target. Add tabindex / 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 — so save receives 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.


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