Validation

Formisch validates your form against your Valibot schema — the same one that defines its types. This guide explains how validation runs under the hood, how the validate and revalidate config control its timing, how to display errors only when they are helpful, and how to react to asynchronous validation with isValidating.

How validation works

Whenever validation is triggered, Formisch parses the entire form against your schema in a single pass. It then distributes the resulting issues to the individual fields: every field with an issue receives its error messages, and every field without one is cleared. A field is valid when it has no errors.

Validation always runs against the whole schema, even when a single field triggers it. This keeps cross-field rules (like a password and confirmPassword that must match) correct without any extra wiring. The result is still stored per field, so each <input /> only updates when its own errors change.

Because Formisch parses with Valibot's asynchronous API, schemas with async checks (for example, a server-side uniqueness check) work out of the box. While such a check is in flight, the form's isValidating() state is true (see below).

When validation runs

Two config options control the timing of validation:

  • validate: when a field is validated for the first time. Defaults to 'submit'.
  • revalidate: when a field is validated again, once it already has an error or the form has been submitted. Defaults to 'input'.

Both accept the following modes:

  • 'initial': immediately, when the form is created (only valid for validate, not revalidate).
  • 'touch': when the field is first focused.
  • 'input': on every keystroke.
  • 'change': on the field's change event.
  • 'blur': when the field loses focus.
  • 'submit': only when the form is submitted.
readonly loginForm = injectForm({
  schema: LoginSchema,
  validate: 'blur',
  revalidate: 'input',
});

The split between validate and revalidate is what makes good defaults possible. With the default validate: 'submit' and revalidate: 'input', a field stays quiet until the user submits, and only then does it start correcting itself live as the user fixes it. The configuration above (validate: 'blur') is a gentler variant: a field is first checked when the user leaves it, then re-checked on every keystroke once it has an error.

The [formischControl] directive wires the input, change, focus and blur events of the element for you, so every mode works without extra bindings.

Showing errors at the right time

validate and revalidate control when validation runs — but not when errors are shown. After a validation pass, every invalid field has its errors() populated, and it is up to you to decide which ones to render.

Showing every error the moment it appears can overwhelm the user, while showing them only after submit forces them to scroll back and fix fields they have already filled out. A balanced approach is to reveal a field's error once the user has actually changed it, and to reveal all remaining errors after the first submit attempt.

Each field exposes several state signals to drive this decision:

  • isTouched(): the field has been focused or changed.
  • isEdited(): the field's value has been changed (but not merely focused), and stays true even if the value is changed back to its initial value.
  • isDirty(): the field's current value differs from its initial value.

The form additionally exposes isSubmitted(), which becomes true after the first submit attempt. Combining isEdited() with isSubmitted() gives the balanced behavior described above:

import { Component } from '@angular/core';
import {
  FormischControl,
  FormischField,
  FormischForm,
  injectForm,
} from '@formisch/angular';
import * as v from 'valibot';

const LoginSchema = v.object({
  email: v.pipe(v.string(), v.email()),
  password: v.pipe(v.string(), v.minLength(8)),
});

@Component({
  selector: 'app-login',
  imports: [FormischForm, FormischField, FormischControl],
  template: `
    <form [formischForm]="loginForm" [formischSubmit]="handleSubmit">
      <ng-container *formischField="['email'] of loginForm; let field">
        <input [formischControl]="field" type="email" />
        @if (field.errors(); as errors) {
          @if (field.isEdited() || loginForm.isSubmitted()) {
            <div>{{ errors[0] }}</div>
          }
        }
      </ng-container>
      <button type="submit">Login</button>
    </form>
  `,
})
export class LoginComponent {
  readonly loginForm = injectForm({
    schema: LoginSchema,
    validate: 'change',
  });

  readonly handleSubmit = (output: v.InferOutput<typeof LoginSchema>) => {
    console.log(output);
  };
}

isEdited() is the right signal for this. isTouched() would reveal the error as soon as the user tabs through a field without changing it, and isDirty() would hide the error again if the user clears an invalid value back to its initial state. isEdited() avoids both: it turns on only after a real change and stays on. Pair it with validate: 'input' (or 'change') so the error appears as soon as the field becomes invalid, and with isSubmitted() so that the errors of all fields — including the ones the user never touched — become visible after a submit attempt.

Reuse the guard across fields

Writing this guard at every field gets repetitive. The common fix is to move it into a small reusable component, either on its own or folded into the TextInputComponent from the input components guide, so the isEdited() || isSubmitted() logic lives in exactly one place:

import { ChangeDetectionStrategy, Component, input } from '@angular/core';

@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  selector: 'app-field-error',
  template: `
    @if (errors(); as errors) {
      @if (isEdited() || isSubmitted()) {
        <div>{{ errors[0] }}</div>
      }
    }
  `,
})
export class FieldErrorComponent {
  readonly errors = input.required<[string, ...string[]] | null>();
  readonly isEdited = input.required<boolean>();
  readonly isSubmitted = input.required<boolean>();
}

Add FieldErrorComponent to the imports array of the components that use it. Each field then stays focused on its input:

<ng-container *formischField="['email'] of loginForm; let field">
  <input [formischControl]="field" type="email" />
  <app-field-error
    [errors]="field.errors()"
    [isEdited]="field.isEdited()"
    [isSubmitted]="loginForm.isSubmitted()"
  />
</ng-container>

Validating state

When your schema contains asynchronous checks, validation does not resolve instantly. The form's isValidating() state is true while any validation is in flight, which you can use to show a loading indicator or to disable the submit button until validation settles.

import { Component } from '@angular/core';
import { FormischForm, injectForm } from '@formisch/angular';
import * as v from 'valibot';
import { isUsernameAvailable } from '../api';

const SignUpSchema = v.objectAsync({
  username: v.pipeAsync(
    v.string(),
    v.nonEmpty(),
    v.checkAsync(isUsernameAvailable, 'Username is already taken.')
  ),
});

@Component({
  selector: 'app-sign-up',
  imports: [FormischForm],
  template: `
    <form [formischForm]="signUpForm" [formischSubmit]="handleSubmit">
      <!-- fields -->
      <button type="submit" [disabled]="signUpForm.isValidating()">
        {{ signUpForm.isValidating() ? 'Checking...' : 'Sign up' }}
      </button>
    </form>
  `,
})
export class SignUpComponent {
  readonly signUpForm = injectForm({
    schema: SignUpSchema,
    validate: 'blur',
  });

  readonly handleSubmit = (output: v.InferOutput<typeof SignUpSchema>) => {
    console.log(output);
  };
}

isValidating() and isSubmitting() overlap but are not the same. isValidating() is true whenever validation runs — triggered by a field event or as part of a submit. isSubmitting() is true for the entire submission, which covers both validating the form and running your submit handler. So during a submit both are true while the form validates, and once validation passes only isSubmitting() stays true while your handler runs.

Further reading

To process your form's values once they pass validation, see the handle submission guide. For working with the fields a user has changed, see the dirty fields guide.

Contributors

Thanks to all the contributors who helped make this page better!

  • GitHub profile picture of @fabian-hiller

Partners

Thanks to our partners who support the project ideally and financially.

Sponsors

Thanks to our GitHub sponsors who support the project financially.

  • GitHub profile picture of @vasilii-kovalev
  • GitHub profile picture of @UpwayShop
  • GitHub profile picture of @ruiaraujo012
  • GitHub profile picture of @hyunbinseo
  • GitHub profile picture of @nickytonline
  • GitHub profile picture of @kibertoad
  • GitHub profile picture of @caegdeveloper
  • GitHub profile picture of @Thanaen
  • GitHub profile picture of @bmoyroud
  • GitHub profile picture of @ysknsid25
  • GitHub profile picture of @dslatkin