Typed forms and validation
Prerequisites
Read Models and messages and HTML and event bindings first.
Define A Rooted Form
Start with a FormRootclass FormRootDefines one form root and owns the fields, codecs, initial values, and forms created from it., define fields relative
to it, and combine those fields into one domain constructor:
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 mapdef map[B](f: A => B): FormField[B]Transforms a successfully decoded value while retaining this field's path.
runs before requireddef required(message: String = ..., code: Option[String] = ...)(using ev: =:=[A, String]): FormField[String]Requires a decoded String to be non-empty., so
whitespace-only input is blank and valid values enter the domain without
surrounding whitespace.
Accumulate Field Errors
Fields combined by FormRoot.formdef form[A1, A2, A3, A4, A5, Result](construct: (A1, A2, A3, A4, A5) => Result)(field1: Field[A1], field2: Field[A2], field3: Field[A3], field4: Field[A4], field5: Field[A5]): FormDefinition[type, Result]
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 FormPathclass FormPath(segments: Vector[String])A structured form field path rendered with browser bracket notation., allowing
rendering to associate feedback with the exact input. The complete executable
profile definition also limits biography length:
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 ProfileFormExampleView source (documentation/site/src/scalive/docs/examples/ProfileFormExample.scala:8-121)
Keep Form State In The Model
Create pristine form state during mount and store the
RootedFormclass RootedForm[Owner, A]A form whose field access is restricted to definitions owned by the same FormRoot. in the model:
Task[A] is the effect returned from LiveView lifecycle methods, and
ZIO.succeed creates one that cannot fail.
def mount(ctx: MountContext): Task[Model] =
ZIO.succeed(Model(Profile.Definition.initial()))Pass owner-checked initial raw values when editing existing data:
Profile.Definition.initial(
Profile.Name.initial(existing.name),
Profile.Email.initial(existing.email)
)FormDefinition.initialdef initial(values: InitialValue*): FormCreates an unsubmitted form from owner-checked raw initial values. 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:
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:
form(
idAttr := "profile-form",
profileForm.onChange(Msg.Validate(_)),
profileForm.onSubmit(Msg.Save(_)),
// fields and actions
)Both messages carry FormEvent[Profile]class FormEvent[+A](raw: FormData, value: Either[FormErrors, A], target: Option[FormPath] = ..., submitter: Option[FormSubmitter] = ..., recovery: Boolean = ..., submitted: Boolean = ..., metadata: Map[String, String] = ...)A typed LiveView form event and its semantic browser metadata..
Its valueval value: util.Either[FormErrors, A] is either accumulated
FormErrorsclass FormErrorsAn immutable, ordered collection of validation errors. 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:
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:
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:
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:
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:
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.
validationAttributesdef validationAttributes: Vector[Mod.Attr[Nothing]]Returns accessibility attributes paired with errorFeedback.
connects the input to feedback and adds aria-invalid when visible errors
exist. errorFeedbackdef errorFeedback(mods: |[Mod[Nothing], IterableOnce[Mod[Nothing]]]*): HtmlElement[Nothing]Renders interaction-visible errors paired with validationAttributes.
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.
Related Tasks
Use Ordinary HTTP forms and redirects when the browser should perform a normal GET or POST.
Use File uploads for file inputs and resource ownership.
Use Authentication and sessions for credential forms and protected routes.
Use Testing to exercise duplicate fields, invalid submission, recovery, and reset behavior.