Best React Form Libraries (2026)
TL;DR
React Hook Form is still the default with 12M weekly downloads. Conform emerged as the go-to for Server Actions forms (uncontrolled, progressive enhancement, works without JS). TanStack Form is new but brings end-to-end type safety including field-level errors. For most apps: React Hook Form + Zod resolver. For Next.js with Server Actions: Conform. For type-safety fanatics who'll accept beta software: TanStack Form.
Key Takeaways
- React Hook Form: 12M downloads/week, Zod resolver,
useFormStatefor Server Actions, ~30KB - Conform: 300K downloads/week growing fast, built for Server Actions, progressive enhancement by default
- TanStack Form: ~100K downloads/week, alpha/beta, end-to-end type safety, framework-agnostic
- Formik: 7M downloads/week but declining — being replaced by React Hook Form
- Server Actions: Conform designed for it; RHF added support later; TanStack Form is framework layer
Why Form Libraries Exist
Managing form state in React without a library means re-inventing a large amount of solved problems. Every non-trivial form needs: field validation (ideally with a schema), error message display, async submit state (preventing double-submission), field-level dirty/touched tracking for showing errors only after interaction, and clean integration with your component library's input components.
The uncontrolled component model — where form values live in DOM state, not React state — is dramatically more performant than a controlled approach. With controlled components, every keystroke causes a re-render of the entire form. With uncontrolled components via ref, only the component containing the submit handler re-renders on submission. React Hook Form popularized this approach, which is why it became the dominant library.
In 2026, the landscape has a new dimension: React Server Actions. With next/navigation and app/ directory forms, server validation and form mutation can happen on the server without client-side JavaScript. Conform was built specifically for this pattern. Understanding when you need progressive enhancement versus client-only form handling is now a key architectural decision.
Download Trends
| Package | Weekly Downloads | Trend |
|---|---|---|
react-hook-form | ~12M | Stable |
formik | ~7M | Declining |
@conform-to/react | ~300K | Growing fast |
@tanstack/react-form | ~100K | Growing |
React Hook Form: The Standard
React Hook Form (~12M weekly downloads) is the right default for the vast majority of React forms. It uses uncontrolled inputs registered with register(), which means no re-render per keystroke. Validation runs on submit by default (configurable to run on blur or change). The Zod resolver integration via @hookform/resolvers/zod gives you full schema-based validation with excellent TypeScript inference.
npm install react-hook-form zod @hookform/resolvers
// Complete login form with Zod validation:
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const loginSchema = z.object({
email: z.string().email('Enter a valid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
rememberMe: z.boolean().optional().default(false),
});
type LoginForm = z.infer<typeof loginSchema>;
export function LoginForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isSubmitSuccessful },
setError,
} = useForm<LoginForm>({
resolver: zodResolver(loginSchema),
defaultValues: { email: '', password: '', rememberMe: false },
});
const onSubmit = async (data: LoginForm) => {
try {
await signIn(data.email, data.password);
} catch (err) {
// Set server-side error on a specific field:
setError('email', {
type: 'server',
message: 'Invalid email or password',
});
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
{...register('email')}
aria-invalid={!!errors.email}
/>
{errors.email && (
<p className="text-sm text-red-500" role="alert">
{errors.email.message}
</p>
)}
</div>
<div>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
{...register('password')}
aria-invalid={!!errors.password}
/>
{errors.password && (
<p className="text-sm text-red-500" role="alert">
{errors.password.message}
</p>
)}
</div>
<div className="flex items-center gap-2">
<input id="rememberMe" type="checkbox" {...register('rememberMe')} />
<label htmlFor="rememberMe">Remember me</label>
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Signing in...' : 'Sign in'}
</button>
</form>
);
}
useFieldArray handles dynamic field lists — adding and removing items at runtime:
// useFieldArray — dynamic form fields (e.g., adding team members)
import { useForm, useFieldArray } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const teamSchema = z.object({
teamName: z.string().min(1, 'Team name required'),
members: z.array(z.object({
email: z.string().email('Valid email required'),
role: z.enum(['admin', 'member', 'viewer']),
})).min(1, 'Add at least one member'),
});
type TeamForm = z.infer<typeof teamSchema>;
export function CreateTeamForm() {
const { register, control, handleSubmit, formState: { errors } } = useForm<TeamForm>({
resolver: zodResolver(teamSchema),
defaultValues: {
teamName: '',
members: [{ email: '', role: 'member' }],
},
});
const { fields, append, remove } = useFieldArray({
control,
name: 'members',
});
return (
<form onSubmit={handleSubmit(console.log)}>
<input {...register('teamName')} placeholder="Team name" />
{fields.map((field, index) => (
<div key={field.id} className="flex gap-2">
<input
{...register(`members.${index}.email`)}
placeholder="Email address"
/>
<select {...register(`members.${index}.role`)}>
<option value="admin">Admin</option>
<option value="member">Member</option>
<option value="viewer">Viewer</option>
</select>
<button type="button" onClick={() => remove(index)}>
Remove
</button>
</div>
))}
<button type="button" onClick={() => append({ email: '', role: 'member' })}>
Add Member
</button>
<button type="submit">Create Team</button>
</form>
);
}
React Hook Form's one weakness is Server Actions. Adding server-side validation errors back into the form requires bridging useActionState (formerly useFormState) with RHF's setError — it works but feels like two systems stitched together. Conform was designed for this case from the ground up.
Conform: Built for Server Actions
Conform (~300K weekly downloads, growing fast) is designed around a different mental model from React Hook Form. Instead of managing form state in the browser, Conform treats the form as a progressive enhancement layer over native HTML form submission. Your Server Action validates the form data using Zod, returns structured errors, and Conform's client-side hooks display them. If JavaScript fails to load, the form still submits and validation still runs on the server.
This architecture is particularly well-matched to Next.js App Router and use server actions.
npm install @conform-to/react @conform-to/zod
// Server Action — the source of truth for validation:
// app/actions/profile.ts
'use server';
import { parseWithZod } from '@conform-to/zod';
import { z } from 'zod';
import { redirect } from 'next/navigation';
const profileSchema = z.object({
name: z.string().min(1, 'Name is required').max(100),
email: z.string().email('Enter a valid email'),
bio: z.string().max(500).optional(),
website: z.string().url('Enter a valid URL').optional().or(z.literal('')),
});
export async function updateProfile(prevState: unknown, formData: FormData) {
const submission = parseWithZod(formData, { schema: profileSchema });
// Return structured errors back to the form:
if (submission.status !== 'success') {
return submission.reply();
}
// submission.value is typed and validated:
await db.user.update({
where: { id: await getCurrentUserId() },
data: submission.value,
});
// Can reset form on success:
return submission.reply({ resetForm: true });
}
// Client Component — Conform hooks connect to the Server Action:
'use client';
import { useForm, getFormProps, getInputProps, getTextareaProps } from '@conform-to/react';
import { parseWithZod } from '@conform-to/zod';
import { useFormState } from 'react-dom';
import { updateProfile } from './actions';
import { profileSchema } from './schema'; // Shared schema
export function ProfileForm({ defaultValues }: { defaultValues: ProfileFormData }) {
const [lastResult, action] = useFormState(updateProfile, undefined);
const [form, fields] = useForm({
lastResult, // Errors returned from Server Action
defaultValue: defaultValues,
onValidate: ({ formData }) =>
parseWithZod(formData, { schema: profileSchema }), // Client-side preview validation
});
return (
<form {...getFormProps(form)} action={action}>
<div>
<label htmlFor={fields.name.id}>Name</label>
<input
{...getInputProps(fields.name, { type: 'text' })}
placeholder="Your display name"
/>
{fields.name.errors && (
<p id={fields.name.errorId} className="text-red-500 text-sm">
{fields.name.errors}
</p>
)}
</div>
<div>
<label htmlFor={fields.email.id}>Email</label>
<input
{...getInputProps(fields.email, { type: 'email' })}
/>
{fields.email.errors && (
<p id={fields.email.errorId} className="text-red-500 text-sm">
{fields.email.errors}
</p>
)}
</div>
<div>
<label htmlFor={fields.bio.id}>Bio</label>
<textarea
{...getTextareaProps(fields.bio)}
placeholder="Tell us about yourself"
/>
{fields.bio.errors && (
<p id={fields.bio.errorId} className="text-red-500 text-sm">
{fields.bio.errors}
</p>
)}
</div>
{form.errors && (
<p className="text-red-500 text-sm">{form.errors}</p>
)}
<button type="submit">Save Profile</button>
</form>
);
}
The key insight with Conform: getInputProps and getFormProps set the correct id, name, aria-describedby, and aria-invalid attributes automatically. Accessibility is handled correctly without manual wiring.
Conform works best when your validation logic lives on the server. The shared Zod schema between the Server Action and onValidate gives you client-side preview validation while the server remains the authoritative validator. If JavaScript is disabled, the form still submits and the user gets validation errors back through full-page navigation.
TanStack Form: End-to-End Type Safety
TanStack Form (~100K weekly downloads) takes type safety further than any other form library. Where React Hook Form infers field types from your Zod schema, TanStack Form infers types from your defaultValues throughout the component tree — including in render props within form.Field components. The field value type, error type, and metadata are all inferred without explicit annotations.
The trade-off is that TanStack Form was in beta/RC status through much of 2025-2026. The API is more complex than React Hook Form's, and some patterns require understanding the form.Field render prop pattern.
npm install @tanstack/react-form zod @tanstack/zod-form-adapter
// TanStack Form — end-to-end type safety:
import { useForm } from '@tanstack/react-form';
import { zodValidator } from '@tanstack/zod-form-adapter';
import { z } from 'zod';
const formSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Valid email required'),
age: z.number().min(18, 'Must be at least 18'),
bio: z.string().max(500).optional(),
});
type FormValues = z.infer<typeof formSchema>;
export function SignupForm() {
const form = useForm<FormValues>({
defaultValues: {
name: '',
email: '',
age: 0,
bio: '',
},
validators: {
onChange: formSchema, // Validate on every change
},
validatorAdapter: zodValidator(),
onSubmit: async ({ value }) => {
// value is fully typed as FormValues:
await createAccount(value);
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
className="space-y-4"
>
{/* form.Field gives you render-prop access to field state: */}
<form.Field
name="name"
validators={{
onChange: z.string().min(1, 'Name is required'),
}}
>
{(field) => (
<div>
<label htmlFor={field.name}>Name</label>
<input
id={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
aria-invalid={field.state.meta.errors.length > 0}
/>
{field.state.meta.errors.length > 0 && (
<p className="text-red-500 text-sm">
{field.state.meta.errors.join(', ')}
</p>
)}
</div>
)}
</form.Field>
<form.Field name="email">
{(field) => (
<div>
<label htmlFor={field.name}>Email</label>
<input
id={field.name}
type="email"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.isTouched && field.state.meta.errors.length > 0 && (
<p className="text-red-500 text-sm">
{field.state.meta.errors.join(', ')}
</p>
)}
</div>
)}
</form.Field>
<form.Subscribe
selector={(state) => [state.canSubmit, state.isSubmitting]}
>
{([canSubmit, isSubmitting]) => (
<button type="submit" disabled={!canSubmit || isSubmitting}>
{isSubmitting ? 'Creating account...' : 'Create Account'}
</button>
)}
</form.Subscribe>
</form>
);
}
The form.Field render prop pattern is more verbose than React Hook Form's register() spread, but it gives you something RHF can't: the field's complete type-safe state in the render context. field.state.value is typed as the field's type (not unknown or string), field.state.meta.errors is typed correctly, and TypeScript catches mismatched field names at compile time.
TanStack Form is also framework-agnostic — the same @tanstack/form-core package works with React, Vue, Angular, and Solid via thin adapter layers. For organizations building multi-framework applications, this is a meaningful advantage.
Formik: The Legacy Leader
Formik (~7M weekly downloads, declining) was the dominant form library before React Hook Form's controlled-vs-uncontrolled performance model changed the landscape. Formik uses controlled components — it stores every field value in React state, which causes re-renders on every keystroke in large forms.
For new projects, Formik is not recommended. React Hook Form outperforms it in every benchmark, has better TypeScript support, and has a larger and more active community. The download numbers remain high due to existing codebases that haven't migrated.
If you're maintaining a Formik codebase, migration to React Hook Form is worthwhile for any form with more than 5-10 fields or any form that users interact with heavily. The migration is straightforward: useForm replaces useFormik, register() replaces Field + name, and handleSubmit works nearly identically.
Form Validation Strategy: Client vs Server vs Both
Understanding where validation runs is as important as which library you use. The three strategies are: client-only, server-only, and the hybrid approach that Conform enables.
Client-only validation (React Hook Form's default mode) validates in the browser on submit or on field blur. It gives instant feedback without network round-trips. The weakness: it can be bypassed. Never trust client-only validation for security-sensitive fields like email uniqueness checks, role assignment, or payment limits.
Server-only validation runs all validation in the Server Action or API route. Users get accurate validation (checked against real data) but experience a round-trip delay on every submission attempt. For simple forms with few fields, this is acceptable. For complex multi-step forms, it becomes frustrating.
Hybrid validation (Conform's approach) runs the same Zod schema on both client and server. The client does optimistic validation immediately for basic rules (required, min length, email format), while the server validates rules that require database access (unique email, valid foreign key). Errors from the server are merged back into the form's field-level state.
For most forms in production Next.js applications, the hybrid pattern is the right default. React Hook Form with setError can approximate this pattern, but Conform's architecture makes it natural.
Integrating with shadcn/ui Components
React Hook Form and shadcn/ui's form primitives are designed to work together. shadcn/ui ships a FormField, FormItem, FormLabel, FormControl, and FormMessage set of components that wraps React Hook Form's useController pattern and handles aria attributes automatically:
// shadcn/ui Form components + React Hook Form:
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const formSchema = z.object({
username: z
.string()
.min(3, 'Username must be at least 3 characters')
.max(20, 'Username must be less than 20 characters')
.regex(/^[a-z0-9_-]+$/, 'Only lowercase letters, numbers, hyphens, and underscores'),
email: z.string().email('Enter a valid email address'),
});
export function RegistrationForm() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: { username: '', email: '' },
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(console.log)} className="space-y-6">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="jane_doe" {...field} />
</FormControl>
<FormDescription>
This is your public display name.
</FormDescription>
<FormMessage /> {/* Shows validation error automatically */}
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="jane@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={form.formState.isSubmitting}>
Create account
</Button>
</form>
</Form>
);
}
The shadcn Form components set aria-describedby, aria-invalid, and associate labels with inputs automatically through React Hook Form's useController. This is the recommended pattern for shadcn/ui projects — it avoids the manual {...register('name')} spread and gives you better accessibility out of the box.
Accessibility: The Underrated Form Concern
Form accessibility is often an afterthought but dramatically affects usability for keyboard users, screen reader users, and users with motor impairments. The libraries covered here provide varying levels of accessibility support, and the patterns you use within them matter.
The essential requirements: every <input> must have a corresponding <label> (either wrapping it or connected via htmlFor / id). Error messages must be associated with their field via aria-describedby. Invalid fields should have aria-invalid="true". Submit buttons should indicate pending state. Focus management on multi-step forms should move focus to the first error after failed submission.
React Hook Form's register() function does not automatically set aria-invalid or aria-describedby. You wire these yourself from formState.errors. The shadcn/ui Form components handle this correctly, which is why they're recommended for new projects.
Conform's getInputProps() helper sets aria-invalid and aria-describedby automatically when the field has errors, correctly associating the error element via fields.name.errorId. This makes Conform the most accessible option out of the box.
TanStack Form provides the field's error state in field.state.meta.errors, but accessibility wiring is manual — you set aria-invalid and aria-describedby yourself in the render prop.
// Accessible error message pattern for React Hook Form:
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
{...register('email')}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && (
<p id="email-error" role="alert" className="text-red-500 text-sm">
{errors.email.message}
</p>
)}
</div>
The role="alert" on the error paragraph causes screen readers to announce it immediately when it appears, without the user having to navigate to it. This is the correct pattern for inline validation errors.
Package Health Comparison
| React Hook Form | Conform | TanStack Form | Formik | |
|---|---|---|---|---|
| Downloads | ~12M/week | ~300K/week | ~100K/week | ~7M/week |
| Component model | Uncontrolled | Uncontrolled | Controlled | Controlled |
| Server Actions | Added later | Native | Plugin | No |
| Progressive enhancement | Partial | Full | Partial | No |
| TypeScript quality | Good | Good | Excellent | Good |
| Bundle size | ~30KB | ~15KB | ~20KB | ~45KB |
| Maturity | Stable | Stable | Beta/RC | Stable |
| Zod integration | @hookform/resolvers | @conform-to/zod | Native adapter | Via Yup compat |
| Field arrays | useFieldArray | Fieldset API | form.Field list | FieldArray |
| Active development | Yes | Yes | Yes | Slowing |
When to Choose
Standard React application → React Hook Form + Zod
For the overwhelming majority of React applications — client-rendered SPAs, Next.js with client components, any form without Server Actions — React Hook Form with a Zod resolver is the right default. The API is mature and well-documented, the community is large, and the uncontrolled model handles even large forms with excellent performance. useFieldArray covers dynamic fields. @hookform/resolvers/zod gives you first-class schema validation.
Next.js App Router with Server Actions → Conform
When your form mutations run as Server Actions and you want progressive enhancement (the form works even without JavaScript), Conform is the purpose-built solution. The Server Action validates with Zod and returns structured errors; Conform's client hooks display them. The shared schema between server and client avoids duplicating validation logic. This pattern also works with Remix's action functions.
Maximum type safety, willing to track evolving API → TanStack Form
If end-to-end type inference is a priority — especially for form-heavy applications like survey builders, data entry tools, or configuration forms — TanStack Form delivers type safety that RHF can't match. The render prop pattern is more verbose, but the TypeScript experience throughout is superior. Accept that the API is still evolving and track the changelog.
Existing Formik codebase → Stay for now, plan migration
If your team has existing Formik forms and they're working, the migration cost may not justify immediate action. Plan the migration for new forms or when Formik forms need significant changes. React Hook Form's API is close enough that migration is a few-hour task per form rather than a weeks-long project.