Skip to content
scalive
Menu
ConnectingLiveReconnectingOffline
GitHub

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:

Source
scala
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 BlurFeedbackExample

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:

Fragment
scala
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:

Fragment
scala
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.