Skip to content

Formhaus vs react-hook-form ​

react-hook-form is a hook for forms you write in React code; Formhaus is a JSON definition that an engine runs and React, Vue and Figma render. For one hand-written React form, react-hook-form is the more flexible choice with a smaller API surface. For multi-step flows with branching, forms stored as data, or forms that also live in Figma, Formhaus does that work without extra code.

Choose react-hook-form when ​

  • The form is written and maintained by developers in React, and its markup is custom.
  • You already validate with Zod, Yup or another schema library. react-hook-form plugs them in through a resolver.
  • You need field arrays, fine-grained re-render control or a large ecosystem of examples. The useFieldArray hook covers dynamic lists.
  • The app is React only. react-hook-form supports React 16.8 through 19 (npm).

Choose Formhaus when ​

  • The form is data: it comes from a CMS, an API, a product team or an AI agent, and changes without a deploy.
  • The flow has steps that depend on answers. Formhaus has steps, routes and conditional steps in the definition.
  • The same form must render in React and Vue, or be drawn in Figma from the same file.
  • AI agents generate or edit forms. The JSON Schema and the MCP server check their output against the real engine.

Feature comparison ​

Formhausreact-hook-form
Definition formatJSON, checked by a JSON SchemaReact code with register and useForm (docs)
Multi-stepBuilt in: steps, per-step validation, progress (guide)Pattern: one form per page, answers kept in a state library (Wizard Form / Funnel)
Branching routesroutes on a step (guide)Written in your router or state code (Wizard Form / Funnel)
Conditional fieldsshow / showAny; hidden values cleared (guide)Render based on watch; unmounted values dropped with shouldUnregister
ValidationDeclarative rules, named validators, async step validation (guide)Rules in register, or a schema resolver (docs)
Custom componentscomponents map per field type (guide)Any component through Controller
FigmaPlugin draws the definition (guide)Not documented
AI toolingJSON Schema, MCP server, Claude Code pluginForms are code; a web Form Builder generates React code
FrameworksReact 18+, Vue 3.3+, headless engineReact (npm)
Bundle size (gzipped)6.2 KB core + 4.8 KB React14.4 KB for 7.89.0 (bundlephobia)
LicenseMITMIT (npm)

Sizes measured on 2026-10-09. See how Formhaus sizes are measured.

Migration ​

The same sign-up form: email, plan, and a company field only for the Team plan.

react-hook-form ​

tsx
import { useForm } from 'react-hook-form';

type Values = { email: string; plan: string; company?: string };

export function Signup({ onDone }: { onDone: (values: Values) => void }) {
  const { register, handleSubmit, watch, formState: { errors } } = useForm<Values>({
    shouldUnregister: true,
  });
  const plan = watch('plan');

  return (
    <form onSubmit={handleSubmit(onDone)}>
      <label>
        Email
        <input
          type="email"
          {...register('email', {
            required: 'Enter your email',
            pattern: { value: /^\S+@\S+$/, message: 'Enter a valid email' },
          })}
        />
      </label>
      {errors.email && <p>{errors.email.message}</p>}
      <label>
        Plan
        <select {...register('plan', { required: 'This field is required' })}>
          <option value="">Choose</option>
          <option value="solo">Solo</option>
          <option value="team">Team</option>
        </select>
      </label>
      {errors.plan && <p>{errors.plan.message}</p>}
      {plan === 'team' && (
        <label>
          Company
          <input {...register('company', { required: 'Enter your company' })} />
        </label>
      )}
      {errors.company && <p>{errors.company.message}</p>}
      <button type="submit">Create account</button>
    </form>
  );
}

Formhaus ​

json
{
  "id": "signup",
  "title": "Sign up",
  "submit": { "label": "Create account" },
  "fields": [
    {
      "key": "email",
      "type": "email",
      "label": "Email",
      "validation": {
        "required": "Enter your email",
        "pattern": "^\\S+@\\S+$",
        "patternMessage": "Enter a valid email"
      }
    },
    {
      "key": "plan",
      "type": "select",
      "label": "Plan",
      "options": [
        { "value": "solo", "label": "Solo" },
        { "value": "team", "label": "Team" }
      ],
      "validation": { "required": true }
    },
    {
      "key": "company",
      "type": "text",
      "label": "Company",
      "show": [{ "field": "plan", "eq": "team" }],
      "validation": { "required": "Enter your company" }
    }
  ]
}
tsx
import { FormRenderer } from '@formhaus/react';
import { parseDefinition } from '@formhaus/core';
import signup from './signup.json';

const definition = parseDefinition(signup);

export function Signup({ onDone }: { onDone: (values: Record<string, unknown>) => void }) {
  return <FormRenderer definition={definition} onSubmit={onDone} />;
}

Rules move from register options to validation, the watch check becomes a show condition, and labels, options and error rendering come from the definition. To keep your own inputs, map them with the components prop.

Rule map ​

react-hook-formzodFormhaus
required: 'Enter your name'z.string().min(1, 'Enter your name')"required": "Enter your name"
minLength, maxLength.min(n), .max(n) on a string"minLength", "maxLength" with minLengthMessage, maxLengthMessage
min, maxz.coerce.number().min(n)"type": "number" with "min", "max"
pattern: { value: /^\d{5}$/ }.regex(/^\d{5}$/)"pattern": "^\\d{5}$", a string. Not anchored: keep ^ and $.
pattern for email.email()"type": "email" plus a pattern. The browser does not check the format.
Value from a listz.enum([...])options on select or radio
validate: (v) => v === true on a checkboxz.literal(true)"required": "Accept the terms" on a checkbox
At least one itemz.array(z.string()).min(1)"required": true on a multiselect. minLength alone skips an empty array.
No rule.optional()Leave out required
validate: (v, all) => v === all.password.refine((d) => d.confirm === d.password, { path: ['confirm'] })"matchField": "password" plus "required". matchField passes when the confirmation is empty.
Other validate functions.refine(fn), .superRefine(fn)"validator": "name", and a name function in the validators prop that takes (value, values) and returns a message or null
Async validate.refine(async fn)onStepValidate on the step. See Async step validation.
watch and conditional render, shouldUnregisterz.discriminatedUnionshow or showAny. Hidden fields are not validated or submitted.
Server-side schema.parse(body)schema.parse(body)validateSubmission(definition, body.values) from @formhaus/core/server

Rules run on Continue and Submit, not on change or blur. A field's error clears when its value changes.

Formhaus is MIT licensed. Legal