Form feedback and recovery
Before you start
This guide extends the profile form from Typed forms and validation. By default, a field's errors appear once the visitor has used that field, and every error appears after a submit. That suits most forms; read on when errors should wait until the visitor leaves a field, or when a form must behave differently after a reconnect.
Understand the default
The Phoenix client marks fields the visitor has not used yet with _unused_*
markers in each change. Scalive reads those markers into the form's used-field
set on change and recovery, and errorFeedback shows a field's errors only
once it is used. A submit makes every field's errors eligible until the form is
reset.
Putting phx.noUnusedField := true on a form or control suppresses those
markers, so every received field is treated as used. Keep that switch
exceptional; the default gives field-local feedback before submit.
Choose when feedback appears
Choose FormFeedback.AfterBlur when an invalid field should stay quiet until
focus leaves it. For signal-backed forms, formSignal.bind ties rendering and
events to one stable, instance-unique DomRef and takes the feedback policy;
the default FormFeedback.WhenUsed follows Phoenix's unused markers.
Apply every FormUpdate to the form in the current model, not to a form
captured during rendering. AppliedFormUpdate.valuesChanged is false for
feedback-only transitions, so status such as a save confirmation need not be
cleared just because a field blurred. Submission remains a separately typed
Definition.Event:
final class BlurFeedbackExample
extends LiveView[BlurFeedbackExample.Msg, BlurFeedbackExample.Model]:
import BlurFeedbackExample.*
def mount(ctx: MountContext): Task[Model] = ZIO.succeed(Model.initial)
def handleMessage(model: Model, ctx: MessageContext) =
case Msg.Updated(update) =>
val applied = update.applyTo(model.form)
val status = if applied.valuesChanged then None else model.status
ZIO.succeed(model.copy(form = applied.form, status = status))
case Msg.Submitted(event) =>
ZIO.succeed(model.copy(form = event.form, status = Some("Submitted")))
case Msg.NormalizePhone(value) =>
val form = model.form.updated(Profile.Phone, value.trim)
ZIO.succeed(model.copy(form = form, status = None))
case Msg.ResetCurrent =>
ZIO.succeed(model.copy(form = model.form.pristine))
case Msg.SelectChoice =>
ZIO.succeed(model.copy(form = model.form.updated(Profile.Choice, "selected"), status = None))
case Msg.ResetInitial =>
ZIO.succeed(Model.initial)
def view(model: Signal[Model]): HtmlElement[Msg] =
val binding = model
.map(_.form).bind(
FormRef,
Msg.Updated(_),
Msg.Submitted(_),
feedback = FormFeedback.AfterBlur
)
val name = binding.field(Profile.Name)
val phone = binding.field(Profile.Phone).onBlur(Msg.NormalizePhone(_))
val choice = binding.field(Profile.Choice)
val rows = binding.rows(Profile.ContactRows)
binding.render(
model.map(_.status).option(status => p(role := "status", status)),
label(forId := name.id, "Name"),
name.text(name.validationAttributes),
name.errorFeedback(error => error.map(_.message)),
label(forId := phone.id, "Phone"),
phone.tel(phone.validationAttributes),
phone.errorFeedback(error => error.map(_.message)),
div(
idAttr := choice.id,
role := "group",
choice.validationAttributes,
input(typ := "hidden", nameAttr := choice.name, value := choice.fieldValue),
button(typ := "button", on.click(Msg.SelectChoice), "Choose an option"),
// A real composite widget dispatches this only when focus leaves the whole widget.
button(typ := "button", on.click(choice.blurred), "Close picker")
),
choice.errorFeedback(error => error.map(_.message)),
rows.splitBy(_.key) { (key, row) =>
val email = binding.field(Profile.ContactRows, key, Profile.ContactEmail)
div(
row.presence(),
label(forId := email.id, "Contact email"),
email.email(email.validationAttributes),
email.errorFeedback(error => error.map(_.message))
)
},
button(typ := "submit", "Save"),
button(typ := "button", on.click(Msg.ResetCurrent), "Clear feedback"),
button(typ := "button", on.click(Msg.ResetInitial), "Reset values")
)
end view
end BlurFeedbackExample
object BlurFeedbackExample:
final case class Contact(email: String)
final case class Profile(name: String, phone: String, choice: String, contacts: Vector[Contact])
object Profile:
val Root = FormRoot("profile")
val Name = Root.text("name").required(FieldIssue("Name is required"))
val Phone = Root.text("phone")
val Choice = Root.text("choice").required(FieldIssue("Choose an option"))
val Contacts = Root.rows("contacts")
val ContactEmail = Contacts.text("email").required(FieldIssue("Email is required"))
val ContactRows = Contacts.product[Contact](ContactEmail)
val Definition = Root.product[Profile]((Name, Phone, Choice, ContactRows))
private val FirstContact = FormRowKey
.from[Contacts.type]("primary")
.fold(error => throw new IllegalArgumentException(error.code), identity)
def initial: Definition.Form = Definition.initial(
Name.initial(""),
Phone.initial(""),
Choice.initial(""),
ContactRows.initial(ContactRows.row(FirstContact)(ContactEmail.initial("")))
)
private val FormRef = DomRef("blur-feedback-profile")
final case class Model(form: Profile.Definition.Form, status: Option[String] = None)
object Model:
def initial: Model = Model(Profile.initial)
enum Msg:
case Updated(update: Profile.Definition.Update)
case Submitted(event: Profile.Definition.Event)
case NormalizePhone(value: String)
case SelectChoice
case ResetCurrent
case ResetInitial
end BlurFeedbackExampleView source (documentation/site/src/scalive/docs/examples/BlurFeedbackExample.scala:8-116)
Include binding.modifiers exactly once when supplying your own form tag:
under AfterBlur, the modifiers include the nameless hidden child required by
blur transport. binding.render(...) also rejects overrides of its owned ID and
event bindings. Keep the feedback policy stable for a rendered form instance;
the binding is the policy's configuration point.
A bound control's id and feedback ID are scoped by the form DomRef; use its
id, validationAttributes, and errorFeedback together for labels and ARIA.
A bound control throws IllegalArgumentException when its modifiers set id,
name, value, type, checked, phx-change, or phx-blur, which the
binding owns. Plain and signal-backed field views append caller modifiers
without that check, but an attribute they already render still fails the
render as a duplicate. For a custom scalar input, inputAttributes supplies
its retained id, name, value, and blur wiring.
Normalize a value on blur
Calling .onBlur(value => Msg.NormalizePhone(value.trim)) on a bound control
sends an application message after the feedback update. The command order does
not wait for server acknowledgement, so it is not a race-free normalization
mechanism; there is no normalizeOnBlur API. A later full-form snapshot can
still overwrite a server-normalized value before its patch reaches the browser.
Scalar .onBlur uses the browser's value, or an empty string when absent; it
is not the complete value vector of a multi-select. Within a LiveComponent,
use the usual phx.target(self) on the form and on controls with
application-specific blur callbacks.
For a composite control, the widget owns its logical focus boundary and
dispatches control.blurred only when focus leaves the whole widget. In keyed
rendering, bind a row control with binding.field(rows, key, field) so blur
state follows the stable row key. A bound row control must only be evaluated
while its row exists. Outside a keyed loop, use
binding.optionalField(rows, key, field)(control => ...) or the overload
accepting a BoundFormField; it omits the control safely when the row
disappears.
Reset interaction without losing values
form.pristine clears interaction while retaining current values, validation
errors, and the active feedback policy. Definition.fromValues(form.values)
revalidates with default pristine interaction; the binding reapplies its
configured policy. Definition.initial(...) creates fresh values and
interaction.
Feedback-only operations (pristine and withAllErrorsVisible) preserve
malformed payload errors rather than silently dropping them through
revalidation.
Recover a form after reconnect
When the connection drops and the page remounts, the Phoenix client sends the
values still in the browser through the form's change binding. Give a
recoverable form a stable, unique DOM id and keep its change binding; storing
event.form, as the change handler already does, restores the values and
validation state.
Use a dedicated typed recovery message when recovery needs different behavior.
Pass a second callback to onChange and handle the extra message:
Profile.Definition.onChange(Msg.Validate(_), Msg.Recover(_))
case Msg.Recover(event) =>
ZIO.succeed(model.copy(form = event.form))The recovery callback runs for successful and failed decoding. Inspect
event.kind == FormEventKind.Recovered when one message type handles multiple
event sources. Recovery is distinct from submission, so used-field visibility
still comes from recovered payload markers. A fresh mount starts with no blur
history: applying a recovery update to retained server state preserves it, but
applying it to a freshly mounted form restores values without old blur history.
Disable client recovery explicitly when replay would be unsafe or the server is
authoritative. onChange always renders the recovery binding, so bind change
events with on.change.form instead, and add phx.autoRecover := "ignore" to
the form:
form(
on.change.form(Profile.Definition)(Msg.Validate(_)),
phx.autoRecover := "ignore",
// fields
)Recovery restores what the browser still holds; it is not draft storage. It does not survive a deliberate page exit, replace a database-backed draft, recover file input bytes, or merge concurrent edits. Persist drafts in an application service when those guarantees are required.
Related tasks
Test changed-field visibility and submission with Testing; keep automatic recovery assertions at the browser boundary.
Render more control types with Form controls.
Warn before leaving a dirty form with Navigation guards.