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
useFieldArrayhook 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,routesand 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
| Formhaus | react-hook-form | |
|---|---|---|
| Definition format | JSON, checked by a JSON Schema | React code with register and useForm (docs) |
| Multi-step | Built in: steps, per-step validation, progress (guide) | Pattern: one form per page, answers kept in a state library (Wizard Form / Funnel) |
| Branching routes | routes on a step (guide) | Written in your router or state code (Wizard Form / Funnel) |
| Conditional fields | show / showAny; hidden values cleared (guide) | Render based on watch; unmounted values dropped with shouldUnregister |
| Validation | Declarative rules, named validators, async step validation (guide) | Rules in register, or a schema resolver (docs) |
| Custom components | components map per field type (guide) | Any component through Controller |
| Figma | Plugin draws the definition (guide) | Not documented |
| AI tooling | JSON Schema, MCP server, Claude Code plugin | Forms are code; a web Form Builder generates React code |
| Frameworks | React 18+, Vue 3.3+, headless engine | React (npm) |
| Bundle size (gzipped) | 6.2 KB core + 4.8 KB React | 14.4 KB for 7.89.0 (bundlephobia) |
| License | MIT | MIT (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 type { FormDefinition } from '@formhaus/core';
import signup from './signup.json';
const definition = signup as FormDefinition;
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.