Skip to content

Forms & Validation ​

Tova provides two ways to handle forms in the browser: manual form handling with signals and the declarative form block. This page covers both approaches and explains when to use each.

Manual Forms with Signals ​

For simple forms, you can use signals and event handlers directly:

tova
browser {
  state name = ""
  state email = ""
  state error = ""

  fn handleSubmit() {
    if name == "" {
      error = "Name is required"
      return
    }
    result = server.createUser(name, email)
    match result {
      Ok(_) => navigate("/success")
      Err(e) => error = e.message
    }
  }

  component ContactForm() {
    <form on:submit={handleSubmit}>
      <input bind:value={name} placeholder="Name" />
      <input bind:value={email} placeholder="Email" />
      if error != "" {
        <p class="error">{error}</p>
      }
      <button type="submit">"Send"</button>
    </form>
  }
}

This works for simple cases, but as forms grow in complexity -- validation rules, touched state, error display, field groups, dynamic arrays -- the manual approach becomes unwieldy.

Declarative Forms with form Block ​

The form block gives you compiler-generated reactive forms with built-in validation:

tova
browser {
  component ContactForm() {
    form contact {
      field name: String = "" { required("Name is required") }
      field email: String = "" {
        required("Email is required")
        email("Invalid email format")
      }
      field message: String = "" {
        required("Message is required")
        minLength(10, "At least 10 characters")
      }

      on submit {
        server.sendMessage(contact.values)
      }
    }

    <form bind:form={contact}>
      <FormField field={contact.name}>
        <label>"Name"</label>
        <input />
        <ErrorMessage />
      </FormField>

      <FormField field={contact.email}>
        <label>"Email"</label>
        <input type="email" />
        <ErrorMessage />
      </FormField>

      <FormField field={contact.message}>
        <label>"Message"</label>
        <textarea />
        <ErrorMessage />
      </FormField>

      <button type="submit" disabled={!contact.isValid}>
        "Send"
      </button>
    </form>
  }
}

For full form block documentation, see Form Block.

bind:form Directive ​

The bind:form directive connects a <form> HTML element to a form controller. It wires the element's onSubmit event to the controller's submit() method:

tova-jsx
<form bind:form={myForm}>
  // form children
</form>

This is equivalent to:

tova-jsx
<form on:submit={fn(e) myForm.submit(e)}>
  // form children
</form>

The form controller's submit() method calls e.preventDefault(), validates all fields, marks them all as touched, and then runs the on submit block if validation passes.

FormField Compiler Transform ​

FormField is not a runtime component -- the compiler transforms it at compile time. It wraps its children in a <div class="form-field"> and auto-wires any child <input>, <select>, or <textarea> to the field's signals:

tova-jsx
<FormField field={form.email}>
  <label>"Email"</label>
  <input type="email" />
  <ErrorMessage />
</FormField>

The compiler generates code that:

  • Binds the input's value to field.value (reactive read)
  • Binds onInput to field.set(e.target.value)
  • Binds onBlur to field.blur() (marks as touched and validates)
  • Replaces <ErrorMessage /> with a conditional error display

Supported Input Elements ​

FormField auto-wires these elements:

ElementBinding
<input>value, onInput, onBlur
<input type="email">Same as above
<input type="password">Same as above
<input type="number">Same as above
<select>value, onInput, onBlur
<textarea>value, onInput, onBlur

Other children (labels, spans, divs) pass through unchanged.

ErrorMessage Compiler Transform ​

ErrorMessage renders a conditional <span class="form-error"> that shows validation errors. It has three modes:

Inside FormField ​

When nested inside <FormField>, it inherits the parent field automatically:

tova-jsx
<FormField field={form.email}>
  <input type="email" />
  <ErrorMessage />
</FormField>

Shows field.error when field.touched && field.error is truthy.

Standalone with Field ​

Attach to any field explicitly:

tova-jsx
<ErrorMessage field={form.email} />

Shows field.error when field.touched && field.error is truthy.

Form-Level Error ​

Display the form's submit error:

tova-jsx
<ErrorMessage form={myForm} />

Shows form.submitError when truthy. Use this for server errors returned during submit.

Styling Error Messages ​

All error messages render as <span class="form-error">. Style them with CSS:

tova
css {
  .form-error {
    color: #dc2626;
    font-size: 0.875rem;
    margin-top: 0.25rem;
  }

  .form-field {
    margin-bottom: 1rem;
  }
}

Two-Way Binding with bind:value ​

For forms that don't use the form block, standard two-way binding works with signals:

tova-jsx
browser {
  state searchQuery = ""

  <input bind:value={searchQuery} placeholder="Search..." />
  <p>"Searching for: {searchQuery}"</p>
}

Checkbox Binding ​

tova-jsx
state agreed = false
<input type="checkbox" bind:checked={agreed} />

Radio Group Binding ​

tova-jsx
state color = "red"
<input type="radio" name="color" value="red" bind:group={color} />
<input type="radio" name="color" value="blue" bind:group={color} />
<input type="radio" name="color" value="green" bind:group={color} />

Select Binding ​

tova-jsx
state country = ""
<select bind:value={country}>
  <option value="">"Select..."</option>
  <option value="us">"United States"</option>
  <option value="uk">"United Kingdom"</option>
</select>

See Directives for the full bind: reference.

Validation Patterns ​

Inline Validation Feedback ​

Show validation state with CSS classes:

tova
form login {
  field email: String = "" { required("Required"), email("Invalid") }
}

<div class={"field " ++ if login.email.touched && login.email.error { "invalid" } else { "" }}>
  <input
    bind:value={login.email.value}
    on:blur={fn() login.email.blur()}
    class:error={login.email.touched && login.email.error != None}
  />
  if login.email.touched && login.email.error {
    <span class="error">{login.email.error}</span>
  }
</div>

Submit Button State ​

Disable the submit button while the form is invalid or submitting:

tova-jsx
<button
  type="submit"
  disabled={!form.isValid || form.submitting}
>
  if form.submitting {
    "Submitting..."
  } else {
    "Submit"
  }
</button>

Server Error Display ​

Show server errors after a failed submit:

tova
form login {
  field email: String = "" { required("Required") }
  field password: String = "" { required("Required") }

  on submit {
    result = server.login(login.values)
    match result {
      Ok(token) => navigate("/dashboard")
      Err(e) => login.setError(e.message)
    }
  }
}

<form bind:form={login}>
  // fields...
  <ErrorMessage form={login} />
  <button type="submit">"Log In"</button>
</form>

Reset Form ​

tova-jsx
<button on:click={fn() myForm.reset()}>
  "Clear Form"
</button>

When to Use What ​

ScenarioApproach
Simple 1-2 field formManual signals + bind:value
Form with validationform block
Multi-step wizardform block with steps
Dynamic repeating fieldsform block with array
Full-stack validationform block with typed type
Search/filter inputsManual signals + bind:value
  • Form Block -- full form block syntax and features
  • Directives -- bind:value, bind:checked, bind:group, bind:form
  • Signals -- reactive state for manual forms
  • Components -- building form components
  • RPC Bridge -- server calls in submit handlers

Released under the MIT License.