Wit Form

Validation

Wit Form validates single fields, field arrays and the whole form, with plain functions, async checks or a schema (Zod, Valibot, ArkType and others). This guide covers each one and when to use it.

Validators are plain functions

Every validator follows one rule: return an error message when something is wrong, and null (or undefined) when it's fine.

const validateAge = (value?: number) =>
  value !== undefined && value < 18 ? 'You must be 18 or older' : null;

A validator can also be async and return a promise of the message, for checks that need the server. See Async validation.

If you already describe your data with a schema library, pass the schema instead. See Schema validation.

Field validation

Pass validate to useField:

const validateEmail = (value?: string) => {
  if (!value) return 'Required';
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return 'Enter a valid email';
  return null;
};

function EmailField() {
  const { fieldValue, setFieldValue, onBlur, error } = useField<string>({
    name: 'email',
    validate: validateEmail,
  });
  // ...render the input and `error`
}

When errors appear

The validator runs on every change, but by default error is only returned once the field is touched: after onBlur, after a submit, or after you call validateFields on it. Connect onBlur to your input, or errors only appear on submit.

To change when errors show, pass mode to useForm:

modeA field's error shows
'onTouched' (default)after the field is blurred, or after a submit
'onChange'as soon as the user changes the field
'onSubmit'only after the first submit (or validateFields call)
useForm({ mode: 'onSubmit', onSubmit });

The mode only decides when errors are shown. Validation still runs on every change, so formState.isValid is always up to date.

Define validators outside your component

validate is read once, when the field mounts. If you write it inline, later versions of the function are ignored. Keep validators at module level, or memoize them:

// ✅ Defined once, outside the component
const validateName = (value?: string) => (!value ? 'Required' : null);

function NameField() {
  useField({ name: 'name', validate: validateName });
}

Try it in the Field Rules example.

Rules that depend on other fields

To compare a field with another one, like "confirm password" with "password", list the other fields in depFields. Their current values arrive in the validator's second argument as other.values:

const validateConfirmation = (
  value?: string,
  other?: { values: { password?: string } }
) => (value !== other?.values.password ? 'Passwords do not match' : null);

useField({
  name: 'confirmPassword',
  depFields: ['password'],
  validate: validateConfirmation,
});

When password changes, confirmPassword re-validates automatically, even though the user didn't touch it.

depFields takes field names, or { name, ancestors } objects for fields inside a field array row.

During a submit, other.values contains every form value, not only the depFields.

See the Cross-field Rules example.

Rules that depend on props or state

Sometimes a rule depends on something outside the form, like the user's account balance passed in as a prop. Since validate is read only once, use validateCallback with useCallback:

function AmountField({ balance }: { balance: number }) {
  const validateCallback = useCallback(
    (value?: number) =>
      value !== undefined && value > balance ? 'Not enough funds' : null,
    [balance]
  );
  const field = useField<number>({ name: 'amount', validateCallback });
  // ...
}

Wit Form always uses the latest validateCallback. The new rule takes effect the next time the field validates: when its value changes, or on submit.

Keep the callback's dependencies stable. A new function on every render causes extra work.

See the Dynamic Rules example.

Async validation

Some checks need the server, like "is this username taken?". Make the validator async, or return a promise:

const validateUsername = async (value?: string) => {
  if (!value || value.length < 3) return null; // cheap checks first
  const available = await api.isUsernameAvailable(value);
  return available ? null : 'This username is taken';
};

function UsernameField() {
  const { fieldValue, setFieldValue, onBlur, error, isValidating } =
    useField<string>({
      name: 'username',
      validate: validateUsername,
      // Wait until typing pauses for 400 ms before validating
      debounceValidation: 400,
    });
  // ...render the input, `error`, and a spinner while `isValidating`
}
  • isValidating is true while the validator runs, and while a debounced validation is waiting.
  • debounceValidation (milliseconds) waits for typing to pause, so the server isn't asked about every keystroke.
  • Outdated results are ignored. If the user keeps typing and an older check finishes after a newer one, the older result is dropped.
  • A failing check blocks submission. If the promise rejects or the validator throws, the error's message (or "Validation failed") becomes the field's error, so a network failure can't let invalid data through.
  • Validators also run when the field mounts, so formState.isValid is accurate from the start. Return early for empty values to avoid a request on mount, as above.

Field array validators and the form-level validate can be async too.

Submitting with async validators

handleSubmit waits for pending async validators before deciding. While it waits, formState.isValidating is true. Use it to disable the submit button:

const { handleSubmit, formState } = useForm({ onSubmit });

<button disabled={formState.isSubmitting || formState.isValidating}>
  Save
</button>;

When no validator is async, submitting stays fully synchronous, as before.

See the Async Rules example.

Schema validation

If you describe your data with a schema, pass it as schema. Wit Form supports Standard Schema, the interface that Zod, Valibot, ArkType, Effect Schema, Yup (1.7 and newer) and others implement. There's nothing extra to install.

A schema for the whole form

import { z } from 'zod';

const signupSchema = z.object({
  name: z.string().min(1, 'Required'),
  address: z.object({ city: z.string().min(1, 'Required') }),
  items: z
    .array(z.object({ qty: z.number().min(1, 'At least 1') }))
    .min(1, 'Add an item'),
});

useForm({ schema: signupSchema, onSubmit });

Each issue the schema reports goes to the field at its path:

  • name and address.city go to those fields.
  • items[1].qty goes to the qty field in the second row of the items field array.
  • items itself (for "Add an item") goes to the field array, as its error.
  • An issue with no matching field, like a .refine across fields, becomes a form error: it's in formState.formErrors and passed to onError.

The schema runs again whenever a value changes, so errors clear as soon as they're fixed and formState.isValid stays accurate. Each field still shows its error according to the mode. Define the schema outside your component, so it isn't rebuilt on every render.

A form schema checks the values. onSubmit still receives the values as they are in the form, not the schema's parsed output, so transforms like z.coerce don't change what you submit.

See the Schema Rules example.

A schema for one field

useField and <Field> accept a schema for that field's value:

useField({ name: 'email', schema: z.email('Enter a valid email') });

When a field has both a schema and a validate, the schema runs first, and validate only runs once the schema passes. A field schema can be async.

A schema for a field array

useFieldArray accepts a schema for the list of rows, for rules like a minimum length:

useFieldArray({
  name: 'people',
  fieldNames: ['email'],
  schema: z.array(z.object({ email: z.string() })).min(1, 'Add a person'),
});

Field array validation

A field array can validate its rows as a whole, for rules like "add at least one item" or "no duplicate emails":

const validateItems = (rows: { email?: string }[]) => {
  if (rows.length === 0) return 'Add at least one person';
  const emails = rows.map((r) => r.email);
  if (new Set(emails).size !== emails.length) return 'Emails must be unique';
  return null;
};

const { error } = useFieldArray({
  name: 'people',
  fieldNames: ['email'],
  validate: validateItems,
});

The array's error comes from the hook's return value. It's checked whenever the rows change and on submit. Unlike field errors, it doesn't wait for a touch: it shows as soon as it's set. Fields inside the rows still validate themselves as usual.

An array validator reads every row, so the component that calls useFieldArray re-renders whenever any value in the array changes. On very large tables, leave validate out and check the rows in the form-level validate instead. See Performance.

Form-level validation

For rules that don't belong to a single field, pass validate to useForm. It receives all values and returns a list of messages:

useForm({
  validate: (values) => {
    const errors: string[] = [];
    if (values.checkOut <= values.checkIn)
      errors.push('Check-out must be after check-in.');
    if (values.adults + values.children > 6)
      errors.push('A room fits at most 6 guests.');
    return errors;
  },
  onSubmit,
  onError: (fieldErrors, formErrors) => {
    // formErrors is the list returned above
  },
});

Form-level validation runs only on submit, and it can be async. Its messages aren't tied to a field. They're passed to onError, and kept in formState.formErrors until the next submit, so you can show them in a banner:

const { formState } = useForm({ validate, onSubmit });

{
  formState.formErrors.length > 0 && <Banner>{formState.formErrors}</Banner>;
}

To change the form-level rule from a child component, use useSetFormProps.

See the Form-level Rules example.

Errors from the server

Some errors are only known after the server answers, like "this email is already registered". Show them with setError, from useForm or useFormContext:

const { handleSubmit, setError, setFormErrors } = useForm({
  onSubmit: async (values) => {
    const result = await api.signUp(values);
    if (result.emailTaken) {
      setError('email', 'This email is already registered');
      return false; // keep the user's edits
    }
    if (!result.ok) {
      setFormErrors(['Something went wrong. Try again.']);
      return false;
    }
  },
});
  • setError(name, message) shows the message on that field right away, whatever the mode. For a field in a row, pass { name, type: 'field', ancestors }. For a field array, pass { name, type: 'field-array' }.
  • The error stays until the field validates again, which happens when its value changes.
  • setFormErrors(messages) sets formState.formErrors.
  • clearErrors() clears every error and form error. clearErrors(['email']) clears only those fields.

What happens on submit

handleSubmit runs, in this order:

  1. Every mounted field's validator (and field schema), with all form values as other.values.
  2. Every field array's validator, and the validators of the fields in its rows.
  3. The form schema, if there is one.
  4. The form-level validate.

If any validator is async, it waits for all of them, with formState.isValidating set to true.

Every field that failed becomes touched, so its error shows. Then:

  • If there were any errors, onError(fieldErrors, formErrors, values) is called, and onSubmit is not. The first invalid field is focused, if you attached its ref.
  • Otherwise, onSubmit(values, extraInfos) is called.

formState tracks the result: submitCount goes up by one, isSubmitted becomes true, and isSubmitSuccessful says whether validation passed and onSubmit finished without failing.

fieldErrors is a list of objects describing each failing field:

{
  name: 'email',          // the field's name
  ancestors: [],          // the rows it belongs to, for fields in field arrays
  type: 'field',          // 'field' or 'field-array'
  error: 'Required',      // the message
}

Focusing the first invalid field

useField returns a ref. Attach it to the input, and when a submit fails, the first invalid field on the page is focused:

const { fieldValue, setFieldValue, error, ref } = useField({ name: 'email' });

<input ref={ref} value={fieldValue ?? ''} /* ... */ />;

Turn it off with useForm({ shouldFocusError: false }). To focus a field yourself, call setFocus(name) from useForm or useFormContext.

Validating without submitting

useForm gives you functions for validating on demand. validateAllFields and validateAllFieldsAsync are also available from useFormContext:

const { validateFields, validateFieldsAsync, validateAllFields } = useForm({
  onSubmit,
});

// Validate some fields, like the current step of a wizard
const errors = validateFields(['email', 'password']);
if (errors.length === 0) goToNextStep();

// The same, waiting for async validators
const asyncErrors = await validateFieldsAsync(['username']);

// Validate every field and field array, without submitting
const allErrors = validateAllFields();

All of them return the same error objects as onError and mark failing fields as touched. They also apply the form schema. None of them runs the form-level validate.

validateFields and validateAllFields return right away with the errors of sync validators. Async validators still run, and their errors show when they finish. Use validateFieldsAsync and validateAllFieldsAsync to wait for them.

validateFields accepts top-level names as strings. For a field inside a row, pass { name, type: 'field', ancestors }. A string that matches a top-level field array validates that array.

The Multi-step Wizard example uses validateFields to check each step.

Knowing whether the form is valid

formState, from useForm or useFormState, has the form's validation state:

const { formState } = useForm({ onSubmit });

<button disabled={!formState.isValid}>Save</button>;
  • isValid: no field or field array has an error, and the form schema passes. The form-level validate only runs on submit, so it isn't included.
  • isValidating: an async validator is running.
  • errors: every current field error, including ones not shown yet. Useful for an error summary.
  • formErrors: the form-level errors from the last submit.

A component only re-renders when a property it reads changes, so reading isValid doesn't re-render on every keystroke.

The <Field> shortcut

The <Field> component has a required prop that adds a "Required" check when you don't pass validate:

<Field name="email" required>
  <MyInput />
</Field>

On this page