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 re-renders 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.
const loginForm = useForm({
  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.

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 flags 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 { Field, Form, useForm } from '@formisch/preact';
import * as v from 'valibot';

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

export default function App() {
  const loginForm = useForm({
    schema: LoginSchema,
    validate: 'input',
  });

  return (
    <Form of={loginForm} onSubmit={(output) => console.log(output)}>
      <Field of={loginForm} path={['email']}>
        {(field) => (
          <div>
            <input {...field.props} value={field.input.value} type="email" />
            {(field.isEdited.value || loginForm.isSubmitted.value) &&
              field.errors.value && <div>{field.errors.value[0]}</div>}
          </div>
        )}
      </Field>
      <button type="submit">Login</button>
    </Form>
  );
}

isEdited is the right flag 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.

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 { Form, useForm } from '@formisch/preact';
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.')
  ),
});

export default function App() {
  const signUpForm = useForm({
    schema: SignUpSchema,
    validate: 'blur',
  });

  return (
    <Form of={signUpForm} onSubmit={(output) => console.log(output)}>
      {/* fields */}
      <button type="submit" disabled={signUpForm.isValidating}>
        {signUpForm.isValidating.value ? 'Checking...' : 'Sign up'}
      </button>
    </Form>
  );
}

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