The Evolution of React Form Libraries: 2020–2026
·PkgPulse Team
TL;DR
React Hook Form won the form library wars — then native browser APIs and server actions started eating into its turf. React Hook Form (~3M weekly downloads) crushed Formik (~1.5M) on every metric that matters: bundle size, performance, TypeScript types. But in 2026, the most interesting form architectures skip form libraries entirely — using React 19 actions, useFormState, and native validation. Choose by what you're building, not by what was best in 2022.
Key Takeaways
- React Hook Form: ~3M weekly downloads — minimal re-renders, Zod integration, best DX
- Formik: ~1.5M downloads — legacy dominant, declining, high re-render count
- Conform: ~400K downloads — server action-compatible, the RHF of the server era
- @tanstack/react-form: ~200K — headless, type-safe, framework-agnostic
- React 19 actions — native
actionprop +useFormStatereduces need for libraries for simple forms
The 2020 Baseline: Formik Ruled
// 2020: Formik was the standard — but it had problems
import { Formik, Field, Form, ErrorMessage } from 'formik';
import * as Yup from 'yup';
const SignupSchema = Yup.object({
email: Yup.string().email().required(),
password: Yup.string().min(8).required(),
});
function SignupForm() {
return (
<Formik
initialValues={{ email: '', password: '' }}
validationSchema={SignupSchema}
onSubmit={(values, actions) => {
submitToAPI(values);
actions.setSubmitting(false);
}}
>
{({ isSubmitting, errors, touched }) => (
<Form>
<Field type="email" name="email" />
{errors.email && touched.email && <div>{errors.email}</div>}
<Field type="password" name="password" />
{errors.password && touched.password && <div>{errors.password}</div>}
<button type="submit" disabled={isSubmitting}>Submit</button>
</Form>
)}
</Formik>
);
}
// Problems:
// - Every keystroke triggers re-render (controlled inputs)
// - Complex state management via render props
// - Yup as only validation option (no Zod)
// - Large bundle (~13KB gzipped)
// - No TypeScript inference on field names
2022: React Hook Form Takes Over
// React Hook Form v7 — the defining API
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const schema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(8, 'At least 8 characters'),
});
type FormData = z.infer<typeof schema>;
function SignupForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormData>({
resolver: zodResolver(schema),
});
const onSubmit = async (data: FormData) => {
await submitToAPI(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input type="email" {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<input type="password" {...register('password')} />
{errors.password && <span>{errors.password.message}</span>}
<button type="submit" disabled={isSubmitting}>Submit</button>
</form>
);
}
// Why RHF won:
// - Uncontrolled inputs → no re-render on keystroke
// - Zod resolver → end-to-end type safety
// - register() returns all needed props in one spread
// - errors are typed: errors.email.message = string | undefined
// - ~9KB gzipped vs Formik's ~13KB
2024: shadcn/ui Standardizes the Pattern
// The de facto 2024 pattern: RHF + Zod + shadcn/ui Form components
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import {
Form, FormField, FormItem, FormLabel,
FormControl, FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
confirmPassword: z.string(),
}).refine(data => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'],
});
type FormData = z.infer<typeof schema>;
function SignupForm() {
const form = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { email: '', password: '', confirmPassword: '' },
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage /> {/* Shows error automatically */}
</FormItem>
)}
/>
{/* Repeat for other fields */}
<Button type="submit" disabled={form.formState.isSubmitting}>
Create Account
</Button>
</form>
</Form>
);
}
2026: Server Actions Enter the Picture
// React 19: native form actions — no form library needed for simple forms
'use server';
import { z } from 'zod';
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
// Server action — validates on server
async function createAccount(prevState: unknown, formData: FormData) {
const raw = Object.fromEntries(formData);
const parsed = schema.safeParse(raw);
if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors };
}
await db.user.create({ data: parsed.data });
return { success: true };
}
// Client component using the server action
'use client';
import { useFormState, useFormStatus } from 'react-dom';
import { createAccount } from '@/actions/auth';
function SignupForm() {
const [state, formAction] = useFormState(createAccount, null);
return (
<form action={formAction}>
<input name="email" type="email" />
{state?.errors?.email && <span>{state.errors.email[0]}</span>}
<input name="password" type="password" />
{state?.errors?.password && <span>{state.errors.password[0]}</span>}
<SubmitButton />
</form>
);
}
function SubmitButton() {
const { pending } = useFormStatus(); // Must be inside <form>
return <button type="submit" disabled={pending}>Sign Up</button>;
}
// Conform — when you want RHF DX with server actions
// Best of both worlds: type-safe + server-compatible
import { useForm, getFormProps, getInputProps } from '@conform-to/react';
import { parseWithZod } from '@conform-to/zod';
import { z } from 'zod';
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
function SignupForm() {
const [form, fields] = useForm({
onValidate({ formData }) {
return parseWithZod(formData, { schema });
},
// Works with server actions
onSubmit(event, { formData }) {
event.preventDefault();
createAccount(formData); // Can be a server action
},
});
return (
<form {...getFormProps(form)}>
<input {...getInputProps(fields.email, { type: 'email' })} />
<div>{fields.email.errors}</div>
<input {...getInputProps(fields.password, { type: 'password' })} />
<div>{fields.password.errors}</div>
<button type="submit">Sign Up</button>
</form>
);
}
Form Library Evolution Timeline
| Year | Dominant Pattern | Bundle | Re-renders | Server-compatible |
|---|---|---|---|---|
| 2020 | Formik + Yup | ~13KB | Many | ❌ |
| 2022 | RHF + Zod | ~9KB | Minimal | ❌ (client-only) |
| 2024 | RHF + Zod + shadcn/ui Form | ~9KB | Minimal | ❌ |
| 2026 | RHF (client) / Conform (server) / Native (simple) | 0-9KB | Minimal | ✅ |
When to Choose
| Scenario | Pick |
|---|---|
| Complex client-side form | React Hook Form + Zod |
| shadcn/ui component library | RHF (built in) |
| Next.js app with server actions | Conform |
| Simple form (1-3 fields) | Native <form> + useFormState |
| Multi-step wizard | RHF with multiple schemas |
| Headless, no library lock-in | @tanstack/react-form |
| Migrating from Formik | React Hook Form (API migration guide in v7 docs) |
Compare form library package health on PkgPulse.
See the live comparison
View react hook form vs. formik on PkgPulse →