Skip to main content
New to FormEngine? It’s a React library that renders forms from JSON schemas — free, MIT-licensed. Start here →
TL;DR: React Hook Form manages form state in React component code; FormEngine manages it in a JSON schema. The migration involves moving field definitions, validation rules, and submit logic out of JSX and into a JSON object — then replacing useForm + JSX with a single <FormViewer> component. Last reviewed: April 2026. This guide maps React Hook Form concepts directly to FormEngine equivalents, with before/after code for the most common patterns. It’s written for incremental migration — you can move one form at a time. FormEngine Core is open-source on GitHub. For the full comparison of architectures, see FormEngine vs React Hook Form.

Why teams migrate

React Hook Form is an excellent library for code-defined forms. Teams typically migrate to FormEngine when:
  • Forms need to be configurable at runtime without a deploy (schemas stored in a database)
  • Non-developers need to edit forms via a visual designer
  • Multiple apps share the same form definitions
  • Forms require complex conditional logic that’s tedious to maintain in JSX
If your forms are stable, JSX-defined, and developer-only, React Hook Form may be the better fit. This guide is for teams where the above applies.

Concept mapping

React Hook FormFormEngine
useForm()<FormViewer form={schema}>
register('fieldName')Component entry in JSON schema
handleSubmit(fn)onActionEventAsync with submit event
ControllerAny component type in schema
formState.errorsform.errors in expressions
watch('field')form.data.field in renderWhen
useFieldArrayRepeater component in schema
setValuesetValues action
Yup/Zod resolverBuilt-in validators or custom Zod validator
defaultValuesdefaultValue prop on components

Before/after: basic form

React Hook Form:

type ContactForm = {
  name: string
  email: string
  message: string
}

function ContactForm() {
  const { register, handleSubmit, formState: { errors } } = useForm<ContactForm>()

  const onSubmit = async (data: ContactForm) => {
    await fetch('/api/contact', {
      method: 'POST',
      body: JSON.stringify(data),
    })
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('name', { required: 'Name is required' })} />
      {errors.name && <span>{errors.name.message}</span>}

      <input {...register('email', {
        required: 'Email is required',
        pattern: { value: /\S+@\S+\.\S+/, message: 'Invalid email' }
      })} />
      {errors.email && <span>{errors.email.message}</span>}

      <textarea {...register('message', { required: 'Message is required' })} />
      {errors.message && <span>{errors.message.message}</span>}

      <button type="submit">Send</button>
    </form>
  )
}
FormEngine:
{
  "version": "1",
  "form": {
    "name": "ContactForm",
    "type": "form",
    "components": [
      {
        "name": "name",
        "type": "Input",
        "props": { "label": "Name" },
        "validations": [
          { "type": "required", "message": "Name is required" }
        ]
      },
      {
        "name": "email",
        "type": "Input",
        "props": { "label": "Email" },
        "validations": [
          { "type": "required", "message": "Email is required" },
          { "type": "email", "message": "Invalid email" }
        ]
      },
      {
        "name": "message",
        "type": "Textarea",
        "props": { "label": "Message" },
        "validations": [
          { "type": "required", "message": "Message is required" }
        ]
      },
      {
        "name": "submit",
        "type": "Button",
        "props": { "label": "Send" },
        "actions": [{ "event": "onClick", "action": "submit" }]
      }
    ]
  }
}

function ContactForm() {
  return (
    <FormViewer
      view={view}
      form={JSON.stringify(schema)}
      onActionEventAsync={async (event) => {
        if (event.name === 'submit') {
          await fetch('/api/contact', {
            method: 'POST',
            body: JSON.stringify(event.args.formData),
          })
        }
      }}
    />
  )
}

Before/after: validation with Zod

React Hook Form + Zod resolver:

const schema = z.object({
  password: z.string().min(8, 'At least 8 characters').regex(/[A-Z]/, 'Needs uppercase'),
  confirmPassword: z.string(),
}).refine(data => data.password === data.confirmPassword, {
  message: 'Passwords must match',
  path: ['confirmPassword'],
})

function SignupForm() {
  const { register, handleSubmit, formState: { errors } } = useForm({
    resolver: zodResolver(schema)
  })
  // ...
}
FormEngine + Zod custom validators:

const passwordSchema = z.string().min(8, 'At least 8 characters').regex(/[A-Z]/, 'Needs uppercase')

export const validators: FormViewerProps['validators'] = {
  string: {
    strongPassword: {
      validate: (value) => {
        if (!value) return true
        const r = passwordSchema.safeParse(value)
        return r.success ? true : r.error.issues[0].message
      }
    },
    matchesPassword: {
      validate: (value, _store, _args, formData) => {
        const password = formData?.data?.password as string
        return value === password ? true : 'Passwords must match'
      }
    }
  }
}
JSON schema:
{
  "name": "password",
  "type": "Input",
  "props": { "label": "Password", "type": "password" },
  "validations": [
    { "type": "required" },
    { "type": "strongPassword", "kind": "custom" }
  ]
},
{
  "name": "confirmPassword",
  "type": "Input",
  "props": { "label": "Confirm password", "type": "password" },
  "validations": [
    { "type": "required" },
    { "type": "matchesPassword", "kind": "custom" }
  ]
}
See Validation with Zod tutorial for the full pattern.

Before/after: conditional fields

React Hook Form:
const { watch } = useForm()
const accountType = watch('accountType')

return (
  <form>
    <select {...register('accountType')}>
      <option value="personal">Personal</option>
      <option value="business">Business</option>
    </select>

    {accountType === 'business' && (
      <input {...register('companyName', { required: 'Required for business accounts' })} />
    )}
  </form>
)
FormEngine:
{
  "name": "accountType",
  "type": "Dropdown",
  "props": {
    "label": "Account type",
    "data": [
      { "label": "Personal", "value": "personal" },
      { "label": "Business", "value": "business" }
    ]
  }
},
{
  "name": "companyName",
  "type": "Input",
  "props": { "label": "Company name" },
  "renderWhen": "form.data.accountType === 'business'",
  "validations": [
    { "type": "required", "message": "Required for business accounts" }
  ]
}
FormEngine’s renderWhen skips validation on hidden fields automatically — no need to conditionally register or unregister. See Conditional fields tutorial.

Before/after: field arrays (repeating sections)

React Hook Form useFieldArray:

const { fields, append, remove } = useFieldArray({ name: 'attendees' })

return (
  <div>
    {fields.map((field, index) => (
      <div key={field.id}>
        <input {...register(`attendees.${index}.name`)} />
        <input {...register(`attendees.${index}.email`)} />
        <button onClick={() => remove(index)}>Remove</button>
      </div>
    ))}
    <button onClick={() => append({ name: '', email: '' })}>Add attendee</button>
  </div>
)
FormEngine Repeater:
{
  "name": "attendees",
  "type": "Repeater",
  "props": {
    "label": "Attendees",
    "addButtonLabel": "Add attendee",
    "removeButtonLabel": "Remove"
  },
  "components": [
    {
      "name": "name",
      "type": "Input",
      "props": { "label": "Name" },
      "validations": [{ "type": "required" }]
    },
    {
      "name": "email",
      "type": "Input",
      "props": { "label": "Email" },
      "validations": [{ "type": "required" }, { "type": "email" }]
    }
  ]
}
The submitted data for a Repeater is an array: formData.attendees = [{ name: '...', email: '...' }, ...].

Before/after: programmatic value setting

React Hook Form setValue:
const { setValue, watch } = useForm()
const country = watch('country')

useEffect(() => {
  if (country === 'US') setValue('currency', 'USD')
  if (country === 'EU') setValue('currency', 'EUR')
}, [country])
FormEngine setValues action:
{
  "name": "country",
  "type": "Dropdown",
  "props": { "label": "Country", "data": [...] },
  "actions": [
    {
      "event": "onChange",
      "action": "setValues",
      "args": {
        "currency": "{{form.data.country === 'US' ? 'USD' : 'EUR'}}"
      }
    }
  ]
}
For complex logic, use a computed property instead. See Actions and events and Computed properties.

Migration strategy

Incremental approach (recommended):
  1. Pick a single form to migrate first — ideally one that changes frequently.
  2. Install FormEngine alongside React Hook Form: npm install @react-form-builder/core @react-form-builder/components-material-ui
  3. Translate the form to a JSON schema.
  4. Replace the RHF <form> with <FormViewer>.
  5. Move validation rules to the schema.
  6. Move the onSubmit handler to onActionEventAsync.
  7. Verify and ship.
  8. Repeat for the next form.
You don’t need to migrate all forms at once. React Hook Form and FormEngine can coexist in the same app. Bulk migration: If you have many similar forms, write a migration script that reads your RHF field configurations and outputs FormEngine JSON schemas. The mapping is mechanical for standard fields.

What doesn’t migrate cleanly

Render-prop patterns: RHF’s Controller render prop and useController hook let you integrate arbitrary controlled components. In FormEngine, you register a component type once and use it everywhere — there’s no per-instance render prop. If you have one-off custom inputs, you’ll need to wrap them as custom components. Complex watch chains: watch in RHF lets you drive arbitrary React code from field values. In FormEngine, reactive logic goes into renderWhen, actions, and computed properties — powerful but different in structure. Very complex watch-driven UI logic may take time to re-express in the schema model. RHF DevTools: React Hook Form has a Chrome extension for inspecting form state. FormEngine doesn’t have a browser extension, but form state is accessible via FormViewerRef.getFormData() for debugging.

Next steps

Last modified on April 16, 2026