TanStack Form vs react-hook-form vs Conform: React Form Libraries in 2026
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
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.