Typed forms and validation
Before You Start
You need a LiveView with a typed model and messages, and you should be able to
render controls and bind browser events. Review Models and messages
or HTML and event bindings if either part is
not yet familiar.
For a first pass, follow the definition, model, event, rendering, and reset sections through the typed profile form. Return for repeated rows and save coordination when the screen needs them. The runnable examples form this progression:
Typed profile form for one complete validated form.
Stable repeated contact rows for keyed row operations.
Form save workflow for dirty state and correlated persistence.
Define A Rooted Form
Start with a FormRootclass FormRootDeclares a root-level form schema namespace., 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
.text("name")
.map(_.trim)
.required(FieldIssue("validation.name.required", Some("required")))
val Email = Root
.text("email")
.map(_.trim)
.required(FieldIssue("validation.email.required", Some("required")))
.validate(FieldIssue("validation.email.invalid", Some("invalid_email")))(
EmailPattern.matches
)
val Definition = Root.product[Profile]((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: Value => B): FormField[Owner, Input, B]Maps a successfully refined value without changing its editable input representation.
runs before requireddef required(issue: FieldIssue = ...)(using valueIsString: =:=[Value, String]): FormField[Owner, Input, String]Rejects an empty refined string while preserving the field's original input type., so
whitespace-only input is blank and valid values enter the domain without
surrounding whitespace. A FieldIssue carries the stable message or
localization key and an optional machine-readable code.
Accumulate Field Errors
Fields combined by FormRoot.productdef product[Domain](parts: Tuple)(using mirror: deriving.Mirror.ProductOf[Domain]): FormDefinition[type, Domain]Completes a product schema, checking at compile time that part values match Domain.
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. The tuple arity and element types must exactly match the case
class constructor, so missing fields and differently typed positions are
rejected at compile time. When adjacent case-class fields have the same type,
their semantic order remains the caller's responsibility.
Use text for a scalar that treats absence as its default and rejects
duplicates, optionalText for Option[String], and texts for repeated
values. Use Root.field("enabled", FieldInput.checkbox()) for a Boolean
checkbox, or supply a custom FieldInput to own decoding and encoding.
Then compose normalization and domain refinement with
map, required, validate, and emap:
val Age = Root.text("age").emap { text =>
text.toIntOption.filter(_ >= 0).toRight(
FieldIssues.one(FieldIssue("validation.age.invalid", Some("invalid_age")))
)
}emap preserves the editable input type (String here) while refining the
successful value to another type (Int). This is why typed updates can still
encode browser-editable input without requiring an inverse for every domain
refinement.
Use FormDefinition.emap after product construction when validation depends on
more than one field. Return errors from the pre-refinement definition so every
issue still has an owner-checked logical address:
object RegistrationForm:
final case class Registration(email: String, confirmation: String)
val Root = FormRoot("registration")
val Email = Root.text("email").map(_.trim)
val Confirmation = Root.text("confirmation").map(_.trim)
val Fields = Root.product[Registration]((Email, Confirmation))
val Definition = Fields.emap { registration =>
Either.cond(
registration.email == registration.confirmation,
registration,
Fields.errors(
Confirmation,
FieldIssue("validation.email.mismatch", Some("mismatch"))
)
)
}View source (documentation/site/src/scalive/docs/examples/FormRecipes.scala:8-24)
Each error retains its owner-scoped FormAddressclass FormAddress[Owner]A stable, owner-scoped logical address used by validation and interaction state.,
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 = event.form, previewed = None, saved = None))
case Msg.Submit(event, intent) =>
intent match
case Right(Profile.Intent.Preview) =>
ZIO.succeed(model.copy(form = event.form, previewed = event.valueOption, saved = None))
case Right(Profile.Intent.Save) =>
ZIO.succeed(model.copy(form = event.form, previewed = None, saved = event.valueOption))
case Left(_) =>
ZIO.succeed(model.copy(form = event.form, previewed = None, saved = None))
case Msg.Reset =>
ZIO.succeed(Model(Profile.Definition.initial()))
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(_.previewed).option { profile =>
p(
dataAttr("profile-previewed") := "",
cls := "docs-profile-saved",
role := "status",
profile.map(profile => s"Previewing ${profile.name}'s profile.")
)
},
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") := "",
idAttr := "profile-form",
Profile.Definition.onChange(Msg.Validate(_)),
Profile.Definition.onSubmit(Profile.Submitter)(Msg.Submit.apply),
field(
label(forId := nameField.id, "Name"),
nameField.text(
nameField.validationAttributes,
placeholder := "Ada Lovelace"
),
nameField.errorFeedback(
error => error.map(value => messages(value.message)),
dataAttr("field-error") := "name"
)
),
field(
label(forId := emailField.id, "Email"),
emailField.email(
emailField.validationAttributes,
placeholder := "ada@example.com"
),
emailField.errorFeedback(
error => error.map(value => messages(value.message)),
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(
error => error.map(value => messages(value.message)),
dataAttr("field-error") := "biography"
)
),
div(
cls := "docs-profile-actions",
Profile.Submitter.button(Profile.Intent.Preview)("Preview profile"),
Profile.Submitter.button(Profile.Intent.Save)(
submission.replaceTextWith("Saving..."),
"Save profile"
),
button(typ := "button", on.click(Msg.Reset), "Reset form")
)
)
)
end view
private def field(content: Mod.Input[Msg]*): HtmlElement[Msg] =
div(cls := "docs-profile-field", Mod.flatten(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
.text("name")
.map(_.trim)
.required(FieldIssue("validation.name.required", Some("required")))
val Email = Root
.text("email")
.map(_.trim)
.required(FieldIssue("validation.email.required", Some("required")))
.validate(FieldIssue("validation.email.invalid", Some("invalid_email")))(EmailPattern.matches)
val Biography = Root
.text("biography")
.map(_.trim)
.required(FieldIssue("validation.biography.required", Some("required")))
.validate(FieldIssue("validation.biography.too_long", Some("too_long")))(
_.length <= BiographyMaxLength
)
val Definition = Root.product[Profile]((Name, Email, Biography))
enum Intent(val wireValue: String):
case Preview extends Intent("preview")
case Save extends Intent("save")
val Submitter = Definition.submitter(Intent.values)(_.wireValue)
private val EmailPattern = """^[^\s@]+@[^\s@]+\.[^\s@]+$""".r
end Profile
private val messages = Map(
"validation.name.required" -> "Name is required.",
"validation.email.required" -> "Email is required.",
"validation.email.invalid" -> "Enter a valid email address.",
"validation.biography.required" -> "Biography is required.",
"validation.biography.too_long" ->
s"Biography must be ${Profile.BiographyMaxLength} characters or fewer."
)
final case class Model(
form: Profile.Definition.Form,
previewed: Option[Profile] = None,
saved: Option[Profile] = None)
enum Msg:
case Validate(event: Profile.Definition.Event)
case Submit(
event: Profile.Definition.Event,
intent: Either[FormSubmitter.DecodeError, Profile.Intent])
case Reset
end ProfileFormExampleView source (documentation/site/src/scalive/docs/examples/ProfileFormExample.scala:8-165)
Keep Form State In The Model
Create pristine form state during mount and store the definition's path-dependent
Form alias in the model:
Task[A] is the effect returned from LiveView lifecycle methods, and
ZIO.succeed creates one that cannot fail.
final case class Model(form: Profile.Definition.Form)
def mount(ctx: MountContext): Task[Model] =
ZIO.succeed(Model(Profile.Definition.initial()))Pass owner-checked typed initial values when editing existing data:
Profile.Definition.initial(
Profile.Name.initial(existing.name),
Profile.Email.initial(existing.email)
)FormDefinition.initialdef initial(assignments: FormInitial[Owner]*): FormCreates a pristine form from typed or explicitly raw schema-owned assignments. decodes
the initial editable 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.
Store the already rebuilt form from every typed event. Definition.Event is the
matching event alias, so model and message declarations stay tied to this exact
schema:
enum Msg:
case Validate(event: Profile.Definition.Event)
case Save(event: Profile.Definition.Event)
case Msg.Validate(event) =>
ZIO.succeed(model.copy(form = event.form, saved = None))FormValues is the immutable, canonical, metadata-free editable state. It
contains schema-owned scalar values and stable keyed rows, but not event target,
used-field, or submission metadata. Values from different definition instances
are intentionally unequal. Compare values to a baseline for dirty checks, and
use Definition.fromValues(values) when interaction should become pristine or
fromValues(values, interaction) when deliberately preserving it.
Update Fields Programmatically
Use Form.updateddef updated[Group, Input, Value](field: BoundFormField[Owner, Schema, Group, Input, Value], input: Input): Form[Owner, Schema, Domain] when a server-side action
needs to replace one field with its typed editable input without rebuilding
FormDataclass FormDataAn ordered browser form payload that preserves duplicate textual fields. or validation state manually:
val updatedForm = profileForm.updated(
Profile.Name,
"Grace Hopper"
)The input type comes from the field's FieldInput: String for text,
Option[String] for optionalText, Vector[String] for texts, and Boolean
for checkbox. Use
updatedRaw(field, Vector(...)) only when raw browser values are explicitly
needed, such as malformed-control tests. The complete form is decoded again
after every update, while its interaction state is preserved.
For fields submitted repeatedly under one exact name, use
appendeddef appended[Value](field: FormField[Owner, Vector[String], Value], value: String): Form[Owner, Schema, Domain]Appends one raw value to a multi-value static field. to add one value and
removedAtdef removedAt[Value](field: FormField[Owner, Vector[String], Value], index: Int): Form[Owner, Schema, Domain]Removes a raw multi-value entry by position, failing when the index is out of bounds. to remove one by its position among that
field's values. These operations do not add or remove indexed nested rows whose controls use several
different names.
Phoenix normally preserves browser-managed state for a focused control during DOM patches. Add
phx.patchFocused := true to a control when a programmatic form update must replace its visible
value while it remains focused.
Handle LiveView Change And Submit Bindings
Bind both LiveView events through the rooted form:
form(
idAttr := "profile-form",
profileForm.onChange(Msg.Validate(_)),
profileForm.onSubmit(Msg.Save(_)),
// fields and actions
)onChange installs Phoenix's phx-change binding, not the native HTML onchange
attribute. The LiveView client observes both browser input and change events, so
text fields normally produce change messages while the user edits. Because the binding
is on the form, each message contains the form's successful controls and identifies the
changed control. See HTML and event bindings
for the distinction between LiveView bindings and native event attributes.
Both messages carry Profile.Definition.Event. event.form.result is either
owner-scoped accumulated FormErrorsclass FormErrors[Owner]An immutable ordered collection of owner-scoped form errors. or
the decoded Profile. event.kind distinguishes Changed, Submitted, and
Recovered; event.meta contains the logical target, optional raw submitter metadata, browser
target, metadata, and diagnostics. The standard Phoenix browser client includes a named submit
control in event.data rather than event.rawSubmitter; use a definition-owned typed submitter for
application action routing. Submitted events rebuild a form with all errors
visible, while change and recovery events show errors only for used fields.
Use RawFormEvent[A] only for an explicitly low-level
on.change.form(formCodec) or equivalent FormCodec binding; definition-backed
events should use Definition.Event and event.form.
Use event.meta.target when work should be narrowed to the logical field that
changed. It is a FormAddress, not an unchecked browser string:
object TargetedValidation:
enum ChangedField:
case Email, Confirmation, Unknown
def changedField(event: RegistrationForm.Definition.Event): ChangedField =
event.meta.target match
case Some(address) if address == RegistrationForm.Email.address => ChangedField.Email
case Some(address) if address == RegistrationForm.Confirmation.address =>
ChangedField.Confirmation
case _ => ChangedField.UnknownView source (documentation/site/src/scalive/docs/examples/FormRecipes.scala:127-136)
Choose When Feedback Appears
For signal-backed forms, formSignal.bind ties rendering and events to one stable,
instance-unique DomRef. Its default FormFeedback.WhenUsed policy follows Phoenix
unused markers. Choose FormFeedback.AfterBlur when an invalid field should remain
quiet until focus leaves it. 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.
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 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](Tuple1(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)
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. For a custom
scalar input, inputAttributes supplies its retained id, name, value, and blur
wiring. Calling .onBlur(value => Msg.NormalizePhone(value.trim)) composes an
application message after feedback dispatch, but command order does not wait for
server acknowledgement. It is therefore 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; this feedback API does
not provide a general value-reconciliation guarantee.
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.
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. A fresh mount starts
with no blur history. Applying a recovery update to retained server state preserves
its history; applying it to a freshly mounted form restores values but not old blur
history. Submission makes all feedback eligible until reset.
Within a LiveComponent, use the usual phx.target(self) on the form and on controls
with application-specific blur callbacks. Scalar .onBlur uses the browser's value,
or an empty string when absent; it is not the complete value vector of a multi-select.
Feedback-only operations (pristine and withAllErrorsVisible) preserve malformed
payload errors rather than silently dropping them through revalidation.
Phoenix _unused_* markers determine the used-field set on change and recovery.
Putting phx.noUnusedField := true on a form or control suppresses those markers,
so received fields are treated as used. Keep that switch exceptional; the
default gives field-local feedback before submit.
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.form.result match
case Right(profile) => save(profile).as(model.copy(
form = event.form,
saved = Some(profile)
))
case Left(_) => ZIO.succeed(model.copy(
form = event.form,
saved = None
))Route Multiple Submit Actions
Use one definition-owned FormSubmitterclass FormSubmitter[Owner, Schema, Action <: reflect.Enum]Definition-owned finite actions rendered and decoded through native form submit controls. when several
native submit buttons send the same typed form to different operations. Give each enum case an
explicit wire value so renaming a Scala case does not silently change the browser contract:
enum Intent(val wireValue: String):
case Discover extends Intent("discover")
case Preview extends Intent("preview")
case Save extends Intent("save")
val Submitter = Profile.Definition.submitter(Intent.values)(_.wireValue)The default browser name is generated below the form root in Scalive's reserved namespace. Supply a checked custom name only when an existing HTTP or Phoenix boundary requires one:
val CompatibleSubmitter =
Profile.Definition.submitter(Intent.values, wireName = "action")(_.wireValue)Bind the descriptor once and render each operation from the same mapping:
enum Msg:
case Validate(event: Profile.Definition.Event)
case Submit(
event: Profile.Definition.Event,
intent: Either[FormSubmitter.DecodeError, Intent]
)
form(
Profile.Definition.onChange(Msg.Validate(_)),
Profile.Definition.onSubmit(Submitter)(Msg.Submit.apply),
Submitter.button(Intent.Discover)("Discover"),
Submitter.button(Intent.Preview)("Preview"),
Submitter.button(Intent.Save)("Save")
)The binding decodes exactly one value from lossless FormData. Missing, unknown, and duplicate
values remain distinct DecodeError cases. There is no implicit default for an Enter-triggered or
programmatic submission without a named submitter. Every decoded action remains untrusted and must
be authorized before performing state-changing work.
The action result and event.form.result are deliberately independent. A discovery operation can
inspect the rebuilt field state even when the complete domain product is invalid, while Save can
require Right(profile). All buttons still trigger FormEventKind.Submitted, so all validation
errors become visible. Keep continuous validation on onChange; use an ordinary typed click message
for an operation that does not need the submitted form.
Use Submitter.attributes(intent) for a native submit input, a form-associated external control,
or other custom markup. The runnable typed profile form demonstrates
Preview and Save buttons through the same descriptor.
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. Storing event.form restores the projected values and
validation state produced by the same definition.
Use a dedicated typed recovery message when recovery needs different behavior:
form(
idAttr := "profile-form",
profileForm.onChange(Msg.Validate(_), Msg.Recover(_)),
profileForm.onSubmit(Msg.Save(_)),
// controls
)
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. Protocol details remain available in
event.meta without contaminating event.form.values.
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.
Model Repeated Rows
Use a repeated group when each row has several fields and a stable identity:
final case class Phone(label: String, number: String)
final case class Contact(name: String, phones: Vector[Phone])
object Contact:
val Root = FormRoot("contact")
val Name = Root.text("name").map(_.trim)
val Phones = Root.rows("phones")
val PhoneLabel = Phones.text("label").map(_.trim)
val PhoneNumber = Phones.text("number").map(_.trim).required(
FieldIssue("validation.phone.required", Some("required"))
)
val PhoneRows = Phones.product[Phone]((PhoneLabel, PhoneNumber))
val Definition = Root.product[Contact]((Name, PhoneRows))Initialize rows with PhoneRows.initial(PhoneRows.row(key)(...)). At render
time, contactForm.rows(Contact.PhoneRows) returns stable keyed row views. Each
core row must render its presence control, then can render bound field views:
contactForm.rows(Contact.PhoneRows).map { row =>
val number = row.field(Contact.PhoneNumber)
div(row.presence(), number.text(number.validationAttributes))
}Use added, removed, movedBefore, and movedAfter with a
FormRowKey[Contact.Phones.type] for server-side row operations. Use
row.bind(field) with typed updated or explicit updatedRaw for a field in
one exact row. Stable keys, rather than display indexes, preserve row identity,
used state, and error addresses across reordering.
In a signal-backed keyed projection, call row.presence() on the retained
Signal[FormRowView]. The stable repeated contact rows example
shows complete initialization, rendering, typed events, add, remove, both move
directions, row-local validation, submission, and reset. Its order summary makes
the persistent keys visible while rows move.
The core wire shape is keyed and includes the presence control. To accept
Phoenix's indexed nested-parameter convention, adapt that boundary explicitly.
For this recipe the per-row mapping is
contact[phones][0][_persistent_id], while the sibling array controls are
contact[phones_sort][] and contact[phones_drop][]:
object PhoenixPhoneForm:
final case class Phone(label: String, number: String)
final case class Contact(phones: Vector[Phone])
val Root = FormRoot("contact")
val Phones = Root.rows("phones")
val Label = Phones.text("label")
val Number = Phones.text("number")
val PhoneRows = Phones.product[Phone]((Label, Number))
val Definition = Root.product[Contact](Tuple1(PhoneRows))
val Adapter = PhoenixNestedParamsAdapter(Definition, PhoneRows)
enum Msg:
case Validate(event: Adapter.Event)
case Save(event: Adapter.Event)
def render(form: Definition.Form): HtmlElement[Msg] =
scalive.form(
Adapter.onChange(Msg.Validate(_)),
Adapter.onSubmit(Msg.Save(_)),
form.rows(PhoneRows).zipWithIndex.map { case (row, index) =>
val phoneLabel = row.field(Label)
val phoneNumber = row.field(Number)
fieldSet(
Adapter.persistentId(row, index),
Adapter.sortControl(row, index),
legend(s"Phone ${index + 1}"),
label(forId := Adapter.fieldId("contact-form", index, Label), "Phone label"),
input(
typ := "text",
idAttr := Adapter.fieldId("contact-form", index, Label),
nameAttr := Adapter.fieldName(index, Label),
value := phoneLabel.fieldValue
),
label(forId := Adapter.fieldId("contact-form", index, Number), "Phone number"),
input(
typ := "tel",
idAttr := Adapter.fieldId("contact-form", index, Number),
nameAttr := Adapter.fieldName(index, Number),
value := phoneNumber.fieldValue
),
button(
typ := "button",
nameAttr := Adapter.dropName,
value := index.toString,
aria.label := s"Remove phone ${index + 1}",
on.click(JS.dispatch("change")),
"Remove phone"
)
)
},
input(typ := "hidden", nameAttr := Adapter.dropName),
button(
typ := "button",
nameAttr := Adapter.sortName,
value := "new",
on.click(JS.dispatch("change")),
"Add phone"
),
button(typ := "submit", "Save contact")
)
end PhoenixPhoneFormView source (documentation/site/src/scalive/docs/examples/FormRecipes.scala:140-201)
The bounded PhoenixNestedParamsAdapter translates the compatibility payload
into the same keyed form projection. persistentIdName(index) reconnects each
positional index to a stable key, sortName supplies order and requests "new"
rows, and dropName removes indexes. Keep an empty dropName control so
removing the last row still submits the group. configured accepts
deterministic key allocation and blank-row policy when its defaults do not
match the application. Prefer the core keyed controls for new Scalive
interfaces; use the adapter when interoperating with Phoenix-style clients or
markup.
Render Richer Controls
Ask the form for a field view, then use its generated ID, name, current raw value, and validation helpers:
val emailField = profileForm.field(Profile.Email)
val messages = Map(
"validation.email.required" -> "Email is required.",
"validation.email.invalid" -> "Enter a valid email address."
)
label(forId := emailField.id, "Email")
emailField.email(emailField.validationAttributes)
emailField.errorFeedback { error =>
messages(error.message)
}FormFieldView, its signal-backed operations, and FormControl provide text,
email, tel, date, search, password, hidden, checkbox, textarea,
and select helpers. 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
)The native tel, date, and search helpers render retained raw scalar values;
they do not introduce parsing, trimming, or domain validation. Bound controls
keep their form-instance-scoped identity and blur wiring and reject overrides
of binding-owned attributes. Plain and signal-backed field views keep their
logical field identity and append caller modifiers without those binding
restrictions. On all three surfaces, ARIA feedback remains opt-in through
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. For a Boolean field, use
FieldInput.checkbox()def checkbox(checkedValue: String = ..., invalidIssue: FieldIssue = ..., duplicateIssue: FieldIssue = ...): FieldInput[Boolean]A strict checkbox codec: absence is false; exactly one checkedValue is true. to pair
strict decoding with typed initialization and updates:
object NotificationForm:
final case class Preferences(enabled: Boolean)
val Root = FormRoot("notifications")
val Enabled = Root.field("enabled", FieldInput.checkbox())
val Definition = Root.product[Preferences](Tuple1(Enabled))
val initial = Definition.initial(Enabled.initial(true))
enum Msg:
case Updated(update: Definition.Update)
case Submitted(event: Definition.Event)
def render(form: Signal[Definition.Form]): HtmlElement[Msg] =
val binding = form.bind(DomRef("notifications-form"), Msg.Updated(_), Msg.Submitted(_))
val enabled = binding.field(Enabled)
binding.render(
label(forId := enabled.id, "Enable notifications"),
enabled.checkbox(enabled.validationAttributes),
enabled.errorFeedback(error => error.map(_.message)),
button(typ := "submit", "Save")
)View source (documentation/site/src/scalive/docs/examples/FormRecipes.scala:69-89)
The codec decodes absence as false and exactly one "true" value as true.
Any other single value produces invalid_checkbox; multiple values produce
duplicate_value, even when identical. Encoding false omits the value, while
encoding true emits the checked token. Both issues are configurable through
invalidIssue and duplicateIssue.
Custom tokens must match on both sides: pair FieldInput.checkbox("yes") with
control.checkbox("yes"). Matching is exact, without trimming or case folding;
an explicitly configured empty token is distinct from absence. The default
codec rejects "on" and "false", and does not accept hidden unchecked fallback
inputs. In change and submit payloads, omitted or disabled controls decode as
false; the codec does not preserve their previous values. A checkbox-only repeated row
still needs its row.presence() control to retain the row when unchecked.
Requiring a checked value is a separate semantic rule, expressible with
field.validate(issue)(identity).
For repeated checkbox choices, use Root.texts, not the Boolean
FieldInput.checkbox codec. A bound FormControl provides
item(key: String): FormControlItem[Msg] so each checkbox has its own label
target while all items submit under the same field name:
object TopicForm:
final case class Preferences(topics: Vector[String])
val Root = FormRoot("subscriptions")
val Topics = Root.texts("topics")
val Definition = Root.product[Preferences](Tuple1(Topics))
enum Msg:
case Updated(update: Definition.Update)
case Submitted(event: Definition.Event)
def render(
form: Signal[Definition.Form],
researchToken: Signal[String]
): HtmlElement[Msg] =
val binding = form.bind(DomRef("subscriptions-form"), Msg.Updated(_), Msg.Submitted(_))
val topics = binding.field(Topics)
val news = topics.item("news-choice")
val research = topics.item("research-choice")
binding.render(
fieldSet(
legend("Topics"),
news.checkbox("news", topics.validationAttributes),
label(forId := news.id, "News"),
research.checkbox(researchToken, topics.validationAttributes),
label(forId := research.id, "Research"),
topics.errorFeedback(error => error.map(_.message))
),
button(typ := "submit", "Save")
)
end TopicFormView source (documentation/site/src/scalive/docs/examples/FormRecipes.scala:93-123)
The recipe uses a static "news" token and a Signal[String] research token;
both item.checkbox(checkedValue, mods*) overloads require an explicit token.
Each item's id and name are Signal[String]. Here every name is exactly
subscriptions[topics]: no item key, index, or extra [] is appended. A
submission with no successful items decodes to Vector.empty.
An item key is an immutable, stable identity, independent of its submitted
token; it is not a signal. Arbitrary strings, including the empty string, are
allowed; null is rejected. Keys are encoded losslessly into ASCII IDs scoped
to the form instance, field, and repeated-row identity when present. The exact
ID format is private: use item.id, not a reconstructed ID. Keep keys unique
among items of the same rendered control; the caller owns that responsibility.
Checked state uses exact membership of the current token in the parent's
rawValues. Items with different keys but the same token are checked together;
their submitted values do not distinguish them. Changing a reactive token updates
the checkbox projection but does not rewrite those raw values or migrate an old
selection. Native successful-control
rules still apply: unchecked, disabled, and removed checkboxes are omitted from
the next form snapshot. There are no hidden unchecked fallbacks or automatic
preservation of omitted selections.
Use a fieldset and legend for the group and a separate label targeting each
item's ID. Pass the parent's validationAttributes to each checkbox explicitly
and render one parent errorFeedback for the group. Configure any application
.onBlur(...) callback on the parent before calling .item(...). With
FormFeedback.AfterBlur, blurring any item makes the parent's feedback
eligible, including when focus moves to another item in the same group. This
is not whole-group blur; custom composite widgets still own that boundary.
For dynamic choices, use .splitBy with stable choice keys and create each item
inside the keyed rendering scope. Do not use changing tokens or display indexes
as identity. Choice keys distinguish checkboxes for one repeated-value field;
they are not form-row keys. Repeated form rows still use the row APIs and
row.presence() described above.
A select marks every option found in rawValues; for a multiple select, use a
repeated-value field such as Root.texts.
There are no current input rendering helpers for numeric, 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.
An unfocused native date input can receive an invalid value attribute from a
server update while exposing an empty DOM input.value. A later form snapshot
contains the browser's current value, not the original attribute, and can
replace the retained server value.
Use a text input when arbitrary malformed date text must remain editable and
visible.
A custom FieldInput defines both structural decoding and the inverse used by
typed server updates. The field view continues to render fieldValue from the
retained raw values rather than the decoded domain value. A text control can
therefore show malformed input even when decoding fails:
object QuantityForm:
final case class Order(quantity: Int)
val PositiveInteger = FieldInput[Int](
{
case Vector(value) =>
value.toIntOption
.filter(_ > 0).toRight(
FieldIssues.one(FieldIssue("validation.quantity.positive", Some("positive_integer")))
)
case Vector() =>
Left(FieldIssues.one(FieldIssue("validation.quantity.required", Some("required"))))
case _ =>
Left(
FieldIssues.one(FieldIssue("validation.quantity.duplicate", Some("duplicate_value")))
)
},
quantity => Vector(quantity.toString)
)
val Root = FormRoot("order")
val Quantity = Root.field("quantity", PositiveInteger)
val Definition = Root.product[Order](Tuple1(Quantity))
def quantityControl(form: Definition.Form): HtmlElement[Nothing] =
val quantity = form.field(Quantity)
div(
label(forId := quantity.id, "Quantity"),
input(
typ := "text",
idAttr := quantity.id,
nameAttr := quantity.name,
value := quantity.fieldValue,
quantity.validationAttributes
),
quantity.errorFeedback(error => error.message)
)
end QuantityFormView source (documentation/site/src/scalive/docs/examples/FormRecipes.scala:28-65)
Use the lower-level FormCodec only when a custom control or transport adapter
does not need definition-owned state, bounded projection, or interaction-aware
errors:
object RawPickerControl:
val Codec = FormCodec.requiredString("picker[value]")
enum Msg:
case Changed(event: RawFormEvent[String])
def render: HtmlElement[Msg] =
form(
on.change.form(Codec)(Msg.Changed(_)),
input(typ := "text", nameAttr := "picker[value]")
)View source (documentation/site/src/scalive/docs/examples/FormRecipes.scala:205-215)
validationAttributesdef validationAttributes: Vector[Mod.Attr[Nothing]]ARIA attributes linking the control to feedback and exposing visible invalidity.
connects the input to feedback and adds aria-invalid when visible errors
exist. errorFeedbackdef errorFeedback(render: FormError[Owner] => Mod[Nothing], mods: Mod.Input[Nothing]*): HtmlElement[Nothing]Renders only visibleErrors in an ARIA live feedback container.
renders a stable live region whose errors remain hidden until that field is
used or the form is submitted. Scalive owns the feedback ID, live-region
attributes, visibility rules, and form-errors and form-error wrappers. The
renderer owns only each error's presentation, so FormError.message may remain
a stable localization key instead of pre-rendered text.
The signal-backed field accepts the same pattern and passes a retained error signal to the renderer:
val emailField = profileFormSignal.field(Profile.Email)
emailField.errorFeedback { error =>
error.map(value => messages(value.message))
}Use a real label with forId and keep success feedback in a separate status
region.
Coordinate Dirty State And Saving
For simple screens, storing Definition.Form directly is enough. When a screen
also needs a baseline, dirty tracking, reset, and one in-flight save, wrap it in
the optional FormWorkflowclass FormWorkflow[Owner, Schema, Domain, Failure]Pure baseline, revision, reset, and one-in-flight-save state.:
final case class SaveError(message: String)
final case class Model(workflow: Profile.Definition.Workflow[SaveError])
val initialModel = Model(
Profile.Definition.workflow(Profile.Definition.initial())
)
def prepareSave(model: Model, event: Profile.Definition.Event) =
val changed = model.workflow.updated(event.form)
changed.beginSave match
case FormSaveStart.Invalid(next) =>
model.copy(workflow = next) -> None
case FormSaveStart.AlreadySaving(current, _) =>
model.copy(workflow = current) -> None
case FormSaveStart.Started(next, submission) =>
model.copy(workflow = next) -> Some(submission)
val (nextModel, submission) = prepareSave(model, event)
install(nextModel)
submission.foreach(enqueueSave)The state/effect boundary must install nextModel before dispatching
persistence, so even an immediate completion observes the matching Saving
workflow. The invalid branch renders next.current with all errors visible.
isDirty compares canonical FormValues. isSaving reports whether a save is
in flight; beginSave still enforces duplicate-submit rejection. When no save
is active, reset returns to the baseline.
Each save captures a valid value-and-values snapshot plus a token and revision.
Send the captured FormSubmissionToken through the persistence callback and
apply exactly one completion transition:
model.workflow.saveSucceeded(token) match
case FormWorkflowTransition.Applied(next) => model.copy(workflow = next)
case FormWorkflowTransition.Stale(_) => modelCarry the captured token unchanged through the persistence callback. A
completion carrying a token from another save attempt is stale. Run the effect with
ctx.async.start
and close over that token in the completion-message mapper.
Use saveFailed to retain the failed submission for retry diagnostics and
saveCancelled to return to idle without advancing the baseline. A stale token
is reported instead of overwriting current state. A successful save advances
the baseline and replaces the current form only when no newer revision exists;
edits made while saving stay current and remain dirty. reset returns
FormWorkflowReset.Saving(current, submission) instead of discarding an
in-flight operation, so cancellation policy remains an application decision.
Use failureForCurrentRevision when failure feedback should apply only to the
draft that was submitted:
val currentFailure: Option[SaveError] = model.workflow.failureForCurrentRevision
// An explicit dismiss action preserves the draft and its interaction state.
val dismissed = model.copy(workflow = model.workflow.dismissFailure)The query returns a failure only when the failed submission's revision equals
the current revision. Blur, feedback-only updates, and identical-value updates
retain relevance. Changing editable values hides the feedback but keeps the
failed submission in save for diagnostics. Editing away and back does not
revive the failure, because the revision has advanced.
A matching failure completion can be Applied after newer edits while
failureForCurrentRevision returns None: accepting a completion token is not
the same as deciding its feedback applies to the current draft. Applications
can still inspect save for failures whose display policy is not draft-specific.
dismissFailure clears the stored Failed state to Idle, preserving the
current form, baseline, revision, and submission-token correlation. It leaves
idle and saving workflows unchanged; it does not cancel persistence or reset
the form. Failure storage is the last retained failed attempt, not an audit log.
The form save workflow example provides deterministic controls for invalid and overlapping starts, success, failure, cancellation, revision-specific failure feedback and dismissal, edits during save, stale replay, blocked reset, and baseline advancement.
Reset Deliberately
For a model that stores Definition.Form directly, 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.
For a model that stores FormWorkflow, call workflow.reset and handle
FormWorkflowReset.Saving explicitly. Do not replace an active workflow with a
fresh one: that discards its in-flight correlation state and restarts submission
token generation.
Try change, invalid submit, valid submit, and reset behavior in the typed profile form example.
Continue Through Input
This guide is the typed-form step in the interface and input path:
HTML and event bindings for controls and browser events.
Typed forms and validation for Live-owned structured input.
Ordinary HTTP forms and redirects for browser POST and Post/Redirect/Get.
Testing for validation visibility, submission, and stable rows.
Related Tasks
Use Ordinary HTTP forms and redirects when the browser should perform a normal GET or POST.
Use Guard unsaved changes when leaving a dirty form should require confirmation.
Use File uploads for file inputs and resource ownership.
Use Authentication and sessions for credential forms and protected routes.
Use Testing to exercise changed-field visibility, invalid submission, stable rows, and reset behavior; keep automatic recovery assertions at the browser boundary.