Skip to content
scalive
Menu
ConnectingLiveReconnectingOffline

Typed forms and validation

Prerequisites

Read Models and messages and HTML and event bindings first.

Define A Rooted Form

Start with a FormRoot, define fields relative to it, and combine those fields into one domain constructor:

scala
final case class Profile(name: String, email: String)

object Profile:
  val Root = FormRoot("profile")

  val Name = Root
    .string("name")
    .map(_.trim)
    .required("Name is required.")

  val Email = Root
    .string("email")
    .map(_.trim)
    .required("Email is required.")
    .validate("Enter a valid email address.")(EmailPattern.matches)

  val Definition = Root.form(Profile.apply)(Name, Email)

The stable root value gives each field a complete browser name such as profile[name]. Its singleton owner type prevents fields from another root, even one with the same runtime name, from being combined accidentally. The constructor produces Profile only when every field decodes successfully.

Normalize before validating. Here map runs before required, so whitespace-only input is blank and valid values enter the domain without surrounding whitespace.

Accumulate Field Errors

Fields combined by FormRoot.form accumulate independent decoding errors in field order. An invalid name and email therefore produce both path-specific errors rather than stopping after the first field.

Use string for a scalar that treats absence as "" and rejects duplicates, requiredString for exactly one non-empty value, optionalString for Option[String], and strings for repeated values. Use Root.field when a custom decoder must own cardinality and validation. Then compose normalization and domain rules with map, required, and validate.

Each error retains its FormPath, allowing rendering to associate feedback with the exact input. The complete executable profile definition also limits biography length:

Source
scala
final class ProfileFormExample extends LiveView[ProfileFormExample.Msg, ProfileFormExample.Model]:
  import ProfileFormExample.*

  def mount(ctx: MountContext): Task[Model] =
    ZIO.succeed(Model(Profile.Definition.initial()))

  def handleMessage(model: Model, ctx: MessageContext) =
    case Msg.Validate(event) =>
      ZIO.succeed(model.copy(form = Profile.Definition.from(event), saved = None))
    case Msg.Save(event) =>
      ZIO.succeed(
        model.copy(
          form = Profile.Definition.from(event),
          saved = event.value.toOption
        )
      )
    case Msg.Reset =>
      ZIO.succeed(Model(Profile.Definition.initial()))

  override def view(model: Signal[Model]): HtmlElement[Msg] =
    val profileForm    = model.map(_.form)
    val nameField      = profileForm.field(Profile.Name)
    val emailField     = profileForm.field(Profile.Email)
    val biographyField = profileForm.field(Profile.Biography)

    div(
      cls := "docs-profile-form",
      model.map(_.saved).option { profile =>
        p(
          dataAttr("profile-saved") := "",
          cls                       := "docs-profile-saved",
          role                      := "status",
          profile.map(profile => s"Saved ${profile.name}'s profile.")
        )
      },
      form(
        dataAttr("profile-form") := "",
        Profile.Definition.onChange(Msg.Validate(_)),
        Profile.Definition.onSubmit(Msg.Save(_)),
        field(
          label(forId := nameField.id, "Name"),
          nameField.text(
            nameField.validationAttributes,
            placeholder := "Ada Lovelace"
          ),
          nameField.errorFeedback(dataAttr("field-error") := "name")
        ),
        field(
          label(forId := emailField.id, "Email"),
          emailField.email(
            emailField.validationAttributes,
            placeholder := "ada@example.com"
          ),
          emailField.errorFeedback(dataAttr("field-error") := "email")
        ),
        field(
          label(forId := biographyField.id, "Biography"),
          biographyField.textarea(
            biographyField.validationAttributes,
            rows        := 5,
            placeholder := s"Up to ${Profile.BiographyMaxLength} characters"
          ),
          biographyField.errorFeedback(dataAttr("field-error") := "biography")
        ),
        div(
          cls := "docs-profile-actions",
          button(typ := "submit", submission.replaceTextWith("Saving..."), "Save profile"),
          button(typ := "button", on.click(Msg.Reset), "Reset form")
        )
      )
    )
  end view

  private def field(content: Mod[Msg]*): HtmlElement[Msg] =
    div(cls := "docs-profile-field", content)
end ProfileFormExample

object ProfileFormExample:
  final case class Profile(name: String, email: String, biography: String)

  object Profile:
    val BiographyMaxLength = 500
    val Root               = FormRoot("profile")

    val Name = Root
      .string("name")
      .map(_.trim)
      .required("Name is required.")

    val Email = Root
      .string("email")
      .map(_.trim)
      .required("Email is required.")
      .validate("Enter a valid email address.")(EmailPattern.matches)

    val Biography = Root
      .string("biography")
      .map(_.trim)
      .required("Biography is required.")
      .validate(s"Biography must be $BiographyMaxLength characters or fewer.")(
        _.length <= BiographyMaxLength
      )

    val Definition = Root.form(Profile.apply)(Name, Email, Biography)

    private val EmailPattern = """^[^\s@]+@[^\s@]+\.[^\s@]+$""".r

  final case class Model(form: Profile.Definition.Form, saved: Option[Profile] = None)

  enum Msg:
    case Validate(event: FormEvent[Profile])
    case Save(event: FormEvent[Profile])
    case Reset
end ProfileFormExample

Keep Form State In The Model

Create pristine form state during mount and store the RootedForm in the model:

Task[A] is the effect returned from LiveView lifecycle methods, and ZIO.succeed creates one that cannot fail.

scala
def mount(ctx: MountContext): Task[Model] =
  ZIO.succeed(Model(Profile.Definition.initial()))

Pass owner-checked initial raw values when editing existing data:

scala
Profile.Definition.initial(
  Profile.Name.initial(existing.name),
  Profile.Email.initial(existing.email)
)

FormDefinition.initial decodes the initial raw values immediately, so state may contain required-field errors. Those errors are intentionally not visible yet: the form has not been submitted and no field is used.

Rebuild the rooted form from every typed event. This preserves raw browser input, decoded values or errors, used fields, and submission state together:

scala
case Msg.Validate(event) =>
  ZIO.succeed(model.copy(form = Profile.Definition.from(event), saved = None))

Handle Change And Submit Events

Bind both events through the rooted form:

scala
form(
  idAttr := "profile-form",
  profileForm.onChange(Msg.Validate(_)),
  profileForm.onSubmit(Msg.Save(_)),
  // fields and actions
)

Both messages carry FormEvent[Profile]. Its value is either accumulated FormErrors or the decoded Profile. Change events retain their target and used-field state. Submit events set submitted = true, making all relevant feedback visible. submitter identifies the successful named submit control when the client supplies one.

On submit, persist or pass the domain value only from the Right branch. Do not re-decode strings manually in handleMessage:

scala
case Msg.Save(event) =>
  event.value match
    case Right(profile) => save(profile).as(model.copy(
      form = Profile.Definition.from(event),
      saved = Some(profile)
    ))
    case Left(_) => ZIO.succeed(model.copy(
      form = Profile.Definition.from(event),
      saved = None
    ))

Recover A Form After Reconnect

Give a recoverable form a stable, unique DOM id and keep its change binding. Phoenix normally recovers client form values through the change event after a LiveView reconnect. Rebuilding with Definition.from(event) applies the same codec and restores the raw values and validation state.

Use a dedicated typed recovery message when recovery needs different behavior:

scala
form(
  idAttr := "profile-form",
  profileForm.onChange(Msg.Validate(_)),
  profileForm.onSubmit(Msg.Save(_)),
  profileForm.onRecover(Msg.Recover(_)),
  // controls
)

case Msg.Recover(event) =>
  ZIO.succeed(model.copy(form = Profile.Definition.from(event)))

The recovery callback runs for successful and failed decoding. Inspect event.recovery when one message type handles multiple event sources. Recovery is distinct from submission: it does not itself set submitted = true, so used-field visibility still comes from recovered payload markers.

Disable client auto-recovery explicitly when replay would be unsafe or the server is authoritative:

scala
form(
  idAttr := "payment-form",
  paymentForm.disableRecovery,
  paymentForm.onSubmit(Msg.Pay(_)),
  // controls
)

Current recovery is browser/LiveView protocol recovery, not durable 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.

Render Richer Controls

Ask the rooted form for a field view, then use its generated ID, name, current raw value, and validation helpers:

scala
val emailField = profileForm.field(Profile.Email)

label(forId := emailField.id, "Email")
emailField.email(emailField.validationAttributes)
emailField.errorFeedback()

FormFieldView currently provides text, email, password, hidden, checkbox, textarea, and select helpers. They append caller modifiers, so normal attributes such as autocomplete, maxlength, required, multiple, or CSS classes remain available:

scala
val bioField  = profileForm.field(Profile.Bio)
val roleField = profileForm.field(Profile.Role)

bioField.textarea(rows := 6, bioField.validationAttributes)
roleField.select(
  List("reader" -> "Reader", "editor" -> "Editor"),
  roleField.validationAttributes
)

A checkbox is checked when its submitted value occurs in rawValues. Its default checked value is "true", or pass an explicit value. The helper does not generate a hidden unchecked value, so model absence deliberately in the field decoder. A select marks every option found in rawValues; for a multiple select, use a repeated-value field such as Root.strings.

There are no current typed convenience helpers for numeric, date, radio-group, or file controls. Use Root.field plus ordinary HTML controls for custom decoding, and use liveFileInput with the upload API for files. The existing helpers preserve raw strings; they do not parse numbers or dates implicitly.

validationAttributes connects the input to feedback and adds aria-invalid when visible errors exist. errorFeedback renders a stable live region whose messages remain hidden until that field is used or the form is submitted. Use a real label with forId and keep success feedback in a separate status region.

Reset Deliberately

A reset message should construct fresh initial form state and clear any saved result. This resets raw values, used fields, submission state, and visible errors together. Replacing only input strings can leave stale validation state behind.

Try change, invalid submit, valid submit, and reset behavior in the typed profile form example.