Skip to main content

React Form Validation with Zod

Form validation is one of the clearest reasons to use FormEngine. Instead of spreading validation logic across React components, effects, and error state, you define rules in JSON and let the runtime execute them. Under the hood, FormEngine uses Zod for built-in validation behavior. If you want to validate forms without adding more component-level wiring, this page is the core reference.

How validation works in FormEngine

FormEngine validates fields from the schema:
  1. a field declares its rules in schema.validations
  2. validators run in the order they appear
  3. if a validator fails, FormEngine returns and displays the error
  4. the runtime handles error state and field updates for you
By default, validation runs automatically as users interact with a field.

Quick example

This field validates required input, email format, and maximum length:
Validated email field
{
  "key": "email",
  "type": "RsInput",
  "props": {
    "label": { "value": "Email address" },
    "helperText": { "value": "We use this for replies only" }
  },
  "schema": {
    "validations": [
      { "key": "required", "args": { "message": "Email is required" } },
      { "key": "email", "args": { "message": "Enter a valid email address" } },
      { "key": "max", "args": { "limit": 100, "message": "Email is too long" } }
    ]
  }
}
This is the main pattern to remember. Validation is attached to the field schema, not embedded in form-specific React handlers.

Real working example from the FormEngine examples

The booking form used in the Next.js, Remix, and Formik examples applies custom validators directly in the real schema:
Booking form validation from the examples
{
  "children": [
    {
      "key": "fullName",
      "type": "RsInput",
      "props": {
        "label": { "value": "Full name" }
      },
      "schema": {
        "validations": [
          { "key": "isFullName", "type": "custom" }
        ]
      }
    },
    {
      "key": "checkinDate",
      "type": "RsDatePicker",
      "props": {
        "label": { "value": "Check-in date" }
      },
      "schema": {
        "validations": [
          { "key": "dateInTheFuture", "type": "custom" }
        ]
      }
    },
    {
      "key": "guestCount",
      "type": "RsNumberFormat",
      "props": {
        "label": { "value": "Guest count" }
      },
      "schema": {
        "validations": [
          { "key": "checkGuestCount", "type": "custom" }
        ]
      }
    }
  ]
}
This is a useful real example because it shows a pattern teams actually use in production:
  • a business-specific text rule
  • a date rule
  • a numeric rule
all expressed in the same schema structure. The validation flow on a real rendered form looks like this: Validation example The screenshot above illustrates the same validation flow described in the schema examples on this page.

Built-in validators

FormEngine includes built-in validators grouped by the type of value being validated.

String validators

ValidatorPurposeExample args
requiredField must not be empty{ "message": "Required" }
nonEmptyAlias for required{ "message": "Cannot be empty" }
minMinimum character length{ "limit": 3, "message": "Too short" }
maxMaximum character length{ "limit": 100, "message": "Too long" }
lengthExact character count{ "length": 10 }
emailMust be a valid email{ "message": "Invalid email" }
urlMust be a valid URL{ "message": "Invalid URL" }
uuidMust be a valid UUID{ "message": "Invalid UUID" }
ipMust be a valid IP address{ "message": "Invalid IP" }
datetimeMust be a valid ISO datetime{ "message": "Invalid datetime" }
regexMust match a pattern{ "regex": "^[A-Z]", "message": "Must start with uppercase" }
includesMust contain a substring{ "value": "@company.com" }
startsWithMust start with a string{ "value": "https://" }
endsWithMust end with a string{ "value": ".com" }

Number validators

ValidatorPurposeExample args
requiredMust have a value{ "message": "Required" }
minMinimum value{ "limit": 0, "message": "Too small" }
maxMaximum value{ "limit": 999 }
lessThanStrictly less than a value{ "value": 100 }
moreThanStrictly greater than a value{ "value": 0 }
integerMust be a whole number{ "message": "Whole numbers only" }
multipleOfMust be a multiple of a value{ "value": 5 }

Date, array, and boolean validators

  • date fields support required, min, and max
  • array fields support required, nonEmpty, min, max, and length
  • boolean fields support required, truthy, and falsy
These cover most practical form needs before you reach for custom logic.

Custom validators

When built-in rules are not enough, add a custom validator with fnSource:
Custom validator
{
  "key": "custom",
  "args": {
    "fnSource": "const value = args[0]; if (value && /\\s/.test(value)) return 'No spaces allowed'; return true",
    "message": "Invalid value"
  }
}
Return true when the field is valid. Return a string when it is invalid. Use this when:
  • the rule is specific to your domain
  • built-in validators are not expressive enough
  • you need access to more form context
In practice, another common pattern is registering named custom validators from app code and referencing them from the schema, as the booking example does with keys like isFullName and dateInTheFuture.

Cross-field validation

For rules like “confirm password must match password”, use form.data inside a custom validator:
Password confirmation
{
  "key": "confirmPassword",
  "type": "RsInput",
  "schema": {
    "validations": [
      { "key": "required", "args": { "message": "Please confirm your password" } },
      {
        "key": "custom",
        "args": {
          "fnSource": "const confirm = args[0]; const password = form.data.password; return confirm === password ? true : 'Passwords do not match'"
        }
      }
    ]
  }
}
This is one of the places where the JSON-driven approach is especially useful. You can express field relationships without wiring that logic into multiple components.

Async validation

For server-backed checks such as uniqueness validation, use an async custom validator:
Async validation example
{
  "key": "custom",
  "args": {
    "fnSource": "const email = args[0]; const res = await fetch('/api/check-email?email=' + email); const data = await res.json(); return data.available ? true : 'Email already registered'"
  }
}
This is useful for:
  • email uniqueness checks
  • username availability
  • organization-specific policies
  • external validation APIs

Form-level validation on submit

Field validation is not the whole story. You can also validate the full form when the user clicks submit:
Submit button with validation
{
  "type": "RsButton",
  "key": "submitButton",
  "props": {
    "children": { "value": "Submit" }
  },
  "events": {
    "onClick": [
      { "name": "validate", "type": "common", "args": { "failOnError": true } },
      { "name": "onSubmit", "type": "custom" }
    ]
  }
}
When failOnError is true, FormEngine stops the action chain until the validation passes.

When to disable automatic validation

Validation runs automatically by default. If you want validation only on submit, set autoValidate to false:
Validation only on submit
{
  "key": "notes",
  "type": "RsTextArea",
  "schema": {
    "autoValidate": false,
    "validations": [
      { "key": "max", "args": { "limit": 500, "message": "Maximum 500 characters" } }
    ]
  }
}
This pattern is useful when real-time validation would feel noisy or distracting.

Validation in the Designer

If your team uses the Designer, validation rules can also be configured visually. That gives teams a path from schema-driven validation to visual editing without changing the underlying model. See Designer validation if your workflow depends on the builder.

FormEngine vs manual React validation

The main practical difference is where the validation logic lives:
ApproachWhere rules liveTypical cost
Manual React form codecomponent logic and handlersmore wiring as the form grows
FormEngineJSON schemaeasier reuse and less repetitive glue code
That difference matters most in:
  • dynamic forms
  • multi-step forms
  • reusable workflows
  • forms shared across teams or products

FAQ

Yes. Set autoValidate to false and trigger validation when the user submits the form.
Yes. Use a custom validator and read related values from form.data.
Usually not. Built-in validators cover many common cases. Custom validators are best for domain-specific rules.
Yes. If your team uses the Designer, validation rules can also be configured there.

Next steps

Add conditional logic

Combine validation with field visibility and branching behavior.

Handle form data

Read, update, and submit the validated form state.

Build a multi-step form

Apply validation across a longer workflow.

Try it in the Online Builder

Configure validation visually and inspect the schema.