Formhaus vs TanStack Form
TanStack Form is headless, type-safe form state that you drive from TypeScript code in React, Vue, Angular, Solid, Lit and Svelte; Formhaus is a JSON definition with steps, routes and conditions that an engine runs and React, Vue and Figma render. When developers write the form and want full type inference over values and Standard Schema validation, TanStack Form is the better fit. When the form is data that changes without a deploy, branches between steps, or has to match a Figma design, Formhaus covers it from one file.
Choose TanStack Form when
- You want value and field types inferred from runtime defaults without passing generics (philosophy).
- The app uses Angular, Solid, Svelte or Lit. TanStack Form ships adapters for them (installation); Formhaus renders React and Vue.
- Validation already lives in Zod, Valibot, ArkType or Effect Schema. TanStack Form accepts any Standard Schema validator (validation guide).
- You need form-level and field-level async validators with debounce, such as
onChangeAsyncDebounceMs(validation guide).
Choose Formhaus when
- Forms come from a CMS, an API, a product team or an AI agent, and change without a deploy.
- Steps depend on answers. Formhaus routes between steps from the definition instead of from component state.
- The same form must be drawn in Figma with your design system.
- AI agents generate forms and you want them checked against the JSON Schema and the real engine.
Feature comparison
| Formhaus | TanStack Form | |
|---|---|---|
| Definition format | JSON, checked by a JSON Schema | TypeScript code with useForm and form.Field (overview) |
| Multi-step | Built in: steps, per-step validation, progress (guide) | Example with one form and the current step in React state (multi-step wizard example) |
| Branching routes | routes on a step (guide) | Written in your step state code (multi-step wizard example) |
| Conditional fields | show / showAny; hidden values cleared (guide) | Render based on subscribed values (reactivity guide) |
| Validation | Declarative rules, named validators, async step validation (guide) | Functions or Standard Schema, sync and async, per field or form (validation guide) |
| Custom components | components map per field type (guide) | Headless; examples for Mantine, Material UI, shadcn/ui and Chakra UI (UI libraries) |
| Figma | Plugin draws the definition (guide) | Not documented |
| AI tooling | JSON Schema, MCP server, Claude Code plugin | Forms are code; docs published as llms.txt |
| Frameworks | React 18+, Vue 3.3+, headless engine | React, Vue, Angular, Solid, Lit, Svelte (installation) |
| Bundle size (gzipped) | 6.2 KB core + 4.8 KB React | 17.4 KB for @tanstack/react-form 1.33.5 (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.
TanStack Form
import { useForm } from '@tanstack/react-form';
type Values = { email: string; plan: string; company: string };
const required = (message: string) => ({ value }: { value: string }) => (value ? undefined : message);
export function Signup({ onDone }: { onDone: (values: Values) => void }) {
const form = useForm({
defaultValues: { email: '', plan: '', company: '' },
onSubmit: ({ value }) => onDone(value),
});
return (
<form onSubmit={(event) => { event.preventDefault(); form.handleSubmit(); }}>
<form.Field
name="email"
validators={{
onSubmit: ({ value }) =>
!value ? 'Enter your email' : /^\S+@\S+$/.test(value) ? undefined : 'Enter a valid email',
}}
>
{(field) => (
<label>
Email
<input type="email" value={field.state.value} onChange={(e) => field.handleChange(e.target.value)} />
{field.state.meta.errors[0] && <p>{field.state.meta.errors[0]}</p>}
</label>
)}
</form.Field>
<form.Field name="plan" validators={{ onSubmit: required('This field is required') }}>
{(field) => (
<label>
Plan
<select value={field.state.value} onChange={(e) => field.handleChange(e.target.value)}>
<option value="">Choose</option>
<option value="solo">Solo</option>
<option value="team">Team</option>
</select>
{field.state.meta.errors[0] && <p>{field.state.meta.errors[0]}</p>}
</label>
)}
</form.Field>
<form.Subscribe selector={(state) => state.values.plan}>
{(plan) =>
plan === 'team' && (
<form.Field name="company" validators={{ onSubmit: required('Enter your company') }}>
{(field) => (
<label>
Company
<input value={field.state.value} onChange={(e) => field.handleChange(e.target.value)} />
{field.state.meta.errors[0] && <p>{field.state.meta.errors[0]}</p>}
</label>
)}
</form.Field>
)
}
</form.Subscribe>
<button type="submit">Create account</button>
</form>
);
}Formhaus
{
"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" }
}
]
}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} />;
}Field validators become validation rules, the form.Subscribe check becomes a show condition, and Formhaus leaves company out of the submitted values when it is hidden. Values are typed as Record<string, unknown>, so narrow them in onSubmit.