Skip to main content
New to FormEngine? It’s a React library that renders forms from JSON schemas — free, MIT-licensed. Start here →
This tutorial walks through building a fully functional dynamic form in React where the form structure is defined in JSON, not JSX. By the end you’ll have a working form with validation, a conditional field, and a submit handler — in about 50 lines of application code. What you’ll build: a contact form with name, email, a conditional “company” field that appears when the user selects “business” contact type, and a submit button. Prerequisites: Basic React knowledge, Node.js installed, ~15 minutes.

1. Install FormEngine

Create a new Vite app (or use your existing React project):
npm create vite@latest my-form-app -- --template react-ts
cd my-form-app
npm install
Install FormEngine Core and the Material UI component adapter:
npm install @react-form-builder/core @react-form-builder/components-material-ui
npm install @mui/material @emotion/react @emotion/styled

2. Define the form schema

Create src/contactForm.json. This JSON object fully describes the form — fields, validation rules, conditional logic, and layout:
src/contactForm.json
{
  "errorType": "MuiErrorWrapper",
  "tooltipType": "MuiTooltip",
  "form": {
    "key": "Screen",
    "type": "Screen",
    "children": [
      {
        "key": "contactType",
        "type": "MuiSelect",
        "props": {
          "label": { "value": "Contact type" },
          "options": {
            "value": [
              { "value": "personal", "label": "Personal" },
              { "value": "business", "label": "Business" }
            ]
          }
        },
        "schema": {
          "validations": [{ "key": "required" }]
        }
      },
      {
        "key": "name",
        "type": "MuiTextField",
        "props": {
          "label": { "value": "Full name" }
        },
        "schema": {
          "validations": [
            { "key": "required" },
            { "key": "min", "args": { "limit": 2 } }
          ]
        }
      },
      {
        "key": "email",
        "type": "MuiTextField",
        "props": {
          "label": { "value": "Email address" }
        },
        "schema": {
          "validations": [
            { "key": "required" },
            { "key": "email" }
          ]
        }
      },
      {
        "key": "company",
        "type": "MuiTextField",
        "props": {
          "label": { "value": "Company name" }
        },
        "renderWhen": {
          "value": "form.data.contactType === 'business'"
        },
        "schema": {
          "validations": [{ "key": "required" }]
        }
      },
      {
        "key": "submitButton",
        "type": "MuiButton",
        "props": {
          "children": { "value": "Submit" },
          "variant": { "value": "contained" }
        },
        "events": {
          "onClick": [
            { "name": "validate", "type": "common", "args": { "failOnError": true } },
            { "name": "onSubmit", "type": "custom" }
          ]
        }
      }
    ]
  }
}
Key points:
  • errorType: "MuiErrorWrapper" tells FormEngine to use Material UI’s error display component
  • Each field has a unique key — this key is used in the form data object
  • schema.validations is an array of Zod-backed validation rules
  • renderWhen is a JavaScript expression evaluated against the live form data — the “company” field only renders when contactType is "business"
  • The submit button’s onClick event runs validate first (stops if invalid), then calls our custom onSubmit action

3. Render the form

Replace src/App.tsx with:
src/App.tsx

export default function App() {
  const getForm = useCallback(() => JSON.stringify(formSchema), [])

  const actions = useMemo(() => ({
    onSubmit: (event: any) => {
      console.log('Form data:', event.data)
      alert('Submitted: ' + JSON.stringify(event.data, null, 2))
    }
  }), [])

  return (
    <div style={{ maxWidth: 480, margin: '40px auto', padding: '0 16px' }}>
      <h1>Contact us</h1>
      <FormViewer
        view={muiView}
        getForm={getForm}
        actions={actions}
      />
    </div>
  )
}
Run the app:
npm run dev
Open http://localhost:5173. You should see the form with the three visible fields. Select “Business” from the dropdown — the “Company name” field appears. Try submitting without filling in required fields — validation errors appear inline.

4. How validation works

FormEngine validation rules live in the JSON schema, not in your React code. The available built-in rules are backed by Zod:
Rule keyWhat it validatesExample args
requiredField must have a value{ message: "Required" }
emailMust be a valid email{ message: "Invalid email" }
minMinimum string length or number value{ limit: 2 }
maxMaximum string length or number value{ limit: 100 }
urlMust be a valid URL
patternMust match a regex{ regex: "^[A-Z]" }
Custom validators can also be registered — see Validation.

5. How conditional fields work

The renderWhen property accepts a JavaScript expression string that’s evaluated against the live form state:
"renderWhen": {
  "value": "form.data.contactType === 'business'"
}
form.data is the current form data object — a plain JS object where each key matches a field’s key value. The expression is re-evaluated on every form change. When the expression returns false, the field is hidden and its value is excluded from form data. You can write any JavaScript expression:
// Show only when age >= 18
"renderWhen": { "value": "parseInt(form.data.age) >= 18" }

// Show when either of two fields has a value
"renderWhen": { "value": "form.data.fieldA || form.data.fieldB" }

6. Handle form submission with an API call

Replace the alert in the onSubmit action with a real API call:
const actions = useMemo(() => ({
  onSubmit: async (event: any) => {
    try {
      const response = await fetch('/api/contact', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(event.data),
      })

      if (!response.ok) throw new Error('Submission failed')

      // Reset or redirect after success
      alert('Message sent successfully!')
    } catch (err) {
      console.error(err)
      alert('Something went wrong. Please try again.')
    }
  }
}), [])
The event.data object contains the current form values, keyed by field key:
{
  "contactType": "business",
  "name": "Jane Smith",
  "email": "jane@acme.com",
  "company": "Acme Corp"
}

7. Load the schema from an API

One of FormEngine’s main advantages is that the form schema doesn’t have to be a static file. You can fetch it from an API, a database, or any other source:

export default function DynamicForm({ formId }: { formId: string }) {
  const [schema, setSchema] = useState<string | null>(null)

  useEffect(() => {
    fetch(`/api/forms/${formId}`)
      .then(res => res.json())
      .then(data => setSchema(JSON.stringify(data)))
  }, [formId])

  const getForm = useCallback(() => schema!, [schema])

  if (!schema) return <p>Loading form…</p>

  return <FormViewer view={muiView} getForm={getForm} />
}
This pattern is what makes FormEngine suitable for applications where forms need to change without a code deployment — you update the schema in your database, and the next page load renders the updated form.

Full example on GitHub

The complete working code for this tutorial is available in the FormEngine repository examples.

Next steps

Last modified on April 16, 2026