Skip to main content

The Evolution of React Form Libraries: 2020–2026

·PkgPulse Team
0

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.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.