Skip to main content

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 action prop + useFormState reduces 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

YearDominant PatternBundleRe-rendersServer-compatible
2020Formik + Yup~13KBMany
2022RHF + Zod~9KBMinimal❌ (client-only)
2024RHF + Zod + shadcn/ui Form~9KBMinimal
2026RHF (client) / Conform (server) / Native (simple)0-9KBMinimal

When to Choose

ScenarioPick
Complex client-side formReact Hook Form + Zod
shadcn/ui component libraryRHF (built in)
Next.js app with server actionsConform
Simple form (1-3 fields)Native <form> + useFormState
Multi-step wizardRHF with multiple schemas
Headless, no library lock-in@tanstack/react-form
Migrating from FormikReact Hook Form (API migration guide in v7 docs)

Compare form library package health on PkgPulse.

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.