Skip to content
scalive
Menu
ConnectingLiveReconnectingOffline
GitHub

Your first form

Define the form

A form starts as a definition of its fields and the value they decode to. This lesson builds a sign-up form with a name and an email address. Create app/src/quickstart/Signup.scala:

Source
scala
package quickstart

import scalive.*

final case class Signup(name: String, email: String)

object Signup:
  val Root = FormRoot("signup")

  val Name = Root
    .text("name")
    .map(_.trim)
    .required(FieldIssue("Enter your name."))

  val Email = Root
    .text("email")
    .map(_.trim)
    .required(FieldIssue("Enter your email address."))
    .validate(FieldIssue("Enter an email address such as ada@example.com."))(_.contains("@"))

  val Definition = Root.product[Signup]((Name, Email))

FormRoot("signup") names the form, and each field's HTML name is nested under it, such as signup[name]. Each field reads text, trims it, and checks it. required rejects an empty value and validate rejects a value that fails its predicate. The FieldIssue text is the message shown to the visitor. product combines the fields into Signup.Definition, which decodes a complete Signup only when every field is valid.

The definition is plain data. It does not know about any LiveView, so tests and other pages can use it too.

Keep the form in the model

The LiveView keeps the form's current state in its model, next to what happened last:

Source
scala
final case class Model(form: Signup.Definition.Form, signedUp: Option[Signup])

enum Msg:
  case Changed(event: Signup.Definition.Event)
  case Submitted(event: Signup.Definition.Event)

Signup.Definition.Form holds the raw text the visitor typed, the current errors, and which fields the visitor has used. Its Event is what the browser sends when the form changes or is submitted. mount starts from an empty form:

Source
scala
def mount(ctx: MountContext): Task[Model] =
  ZIO.succeed(Model(form = Signup.Definition.initial(), signedUp = None))

Validate as the visitor types

Signup.Definition.onChange(Msg.Changed(_)) in the view sends Msg.Changed each time an input changes. The event already contains the updated, validated form, so the handler only stores it:

Source
scala
case Msg.Changed(event) =>
  ZIO.succeed(model.copy(form = event.form))

Storing event.form is what keeps the inputs and their errors in step with the browser. Without it, the next render would show the previous form.

Handle the submission

Signup.Definition.onSubmit(Msg.Submitted(_)) sends Msg.Submitted when the visitor submits. event.form.result is Right(signup) when every field is valid, and Left(issues) otherwise:

Source
scala
case Msg.Submitted(event) =>
  event.form.result match
    case Right(signup) =>
      ZIO.succeed(Model(form = Signup.Definition.initial(), signedUp = Some(signup)))
    case Left(_) =>
      ZIO.succeed(model.copy(form = event.form))

On success, the handler records the Signup and resets the form by building a fresh Signup.Definition.initial(). On failure, it stores the submitted form. A submitted form marks every field as used, so all of its errors appear at once.

A real application would save the sign-up through a service before resetting the form. Loading data and live updates shows how a LiveView gets a service.

Render fields and errors

The view reads each field from a signal of the form:

Source
scala
def view(model: Signal[Model]): HtmlElement[Msg] =
  val signupForm = model.map(_.form)
  val name       = signupForm.field(Signup.Name)
  val email      = signupForm.field(Signup.Email)

  mainTag(
    h1("Sign up for updates"),
    form(
      idAttr := "signup-form",
      Signup.Definition.onChange(Msg.Changed(_)),
      Signup.Definition.onSubmit(Msg.Submitted(_)),
      label(forId := name.id, "Name"),
      name.text(name.validationAttributes),
      name.errorFeedback(error => error.map(_.message)),
      label(forId := email.id, "Email"),
      email.email(email.validationAttributes),
      email.errorFeedback(error => error.map(_.message)),
      button(typ := "submit", "Sign up")
    ),
    model.map(_.signedUp).option { signup =>
      p(role := "status", signup.map(signup => s"Thanks, ${signup.name}. Check ${signup.email}."))
    }
  )

signupForm.field(Signup.Name) gives the field's id, name, current value, and errors. name.text(...) renders a text input with all of them, and email.email(...) an email input. validationAttributes adds aria-invalid and links the input to its error list for screen readers. errorFeedback renders the field's visible errors, one per issue.

An error becomes visible once the visitor has used its field or submitted the form. This keeps a fresh form from opening covered in "Enter your name."

Try it

The complete app/src/quickstart/SignupLiveView.scala:

Source
scala
package quickstart

import zio.{Task, ZIO}

import scalive.*

final class SignupLiveView extends LiveView[SignupLiveView.Msg, SignupLiveView.Model]:
  import SignupLiveView.*

  def mount(ctx: MountContext): Task[Model] =
    ZIO.succeed(Model(form = Signup.Definition.initial(), signedUp = None))

  def handleMessage(model: Model, ctx: MessageContext) =
    case Msg.Changed(event) =>
      ZIO.succeed(model.copy(form = event.form))
    case Msg.Submitted(event) =>
      event.form.result match
        case Right(signup) =>
          ZIO.succeed(Model(form = Signup.Definition.initial(), signedUp = Some(signup)))
        case Left(_) =>
          ZIO.succeed(model.copy(form = event.form))

  def view(model: Signal[Model]): HtmlElement[Msg] =
    val signupForm = model.map(_.form)
    val name       = signupForm.field(Signup.Name)
    val email      = signupForm.field(Signup.Email)

    mainTag(
      h1("Sign up for updates"),
      form(
        idAttr := "signup-form",
        Signup.Definition.onChange(Msg.Changed(_)),
        Signup.Definition.onSubmit(Msg.Submitted(_)),
        label(forId := name.id, "Name"),
        name.text(name.validationAttributes),
        name.errorFeedback(error => error.map(_.message)),
        label(forId := email.id, "Email"),
        email.email(email.validationAttributes),
        email.errorFeedback(error => error.map(_.message)),
        button(typ := "submit", "Sign up")
      ),
      model.map(_.signedUp).option { signup =>
        p(role := "status", signup.map(signup => s"Thanks, ${signup.name}. Check ${signup.email}."))
      }
    )
end SignupLiveView

object SignupLiveView:
  final case class Model(form: Signup.Definition.Form, signedUp: Option[Signup])

  enum Msg:
    case Changed(event: Signup.Definition.Event)
    case Submitted(event: Signup.Definition.Event)

Add its route to Routes.scala:

Source
scala
val signup = live / "signup"

Register it in Main.scala:

Source
scala
Routes.signup -> SignupLiveView(),

Run mill app.run and open http://localhost:8080/signup:

  1. Type ada in Email. The email error appears; the name field shows none because you have not used it.

  2. Select Sign up. Both errors appear.

  3. Fill in a name and a valid email, then select Sign up again. The page thanks you and the form is empty again.

The typed forms guide covers initial values, other input types, repeated rows, and saving in the background. Next, Loading data and live updates gives a page a service.