TL;DR
react-hook-form is the pragmatic default — most widely used, excellent performance via uncontrolled inputs, mature ecosystem, and works in any React setup. TanStack Form v1 (stable in 2025) brings the TanStack philosophy to forms — end-to-end type safety, framework-agnostic, and deep async validation. Conform is the modern choice for Next.js App Router and Remix — it's designed around server actions and progressive enhancement, treating the server as the source of truth. The choice increasingly depends on your data flow model.
Key Takeaways
- react-hook-form: ~4.5M weekly downloads — most popular, uncontrolled inputs, best performance
- @tanstack/react-form: ~600K weekly downloads — end-to-end types, framework-agnostic, async validation
- conform-to: ~300K weekly downloads — server-first, progressive enhancement, Next.js/Remix native
- react-hook-form for most React apps — the safe, proven choice
- Conform for Next.js App Router with Server Actions or Remix with Actions
- TanStack Form if you're already in the TanStack ecosystem
Download Trends
| Package | Weekly Downloads | Approach | Server Actions | Controlled |
|---|---|---|---|---|
react-hook-form | ~4.5M | Uncontrolled refs | ❌ | Opt-in |
@tanstack/react-form | ~600K | Controlled | ⚠️ Manual | ✅ |
conform-to | ~300K | Server-first | ✅ Native | ❌ |
react-hook-form
react-hook-form uses uncontrolled inputs (refs, not state) — this means minimal re-renders and excellent performance for large forms.
Basic Form
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
const schema = z.object({
packageName: z.string().min(1, "Required").max(214, "Too long"),
threshold: z.coerce.number().min(1, "Must be at least 1"),
email: z.string().email("Invalid email"),
alertType: z.enum(["downloads_drop", "version_update", "security"]),
})
type FormData = z.infer<typeof schema>
function CreateAlertForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isDirty },
setError,
reset,
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
alertType: "downloads_drop",
threshold: 10,
},
})
const onSubmit = async (data: FormData) => {
try {
await createAlert(data)
reset() // Clear form on success
} catch (err) {
// Set server-side errors:
setError("packageName", {
type: "server",
message: "Package not found in registry",
})
}
}
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<div>
<label htmlFor="packageName">Package Name</label>
<input
id="packageName"
{...register("packageName")} // ← registers input without causing re-renders
placeholder="e.g. react"
/>
{errors.packageName && <p role="alert">{errors.packageName.message}</p>}
</div>
<div>
<label htmlFor="threshold">Drop Threshold (%)</label>
<input id="threshold" type="number" {...register("threshold")} />
{errors.threshold && <p role="alert">{errors.threshold.message}</p>}
</div>
<div>
<label htmlFor="email">Alert Email</label>
<input id="email" type="email" {...register("email")} />
{errors.email && <p role="alert">{errors.email.message}</p>}
</div>
<select {...register("alertType")}>
<option value="downloads_drop">Downloads Drop</option>
<option value="version_update">Version Update</option>
<option value="security">Security Advisory</option>
</select>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Creating..." : "Create Alert"}
</button>
</form>
)
}
Controller for Controlled Components
import { useForm, Controller } from "react-hook-form"
// For custom UI components (DatePicker, Select, etc.):
function PackageSearchForm() {
const { control, handleSubmit } = useForm<FormData>()
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* Controlled component — e.g., shadcn Select: */}
<Controller
name="category"
control={control}
rules={{ required: "Please select a category" }}
render={({ field, fieldState }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ui">UI Components</SelectItem>
<SelectItem value="utils">Utilities</SelectItem>
<SelectItem value="database">Database</SelectItem>
</SelectContent>
</Select>
)}
/>
</form>
)
}
TanStack Form
@tanstack/react-form is the full-stack-first approach to forms — deeply typed with first-class support for async server validation.
Basic Form
import { useForm } from "@tanstack/react-form"
import { z } from "zod"
import { zodValidator } from "@tanstack/zod-form-adapter"
function CreateAlertForm() {
const form = useForm({
defaultValues: {
packageName: "",
threshold: 10,
email: "",
},
onSubmit: async ({ value }) => {
// value is fully typed — TypeScript infers from schema
await createAlert(value)
},
validatorAdapter: zodValidator(),
})
return (
<form
onSubmit={(e) => {
e.preventDefault()
e.stopPropagation()
form.handleSubmit()
}}
>
<form.Field
name="packageName"
validators={{
onChange: z.string().min(1, "Required"),
// Async validator — runs after onChange debounce:
onChangeAsync: z.string().refine(
async (val) => {
const exists = await checkPackageExists(val)
return exists
},
"Package not found"
),
onChangeAsyncDebounceMs: 500,
}}
children={(field) => (
<div>
<label htmlFor={field.name}>Package Name</label>
<input
id={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors.map((error) => (
<p key={error} role="alert">{error}</p>
))}
</div>
)}
/>
<form.Field
name="threshold"
validators={{ onChange: z.number().min(1) }}
children={(field) => (
<div>
<label htmlFor={field.name}>Threshold %</label>
<input
id={field.name}
type="number"
value={field.state.value}
onChange={(e) => field.handleChange(Number(e.target.value))}
/>
{field.state.meta.errors.join(", ")}
</div>
)}
/>
<form.Subscribe
selector={(state) => [state.canSubmit, state.isSubmitting]}
children={([canSubmit, isSubmitting]) => (
<button type="submit" disabled={!canSubmit}>
{isSubmitting ? "Creating..." : "Create Alert"}
</button>
)}
/>
</form>
)
}
TanStack Form's strengths:
// Arrays and nested objects:
const form = useForm({
defaultValues: {
packages: [{ name: "", threshold: 10 }],
settings: { notifyEmail: true, notifySlack: false },
},
})
// Dynamic field arrays:
<form.Field name="packages" mode="array">
{(field) => (
<>
{field.state.value.map((_, i) => (
<form.Field key={i} name={`packages[${i}].name`}>
{/* ... */}
</form.Field>
))}
<button onClick={() => field.pushValue({ name: "", threshold: 10 })}>
Add Package
</button>
</>
)}
</form.Field>
Conform
Conform is built for server-side form validation with Next.js Server Actions and Remix Form Actions:
Next.js App Router with Server Actions
// app/actions.ts — server action:
"use server"
import { parseWithZod } from "@conform-to/zod"
import { z } from "zod"
import { redirect } from "next/navigation"
const schema = z.object({
packageName: z.string().min(1),
threshold: z.coerce.number().min(1).max(100),
email: z.string().email(),
})
export async function createAlertAction(prevState: unknown, formData: FormData) {
const submission = parseWithZod(formData, { schema })
// Return errors if invalid:
if (submission.status !== "success") {
return submission.reply()
}
// Create the alert:
await db.alert.create({ data: submission.value })
redirect("/alerts")
}
// app/create-alert/page.tsx — client component:
"use client"
import { useForm, getFormProps, getInputProps } from "@conform-to/react"
import { parseWithZod } from "@conform-to/zod"
import { useActionState } from "react"
import { createAlertAction } from "./actions"
import { z } from "zod"
const schema = z.object({
packageName: z.string().min(1, "Required"),
threshold: z.coerce.number().min(1, "Must be positive"),
email: z.string().email("Invalid email"),
})
export default function CreateAlertPage() {
const [lastResult, action] = useActionState(createAlertAction, undefined)
const [form, fields] = useForm({
lastResult, // Server errors are fed back into the form
onValidate({ formData }) {
// Client-side validation with same schema as server:
return parseWithZod(formData, { schema })
},
shouldValidateOnChange: false,
shouldRevalidateOnInput: true,
})
return (
<form {...getFormProps(form)} action={action}>
<div>
<label htmlFor={fields.packageName.id}>Package Name</label>
<input
{...getInputProps(fields.packageName, { type: "text" })}
placeholder="e.g. react"
/>
<div id={fields.packageName.errorId}>{fields.packageName.errors}</div>
</div>
<div>
<label htmlFor={fields.threshold.id}>Threshold %</label>
<input {...getInputProps(fields.threshold, { type: "number" })} />
<div id={fields.threshold.errorId}>{fields.threshold.errors}</div>
</div>
<div>
<label htmlFor={fields.email.id}>Alert Email</label>
<input {...getInputProps(fields.email, { type: "email" })} />
<div id={fields.email.errorId}>{fields.email.errors}</div>
</div>
<button type="submit">Create Alert</button>
</form>
)
}
Why Conform for Next.js App Router:
- Form works without JavaScript (true progressive enhancement)
- Server validation errors are rendered without client-side state management
useActionStateintegration gives typed server action results back to the form- Same Zod schema on both client and server — no duplication
Feature Comparison
| Feature | react-hook-form | TanStack Form | Conform |
|---|---|---|---|
| Rendering model | Uncontrolled | Controlled | Uncontrolled |
| Server Actions | ❌ | ⚠️ Manual | ✅ Native |
| Progressive enhancement | ❌ | ❌ | ✅ |
| TypeScript | ✅ | ✅ Excellent | ✅ |
| Zod integration | ✅ @hookform/resolvers | ✅ @tanstack/zod-form-adapter | ✅ @conform-to/zod |
| Async validation | ✅ | ✅ Built-in | ✅ |
| Field arrays | ✅ useFieldArray | ✅ mode="array" | ✅ |
| Nested objects | ✅ | ✅ | ✅ |
| Performance | ✅ Excellent | ✅ Good | ✅ Good |
| Remix support | ❌ | ❌ | ✅ useForm |
| File uploads | ⚠️ Basic | ⚠️ Basic | ✅ FormData native |
When to Use Each
Choose react-hook-form if:
- Most React apps without server actions
- You want the most mature ecosystem with battle-tested patterns
- Forms with complex UI components (custom selects, date pickers, rich text)
- Performance-critical forms with many fields
Choose TanStack Form if:
- You're building with the TanStack ecosystem (Query, Router, Table)
- Async server validation is a core requirement
- You want the most type-safe form experience
- Framework-agnostic — same API for React, Vue, Angular, Svelte
Choose Conform if:
- Next.js App Router with Server Actions
- Remix with form Actions
- Progressive enhancement matters (forms work without JS)
- Server is the source of truth for validation
shadcn/ui Integration and Real-World Component Patterns
All three libraries need to work with custom UI component libraries, and the integration story varies significantly. react-hook-form's Controller component is the standard bridge for any controlled component — shadcn/ui's Select, Checkbox, RadioGroup, and DatePicker all use Controller with field.value and field.onChange. The shadcn/ui documentation explicitly shows react-hook-form + Zod as the canonical form pattern, so there are copy-paste-ready examples for every component in the library. This ecosystem alignment means react-hook-form has the lowest friction when assembling forms from UI kit components.
TanStack Form's form.Field render-prop API requires slightly more boilerplate for custom components — you spread field.state.value and field.handleChange onto the component props manually rather than using a Controller abstraction. The payoff is more explicit data flow: you can see exactly where the value comes from and where changes go. For complex custom inputs that need to interact with field metadata (like showing a character counter that reads from field.state.meta.errors), TanStack Form's approach produces cleaner code than nesting Controller callbacks.
Conform generates form attribute objects (getInputProps, getSelectProps, getTextareaProps) rather than component wrappers. These return plain HTML attribute objects that spread onto native elements or custom components. For shadcn/ui integration, this means passing {...getInputProps(fields.email, { type: "email" })} directly to a shadcn Input component. The approach is less ergonomic than react-hook-form's register spread for simple cases, but it pays off because the same attribute objects work with Server Action forms — the form remains functional even when JavaScript hasn't loaded.
File Uploads and FormData Handling
react-hook-form handles file inputs with register("file") producing a FileList in the form data, but submitting to a server action that expects raw FormData requires an additional serialization step. Most react-hook-form forms use handleSubmit which passes a typed JavaScript object to the submit handler, meaning file uploads need special handling to extract the raw File objects and reconstruct a FormData for server submission.
Conform's native FormData-centric design makes file uploads straightforward. Since Conform works directly with the browser's FormData API (the same data structure that server actions receive), file fields are handled natively — the FormData that Conform manages is the same one that flows to the server action. Parsing with parseWithZod supports file validation via Zod's z.instanceof(File) alongside string fields. This is a meaningful advantage for upload-heavy forms like profile photo editors, document uploaders, or content management tools.
TanStack Form v1 has basic file input support but treats file uploads as a less-common scenario compared to its core typed-value approach. Teams building complex multi-file upload interfaces with progress tracking typically reach for specialized libraries (react-dropzone, uppy) alongside their form library rather than relying on form library file primitives.
Validation Timing Strategies
Each library offers different built-in strategies for when validation runs, and choosing the right one significantly affects perceived form quality. react-hook-form's default is mode: "onSubmit" — validation only runs on submit, which prevents errors appearing while the user is still typing. Changing to mode: "onChange" or mode: "onTouched" (validate after blur) is a one-line config change. The reValidateMode: "onChange" option controls re-validation after the first submit — useful for showing green checkmarks as users fix errors.
TanStack Form exposes validators.onChange, validators.onBlur, and validators.onSubmit as separate functions per field, giving the finest control. You can run a cheap client-side regex check on change, an expensive async API call on blur, and a final server validation on submit — all using different validators on the same field. The onChangeAsyncDebounceMs option prevents rapid-fire async validation calls while the user types.
Conform defers most validation timing to the server action by default, but the shouldValidateOnChange and shouldRevalidateOnInput options enable progressive client-side validation. The typical Conform pattern validates on submit (server-side) first to establish correctness, then adds shouldRevalidateOnInput: true to clear field errors as users fix them — a pattern that works even with JavaScript disabled for the initial submit.
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on react-hook-form v7.x, @tanstack/react-form v1.x, and conform-to v1.x documentation.
Compare form library packages on PkgPulse →
The server action ecosystem around Conform deserves specific mention for Next.js App Router developers. Conform works with useActionState (the React 19 API that replaced useFormState) to feed server action results back to the form state without any client-side state management. This means the round-trip from form submit to error display to corrected re-submit is entirely server-driven: the server action runs, returns a submission.reply() with field-level errors, and Conform's useForm hook automatically maps those errors back to the correct field error elements by field name. For teams who have tried to implement this pattern manually with react-hook-form and setError(), the Conform version is significantly less code and handles the loading state, optimistic UI, and error state transitions correctly by default via React's built-in mechanisms.
A real-world ergonomics difference that affects daily development more than benchmark numbers: react-hook-form's formState.errors object is only populated for fields that have been registered. If you conditionally render fields — a common pattern for multi-step forms or dynamic field arrays — fields that haven't mounted yet will not appear in errors even if the schema would mark them invalid. This is by design (unregistered fields are not validated), but it means that "submit and validate everything" behavior requires calling trigger() explicitly for all field names rather than relying on the submit handler to catch all errors. TanStack Form validates all fields regardless of render state because it stores field values in its own state, not in DOM refs. Conform's server-action approach sidesteps this entirely — the full FormData including all named inputs is validated server-side on submit.
See also: React vs Vue and React vs Svelte, Best React Form Libraries (2026).