Validation

Live validation

live model: User runs your real ActiveModel validators on blur — uniqueness included — and morphs errors back with focus preserved.

One source of truth#

Client-side validation frameworks mirror your validators in JavaScript — and every mirror has a ceiling: uniqueness needs the database, :if/:unless need server context, custom validators don't translate, and every message exists twice.

live takes the other path. The whole form becomes one phlex-reactive component: leaving a field POSTs all form fields to a signed :validate action, the server assigns them to the model, runs the real validators, and replies with a focus-preserving morph.

app/forms/user_form.rb
class UserForm < Forms::Base
  live model: User, debounce: 300

  def fields
    field :email                  # uniqueness validates against the real DB
    field :password
    field :password_confirmation  # cross-field confirmation just works
    submit :primary
  end
end

i18n is plain Rails i18n. Custom validators, :on contexts, errors.add from anywhere — it all works, because it is your model. Nothing is ever persisted by :validate; native submit and your controller stay authoritative.

How it works#

  • The <form> element is the reactive root: it carries the signed identity token and a debounced whole-form input trigger.
  • Each input carries a blur trigger with a _touch param naming the field.
  • Identity is state-backed: the token signs the model's GlobalID when persisted (nil for new records) plus the touched-field list — tamper-proof, with zero client-side bookkeeping.
  • The endpoint rebuilds the form from its class, locates the record (or news up live_model_class), assigns a whitelisted slice, calls model.validate, and replies morph — Idiomorph preserves the focused input and its caret.

No premature errors#

A field's error first appears when you leave it (blur adds it to the signed touched set) — typing in a fresh field never flashes an error under a half-typed value. Meanwhile the debounced input trigger live-updates the errors of fields you already touched, so fixing the password clears the confirmation error as you type.

A form re-rendered after a failed submit (the classic 422) arrives with errors already on the model — those fields are auto-touched, so the standard flow shows everything.

Assignment is double-whitelisted#

The action's param schema admits only the form's scope and _touch — everything else is dropped at the endpoint. In the action, the slice is narrowed again to the model's column/attribute names plus every validated attribute (and _confirmation twins), assigned through public writers on an in-memory model. Tune it per class:

class UserForm < Forms::Base
  live model: User
  live_deny :role                       # never assign these
  # live_permit :email, :password       # or: assign ONLY these
end

Constraints#

  • live requires a Forms::Base subclass. The endpoint rebuilds the form from its class — an inline Form(model:) { … } block cannot be serialized, so Form(live: true) raises and says so.
  • phlex-reactive is a soft dependency: without it the macro raises a FeatureUnavailable with install guidance, and the Stimulus fallback still works.
  • Collection controls (collection_check_boxes, multi-selects) are excluded from live assignment in v1.
  • Every blur/debounced input runs the full validator set — including uniqueness queries. The default 300ms debounce keeps that reasonable; raise it for hot forms.