Skip to main content
Last reviewed: April 2026.
New to FormEngine? It’s a React library that renders forms from JSON schemas — free, MIT-licensed. Start here →
FormEngine automatically validates fields as users interact with your form. When validation fails, you need to show error messages clearly so users understand what went wrong. This tutorial shows how to display errors, customize messages, handle multiple validations per field, and style error states.

What you’ll learn

  • How FormEngine displays errors automatically in your form
  • Customizing error messages for better UX
  • Handling multiple validation rules on one field
  • Reading errors programmatically via callbacks
  • Validating untouched fields before submit
  • Styling and positioning error messages
  • Common mistakes that hide or suppress errors

How errors display automatically

FormEngine renders error messages automatically near the field that failed validation. The key is setting the errorType property at the form level — this tells FormEngine which component renders errors. For example, if you’re using RSuite components, set "errorType": "RsErrorMessage". If you’re using Material UI, use "errorType": "MuiErrorWrapper". If you’re using Mantine, use "errorType": "MtErrorWrapper". Here’s a form with a required email field:
{
  "errorType": "RsErrorMessage",
  "form": [
    {
      "key": "email",
      "type": "RsInput",
      "props": {
        "label": { "value": "Email Address" },
        "placeholder": { "value": "Enter your email" }
      },
      "schema": {
        "validations": [
          {
            "key": "required",
            "args": {
              "message": "Email is required"
            }
          }
        ]
      }
    }
  ]
}
When you render this form in React:
import { FormViewer, IFormViewer } from '@react-form-builder/core'
import { view } from '@react-form-builder/components-rsuite'
import { useRef } from 'react'

function MyForm() {
  const viewerRef = useRef<IFormViewer>(null)

  const formSchema = {
    errorType: 'RsErrorMessage',
    form: [
      {
        key: 'email',
        type: 'RsInput',
        props: {
          label: { value: 'Email Address' },
          placeholder: { value: 'Enter your email' }
        },
        schema: {
          validations: [
            {
              key: 'required',
              args: { message: 'Email is required' }
            }
          ]
        }
      }
    ]
  }

  return (
    <FormViewer
      view={view}
      form={formSchema}
      viewerRef={viewerRef}
    />
  )
}

export default MyForm
What happens: The user focuses on the email field (or clicks away without entering anything). FormEngine detects the field is empty and required, then displays the error message “Email is required” below the field automatically. The error appears without you writing any code to show it.

Customizing error messages

Each validation rule can have a custom message. Define it in the args.message property of the validation:
{
  "key": "email",
  "type": "RsInput",
  "props": {
    "label": { "value": "Email Address" }
  },
  "schema": {
    "validations": [
      {
        "key": "required",
        "args": {
          "message": "Please enter your email address"
        }
      },
      {
        "key": "email",
        "args": {
          "message": "That email doesn't look right. Example: user@example.com"
        }
      },
      {
        "key": "maxLength",
        "args": {
          "limit": 100,
          "message": "Email is too long (max 100 characters)"
        }
      }
    ]
  }
}
What each validation key means:
  • "required" — field cannot be empty
  • "email" — field must be a valid email format
  • "maxLength" — field has a character limit (limit in args)
  • "minLength" — field must have minimum characters (limit in args)
  • "min" — number field has a minimum value (limit in args)
  • "max" — number field has a maximum value (limit in args)
  • "regex" — field must match a regular expression pattern (pattern in args)
If you don’t provide a message, FormEngine uses a default error text like “Field is required” or “Invalid email format”.

Multiple validations on one field

You can combine multiple validation rules in the validations array. FormEngine checks them in order and displays the first failing rule’s error message:
{
  "key": "password",
  "type": "RsInput",
  "props": {
    "label": { "value": "Password" },
    "type": { "value": "password" }
  },
  "schema": {
    "validations": [
      {
        "key": "required",
        "args": { "message": "Password is required" }
      },
      {
        "key": "minLength",
        "args": {
          "limit": 8,
          "message": "Password must be at least 8 characters"
        }
      },
      {
        "key": "regex",
        "args": {
          "pattern": "[A-Z]",
          "message": "Password must contain at least one uppercase letter"
        }
      }
    ]
  }
}
How this works:
  1. User enters password “abc” (less than 8 characters)
  2. Validation checks: required ✅ pass (it’s not empty)
  3. Validation checks: minLength ❌ fail — shows “Password must be at least 8 characters”
  4. Validation stops here — user doesn’t see the uppercase letter error yet
  5. User updates password to “Abcdefgh” (8 chars, has uppercase)
  6. Validation checks: required ✅ pass, minLength ✅ pass, regex ✅ pass
  7. No error shown — field is valid
This “first failing rule wins” behavior prevents error message overload and guides users step-by-step to valid input.

Reading errors programmatically

You can access validation errors in your React component via two methods:

Method 1: onFormDataChange callback

The onFormDataChange callback fires whenever a field changes. It receives an object with { data, errors }:
import { FormViewer, IFormData } from '@react-form-builder/core'
import { view } from '@react-form-builder/components-rsuite'
import { useCallback, useState } from 'react'

function MyForm() {
  const [currentErrors, setCurrentErrors] = useState<Record<string, string>>({})
  const [errorCount, setErrorCount] = useState(0)

  const handleFormChange = useCallback((formData: IFormData) => {
    const { data, errors } = formData
    setCurrentErrors(errors || {})
    setErrorCount(Object.keys(errors || {}).length)
    console.log('Form data:', data)
    console.log('Errors:', errors)
  }, [])

  const formSchema = {
    errorType: 'RsErrorMessage',
    form: [
      {
        key: 'email',
        type: 'RsInput',
        props: { label: { value: 'Email' } },
        schema: {
          validations: [
            { key: 'required', args: { message: 'Email is required' } },
            { key: 'email', args: { message: 'Invalid email' } }
          ]
        }
      },
      {
        key: 'name',
        type: 'RsInput',
        props: { label: { value: 'Name' } },
        schema: {
          validations: [
            { key: 'required', args: { message: 'Name is required' } }
          ]
        }
      }
    ]
  }

  return (
    <div>
      <FormViewer
        view={view}
        form={formSchema}
        onFormDataChange={handleFormChange}
      />
      <div style={{ marginTop: '20px', padding: '10px', backgroundColor: '#f5f5f5' }}>
        <p>Errors found: <strong>{errorCount}</strong></p>
        {Object.entries(currentErrors).map(([field, message]) => (
          <p key={field} style={{ color: 'red' }}>
            <strong>{field}:</strong> {message}
          </p>
        ))}
      </div>
    </div>
  )
}

export default MyForm
What errors contains: It’s a plain object where keys are field names (the key property from your form JSON) and values are error messages (strings). If a field has no error, it’s not in this object.
// Example: user entered invalid email and left name empty
errors = {
  email: 'Invalid email',
  name: 'Name is required'
}

// User fixes both fields
errors = {}  // empty = no errors

Method 2: getValidationResult() on submit

When users click Submit, you might want to validate all fields at once, including ones they haven’t touched yet. Use formData.getValidationResult():
import { FormViewer, IFormViewer, IFormData, ActionDefinition } from '@react-form-builder/core'
import { view } from '@react-form-builder/components-rsuite'
import { useRef } from 'react'

function MyForm() {
  const viewerRef = useRef<IFormViewer>(null)

  const customActions = {
    submitForm: ActionDefinition.functionalAction(async (e) => {
      // Get ALL validation errors (even on untouched fields)
      const allErrors = await e.form.getValidationResult()
      const hasErrors = allErrors && Object.keys(allErrors).length > 0

      if (hasErrors) {
        console.log('Form has errors, cannot submit:', allErrors)
        alert('Please fix errors before submitting')
        return
      }

      // Form is valid — send to server
      const response = await fetch('/api/submit', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(e.data)
      })

      if (!response.ok) {
        alert('Server error: ' + response.statusText)
        return
      }

      alert('Form submitted successfully!')
    })
  }

  const formSchema = {
    errorType: 'RsErrorMessage',
    form: [
      {
        key: 'email',
        type: 'RsInput',
        props: { label: { value: 'Email' } },
        schema: {
          validations: [
            { key: 'required', args: { message: 'Email is required' } },
            { key: 'email', args: { message: 'Invalid email' } }
          ]
        }
      },
      {
        key: 'name',
        type: 'RsInput',
        props: { label: { value: 'Name' } },
        schema: {
          validations: [
            { key: 'required', args: { message: 'Name is required' } }
          ]
        }
      },
      {
        key: 'submit',
        type: 'RsButton',
        props: { children: { value: 'Submit' } },
        events: {
          onClick: { name: 'submitForm', type: 'common' }
        }
      }
    ]
  }

  return (
    <FormViewer
      view={view}
      form={formSchema}
      viewerRef={viewerRef}
      actions={customActions}
    />
  )
}

export default MyForm
Key difference: By default, FormEngine validates only when the user interacts with a field (touches it). If a field is empty and untouched, no error is shown. But getValidationResult() returns errors for all fields, even ones the user never clicked on — this is useful before submission to ensure the entire form is valid.

Displaying error summaries

If you want to show all errors in one place (like an error alert at the top), combine onFormDataChange with a summary component:
import { FormViewer, IFormData } from '@react-form-builder/core'
import { view } from '@react-form-builder/components-rsuite'
import { useCallback, useState } from 'react'

function MyForm() {
  const [errors, setErrors] = useState<Record<string, string>>({})

  const handleFormChange = useCallback((formData: IFormData) => {
    setErrors(formData.errors || {})
  }, [])

  const formSchema = {
    errorType: 'RsErrorMessage',
    form: [
      {
        key: 'email',
        type: 'RsInput',
        props: { label: { value: 'Email' } },
        schema: {
          validations: [
            { key: 'required', args: { message: 'Email is required' } }
          ]
        }
      },
      {
        key: 'phone',
        type: 'RsInput',
        props: { label: { value: 'Phone' } },
        schema: {
          validations: [
            { key: 'required', args: { message: 'Phone is required' } }
          ]
        }
      }
    ]
  }

  const errorCount = Object.keys(errors).length

  return (
    <div>
      {errorCount > 0 && (
        <div style={{
          backgroundColor: '#fee',
          border: '1px solid #f88',
          padding: '16px',
          borderRadius: '4px',
          marginBottom: '20px'
        }}>
          <strong style={{ color: '#c33' }}>
            {errorCount} error{errorCount === 1 ? '' : 's'} found:
          </strong>
          <ul style={{ marginTop: '8px' }}>
            {Object.entries(errors).map(([field, message]) => (
              <li key={field} style={{ color: '#c33' }}>
                <strong>{field}:</strong> {message}
              </li>
            ))}
          </ul>
        </div>
      )}

      <FormViewer
        view={view}
        form={formSchema}
        onFormDataChange={handleFormChange}
      />
    </div>
  )
}

export default MyForm

Styling error messages with errorProps

You can pass custom CSS to error messages via the errorProps property at the form level:
{
  "errorType": "RsErrorMessage",
  "errorProps": {
    "style": {
      "color": "#d32f2f",
      "fontSize": "12px",
      "fontWeight": "bold",
      "marginTop": "4px"
    }
  },
  "form": [
    {
      "key": "email",
      "type": "RsInput",
      "props": {
        "label": { "value": "Email Address" }
      },
      "schema": {
        "validations": [
          {
            "key": "required",
            "args": { "message": "Email is required" }
          }
        ]
      }
    }
  ]
}
The errorProps applies the same style to all error messages in the form. For per-field styling, use component-specific error props or custom error components (see /core/styling).

Common mistakes that break error display

Mistake 1: Missing errorType

If you don’t set errorType, errors won’t display visually:
{
  "form": [
    {
      "key": "email",
      "type": "RsInput",
      "schema": {
        "validations": [
          { "key": "required" }
        ]
      }
    }
  ]
}
Problem: The validation runs, errors are tracked internally, but nothing shows on screen. Fix: Always set errorType at the top level:
{
  "errorType": "RsErrorMessage",
  "form": [ ... ]
}

Mistake 2: Typo in validation key

If you misspell the validation key, it won’t run:
{
  "key": "email",
  "schema": {
    "validations": [
      { "key": "requried" }  // TYPO — should be "required"
    ]
  }
}
Problem: No validation runs, no error shown. User submits invalid data. Fix: Double-check built-in validation keys: required, email, minLength, maxLength, min, max, regex.

Mistake 3: Not awaiting getValidationResult()

getValidationResult() is async — you must await it:
// WRONG — returns a Promise, not the actual errors
const errors = e.form.getValidationResult()
if (errors && Object.keys(errors).length > 0) { ... }

// CORRECT
const errors = await e.form.getValidationResult()
if (errors && Object.keys(errors).length > 0) { ... }

Mistake 4: Expecting errors on untouched fields

By default, FormEngine validates on field interaction (onBlur, onChange). Untouched fields don’t show errors until the user interacts:
// User opens form but hasn't clicked any fields yet
onFormDataChange({ data: {}, errors: {} })  // errors is empty

// User clicks email field and leaves it empty
onFormDataChange({ data: { email: '' }, errors: { email: 'Email is required' } })
If you need to validate untouched fields, use getValidationResult() which forces validation on all fields.

Next steps

Official resources

Last modified on April 16, 2026