---
title: "Form validation"
description: "A sign-up form in Firelight that checks its fields in plain F# on submit, shows each error beside its field with aria-describedby, and moves focus to the first one."
---

# Form validation

Build a sign-up form that checks its fields when it's submitted, shows each error beside its field, and moves focus to the first field that needs fixing.

- [MDN: Client-side form validation](https://developer.mozilla.org/en-US/docs/Learn_web_development/Extensions/Forms/Form_validation)

Press Sign up with both fields empty. Then type a name: its error clears as you type.

```fsharp
module Snippets.SignupForm

open System.Text.RegularExpressions
open Fable.Core
open Browser.Types
open Firelight
open type Firelight.Lit

type Signup = { Name: string; Email: string }

/// Each field that has a problem, with its message, in the form's order.
let validate (form: Signup) =
    [
        if form.Name.Trim() = "" then
            "name", "Enter your name."
        if not (Regex.IsMatch(form.Email.Trim(), @"^[^@\s]+@[^@\s]+\.[^@\s]+$")) then
            "email", "Enter an email address, such as ada@example.com."
    ]

let private field (id: string) (label: string) (kind: string) (value: string) (error: string option) setValue =
    let invalid = error |> Option.map (fun _ -> "true")
    let describedBy = error |> Option.map (fun _ -> id + "-error")

    let message =
        match error with
        | Some text -> html $"""<p class="error" id="{id}-error">{text}</p>"""
        | None -> nothing

    html
        $"""
    <label for={id}>{label}</label>
    <input id={id} type={kind} autocomplete={kind} .value={value} @input={Ev.value setValue}
        aria-invalid={ifDefined invalid} aria-describedby={ifDefined describedBy}>
    {message}"""

[<AttachMembers>]
type SignupForm() =
    inherit LitElement()

    static member properties =
        PropertyDeclarations.create [
            "form", PropertyDeclaration<Signup>(state = true)
            "tried", PropertyDeclaration<bool>(state = true)
            "thanks", PropertyDeclaration<string option>(state = true)
        ]

    static member styles =
        css
            $$"""
        form { display: grid; gap: 0.35rem; justify-items: start; }
        input { font: inherit; padding: 0.3rem 0.5rem; width: 16rem; max-width: 100%; }
        input[aria-invalid] { border: 2px solid var(--accent); }
        .error { margin: 0; color: var(--accent); }
        button { font: inherit; padding: 0.3rem 0.9rem; margin-top: 0.5rem; }
        p[role="status"] { margin: 0.5rem 0 0; }
        """

    member val form = { Name = ""; Email = "" } with get, set
    /// Whether the user has pressed Sign up: errors show from then on, and clear as they're fixed.
    member val tried = false with get, set
    member val thanks: string option = None with get, set

    member this.Submit(e: SubmitEvent) =
        e.preventDefault ()
        this.tried <- true

        match validate this.form with
        | [] ->
            // Send this.form to your server here.
            this.thanks <- Some $"Thanks, {this.form.Name.Trim()}. Check your inbox."
            this.form <- { Name = ""; Email = "" }
            this.tried <- false
        | (firstId, _) :: _ ->
            this.thanks <- None

            promise {
                let! _ = this.updateComplete
                this.query<HTMLInputElement> ("#" + firstId) |> Option.iter _.focus()
            }
            |> Promise.start

    override this.render() =
        let errors = if this.tried then Map(validate this.form) else Map.empty

        html
            $"""
        <form novalidate @submit={Ev.submit this.Submit}>
            {field "name" "Name" "name" this.form.Name (errors.TryFind "name") (fun v -> this.form <- { this.form with Name = v })}
            {field "email" "Email" "email" this.form.Email (errors.TryFind "email") (fun v -> this.form <- { this.form with Email = v })}
            <button>Sign up</button>
        </form>
        <p role="status">{match this.thanks with
                           | Some text -> html $"{text}"
                           | None -> nothing}</p>"""

defineElement<SignupForm> "my-signup-form"
```

```html
<my-signup-form></my-signup-form>
```

## How it works

- `validate` is plain F#: a record in, a list of field ids and messages out. It doesn't touch the
  DOM, so a unit test can call it, and an F# server can run the same checks.
- `Ev.submit` types the handler's event, and `e.preventDefault ()` stops the browser's own
  submission, which would reload the page.
- `novalidate` turns off the browser's validation bubbles, so every message comes from `validate`.
  `type="email"` and `autocomplete` still do their other jobs: the right keyboard on a phone, and
  autofill.
- Errors appear only after the first submit, so nobody is told off for a field they haven't
  reached yet. From then on, `render` checks the form again on every keystroke, so each error
  clears as soon as it's fixed.
- `aria-describedby` points each field at its message, so a screen reader reads the message with
  the field, and `aria-invalid` marks the field as wrong. `ifDefined` leaves both attributes out
  while the field is fine.
- After a failed submit, the handler waits for `updateComplete`, then focuses the first field with
  an error. Before that render, the error messages aren't on the page yet.
- The thanks message goes in a `role="status"` paragraph that is always there, so screen readers
  announce it when its text changes. Its hole holds `nothing` until then, which keeps the
  [prerendered](https://roboz0r.github.io/Firelight/guides/prerendering/) HTML and the first render in the browser the same.

## Keep the form and its fields together

A `<form>` only submits the controls in its own DOM tree. A form in the page doesn't see inputs
inside a component's shadow root, and a form inside a component doesn't see inputs in the page.
Keep the form and its fields in one template, as this component does. To build a control of your
own that a page's form submits, see [A custom form control](https://roboz0r.github.io/Firelight/cookbook/form-control/).

## Related

- [Templates: Typed event handlers](https://roboz0r.github.io/Firelight/guides/templates/#typed-event-handlers) for `Ev.submit` and
  `Ev.value`, and [Directives](https://roboz0r.github.io/Firelight/guides/templates/#directives) for `ifDefined`.
- [Lifecycle: Reach the DOM after it renders](https://roboz0r.github.io/Firelight/guides/lifecycle/#reach-the-dom-after-it-renders)
  for `updateComplete`.
