Advanced

Validation

Every form needs validation. Nestled Forms provides multiple approaches — from simple functions to Zod schemas to cross-field validation and async checks.


Simple validation

The validate function receives the field value and returns true for valid or a string error message for invalid.

FormFieldClass.text('username', {
  label: 'Username',
  required: true,
  validate: (value) => {
    if (value.length < 3) return 'Must be at least 3 characters'
    if (value.length > 20) return 'Must be 20 characters or less'
    if (!/^[a-zA-Z0-9_]+$/.test(value))
      return 'Only letters, numbers, and underscores'
    return true
  },
})

Required fields

Set required: true to enforce that the field has a value. Empty strings, null, and undefined are treated as empty.

FormFieldClass.email('email', {
  label: 'Email',
  required: true, // Shows "Required" error if empty
})

Custom required message

FormFieldClass.email('email', {
  label: 'Email',
  required: true,
  errorMessages: {
    required: 'Please provide your email address',
  },
})

Conditional required

FormFieldClass.text('companyName', {
  label: 'Company Name',
  requiredWhen: (formValues) => formValues.accountType === 'business',
})

Zod schema validation

Use Zod schemas for type-safe validation with rich error messages.

import { z } from 'zod'

FormFieldClass.text('email', {
  label: 'Email',
  schema: z.string().email('Invalid email format').min(5, 'Too short'),
})
FormFieldClass.number('age', {
  label: 'Age',
  schema: z.number().int().min(18, 'Must be 18+').max(120, 'Invalid age'),
})

Zod validation runs automatically when the field value changes or on blur, depending on your form configuration.


Cross-field validation

Use validateWithForm when a field's validity depends on other fields in the form.

FormFieldClass.password('confirmPassword', {
  label: 'Confirm Password',
  required: true,
  validateWithForm: (value, formValues) => {
    if (value !== formValues.password) {
      return 'Passwords do not match'
    }
    return true
  },
  validationDependencies: ['password'],
})

validationDependencies

When you use validateWithForm, specify validationDependencies to tell the form which other fields should trigger re-validation of this field.

FormFieldClass.datePicker('endDate', {
  label: 'End Date',
  validateWithForm: (value, formValues) => {
    if (
      formValues.startDate &&
      new Date(value) <= new Date(formValues.startDate)
    ) {
      return 'End date must be after start date'
    }
    return true
  },
  validationDependencies: ['startDate'], // Re-validate when startDate changes
})

Without validationDependencies, the field only validates when its own value changes. Adding dependencies ensures validation runs when related fields change too.


Async validation

The validate function can return a Promise for server-side checks.

FormFieldClass.text('username', {
  label: 'Username',
  required: true,
  validate: async (value) => {
    const response = await fetch(`/api/check-username?q=${value}`)
    const { available } = await response.json()
    return available || 'Username is already taken'
  },
})
FormFieldClass.email('email', {
  label: 'Email',
  required: true,
  validate: async (value) => {
    const exists = await checkEmailExists(value)
    return exists ? 'An account with this email already exists' : true
  },
})

Async validation is debounced to avoid excessive API calls.


Validation groups

Validation groups allow you to validate subsets of fields — useful for multi-step forms or wizard-style UIs.

const fields = [
  // Step 1 fields
  FormFieldClass.text('name', {
    label: 'Name',
    required: true,
    validationGroup: 'step1',
  }),
  FormFieldClass.email('email', {
    label: 'Email',
    required: true,
    validationGroup: 'step1',
  }),

  // Step 2 fields
  FormFieldClass.text('address', {
    label: 'Address',
    required: true,
    validationGroup: 'step2',
  }),
  FormFieldClass.text('city', {
    label: 'City',
    required: true,
    validationGroup: 'step2',
  }),

  // Step 3 fields
  FormFieldClass.checkbox('agreeToTerms', {
    label: 'I agree to the terms',
    validationGroup: 'step3',
    validate: (v) => v === true || 'Required',
  }),
]

Use validateGroup from the form context to validate a specific step:

import { useFormContext } from '@nestledjs/forms'

function StepNavigation({ currentStep, onNext }) {
  const { validateGroup } = useFormContext()

  const handleNext = async () => {
    const isValid = await validateGroup(`step${currentStep}`)
    if (isValid) onNext()
  }

  return <button onClick={handleNext}>Next</button>
}

Conditional validation

Use validateWhen to only run validation when a condition is met.

FormFieldClass.text('taxId', {
  label: 'Tax ID',
  validate: (value) =>
    /^\d{2}-\d{7}$/.test(value) || 'Invalid format (XX-XXXXXXX)',
  validateWhen: (formValues) => formValues.accountType === 'business',
})

When the condition is false, the field skips validation entirely — even if required is set.


Validation timing

Control when validation runs:

<Form
  id="my-form"
  fields={fields}
  submit={handleSubmit}
  validateOnBlur // Validate when field loses focus
  validateOnChange // Validate as user types
>
  <button type="submit">Submit</button>
</Form>
SettingBehavior
validateOnBlurValidates when the user leaves the field
validateOnChangeValidates on every keystroke/change
NeitherValidates only on form submission
BothValidates on change and on blur

Combining validation methods

You can use multiple validation approaches on the same field. They run in this order:

  1. required check
  2. schema (Zod) validation
  3. validate function
  4. validateWithForm cross-field validation

If any step fails, subsequent steps are skipped and the error is displayed immediately.

FormFieldClass.text('username', {
  label: 'Username',
  required: true,
  schema: z.string().min(3).max(20),
  validate: async (value) => {
    const available = await checkAvailability(value)
    return available || 'Username taken'
  },
  validateWithForm: (value, formValues) => {
    if (value === formValues.email) return 'Username cannot match email'
    return true
  },
})

Custom error messages

Override default error messages:

FormFieldClass.email('email', {
  label: 'Email',
  required: true,
  errorMessages: {
    required: 'We need your email to create your account',
  },
})

Programmatic errors

Set errors manually from the form context:

const { setError } = useFormContext()

// After a failed API call
setError('email', 'This email is already registered')
Previous
Field differences