Skip to main content

Guide

Best React Form Libraries: React Hook Form vs Conform vs TanStack Form (2026)

React Hook Form remains the leader, but Conform adds Server Actions support and TanStack Form brings type-safe field components. Which form library belongs.

·PkgPulse Team·
0

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 JavaScript enabled. TanStack Form is newer but brings end-to-end type safety including field-level errors. For most apps: React Hook Form plus a Zod resolver. For Next.js with Server Actions: Conform. For type-safety as the top priority: TanStack Form, though the API is still evolving.

Key Takeaways

  • React Hook Form: 12M downloads/week, Zod resolver, useFormState for 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 in new projects
  • Server Actions: Conform designed for it; RHF added support later; TanStack Form works at the framework layer

Why Form Libraries Still Matter

Forms in React are deceptively complex. Controlled inputs cause re-renders on every keystroke, degrading performance in large forms. Validation needs to run both on blur and on submit. Error messages need to be associated with the correct fields for screen readers. Server validation errors need to map back to individual fields. File uploads, dynamic field arrays, and conditional validation all add further complexity.

Form libraries solve these problems so you don't have to. The tradeoffs differ: React Hook Form optimizes for performance with uncontrolled inputs. Conform optimizes for progressively enhanced Server Actions. TanStack Form optimizes for TypeScript type inference throughout the entire form lifecycle. Formik was the original pioneer but has been largely superseded for new projects.

Understanding these tradeoffs helps you pick the right library rather than defaulting to the most popular one regardless of context.


React Hook Form: The Current Standard

React Hook Form (~12M weekly downloads) became the default choice for React forms starting around 2020 and has held that position. The core insight is using uncontrolled inputs — React doesn't manage the input state, so there are no re-renders per keystroke. Only form submission and validation trigger re-renders, making React Hook Form significantly more performant than controlled input approaches (including Formik).

npm install react-hook-form zod @hookform/resolvers

The typical setup uses useForm with a zodResolver to connect Zod schema validation:

// React Hook Form — 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('Invalid email address'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
  rememberMe: z.boolean().default(false),
});

type LoginFormData = z.infer<typeof loginSchema>;

export function LoginForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting, isDirty },
    setError,
  } = useForm<LoginFormData>({
    resolver: zodResolver(loginSchema),
    defaultValues: { email: '', password: '', rememberMe: false },
  });

  const onSubmit = async (data: LoginFormData) => {
    try {
      await signIn(data.email, data.password);
    } catch (err) {
      // Map server errors back to specific fields
      setError('email', { message: 'Invalid email or password' });
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
      <div>
        <label htmlFor="email" className="block text-sm font-medium">Email</label>
        <input
          id="email"
          type="email"
          {...register('email')}
          className="mt-1 w-full rounded-md border px-3 py-2"
          aria-describedby={errors.email ? 'email-error' : undefined}
        />
        {errors.email && (
          <p id="email-error" className="mt-1 text-sm text-red-600">
            {errors.email.message}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="password" className="block text-sm font-medium">Password</label>
        <input
          id="password"
          type="password"
          {...register('password')}
          className="mt-1 w-full rounded-md border px-3 py-2"
        />
        {errors.password && (
          <p className="mt-1 text-sm text-red-600">{errors.password.message}</p>
        )}
      </div>

      <div className="flex items-center gap-2">
        <input id="remember" type="checkbox" {...register('rememberMe')} />
        <label htmlFor="remember" className="text-sm">Remember me</label>
      </div>

      <button
        type="submit"
        disabled={isSubmitting}
        className="w-full rounded-md bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
      >
        {isSubmitting ? 'Signing in...' : 'Sign In'}
      </button>
    </form>
  );
}

Dynamic Field Arrays with useFieldArray

useFieldArray handles the "add/remove rows" pattern for dynamic forms — line items in an invoice, team members in a project, or skills in a profile:

// React Hook Form — useFieldArray for dynamic rows
import { useForm, useFieldArray } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const teamSchema = z.object({
  projectName: z.string().min(1, 'Project name required'),
  members: z.array(
    z.object({
      email: z.string().email('Valid email required'),
      role: z.enum(['viewer', 'editor', 'admin']),
    })
  ).min(1, 'At least one member required'),
});

type TeamFormData = z.infer<typeof teamSchema>;

export function TeamForm() {
  const { register, handleSubmit, control, formState: { errors } } = useForm<TeamFormData>({
    resolver: zodResolver(teamSchema),
    defaultValues: {
      projectName: '',
      members: [{ email: '', role: 'viewer' }],
    },
  });

  const { fields, append, remove } = useFieldArray({
    control,
    name: 'members',
  });

  return (
    <form onSubmit={handleSubmit(console.log)} className="space-y-4">
      <input {...register('projectName')} placeholder="Project name" />

      <div className="space-y-2">
        {fields.map((field, index) => (
          <div key={field.id} className="flex gap-2">
            <input
              {...register(`members.${index}.email`)}
              placeholder="Email"
              type="email"
            />
            <select {...register(`members.${index}.role`)}>
              <option value="viewer">Viewer</option>
              <option value="editor">Editor</option>
              <option value="admin">Admin</option>
            </select>
            <button type="button" onClick={() => remove(index)}>Remove</button>
          </div>
        ))}
      </div>

      <button type="button" onClick={() => append({ email: '', role: 'viewer' })}>
        Add Member
      </button>

      <button type="submit">Create Project</button>
    </form>
  );
}

Conform: Built for Server Actions

Conform (~300K downloads, growing quickly) was designed specifically for the Server Actions model in Next.js and Remix. The key distinction is progressive enhancement: a Conform form submits correctly even when JavaScript is disabled or hasn't loaded yet. The form action is a real HTML form action, not a JavaScript event handler.

This matters for initial page load performance. With React Hook Form, forms don't work until JavaScript loads. With Conform, forms work immediately because they submit as native HTML forms — JavaScript enhances the experience by adding client-side validation and optimistic UI, but it's not required.

npm install @conform-to/react @conform-to/zod
// Conform — Client component with Server Action
'use client';
import { useForm, getFormProps, getInputProps } from '@conform-to/react';
import { parseWithZod } from '@conform-to/zod';
import { useFormState } from 'react-dom';
import { z } from 'zod';
import { createUser } from './actions';

const createUserSchema = z.object({
  name: z.string().min(1, 'Name is required').max(100),
  email: z.string().email('Enter a valid email address'),
  role: z.enum(['user', 'admin']),
});

export function CreateUserForm() {
  const [lastResult, action] = useFormState(createUser, undefined);

  const [form, fields] = useForm({
    lastResult,
    // Client-side validation using the same schema
    onValidate: ({ formData }) =>
      parseWithZod(formData, { schema: createUserSchema }),
  });

  return (
    // form action={action} makes this work without JS
    <form {...getFormProps(form)} action={action}>
      <div>
        <label htmlFor={fields.name.id}>Name</label>
        <input
          {...getInputProps(fields.name, { type: 'text' })}
          placeholder="Full name"
        />
        {/* errors from both client and server validation */}
        {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.role.id}>Role</label>
        <select {...getInputProps(fields.role, { type: 'text' })}>
          <option value="user">User</option>
          <option value="admin">Admin</option>
        </select>
      </div>

      <button type="submit">Create User</button>
    </form>
  );
}
// The corresponding Server Action — validates with the same Zod schema
'use server';
import { parseWithZod } from '@conform-to/zod';

export async function createUser(prevState: unknown, formData: FormData) {
  const submission = parseWithZod(formData, { schema: createUserSchema });

  if (submission.status !== 'success') {
    // Returns structured errors that Conform's client can display per-field
    return submission.reply();
  }

  // submission.value is fully typed from the Zod schema
  await db.user.create({ data: submission.value });

  return submission.reply({ resetForm: true });
}

The parseWithZod function runs the same schema on both client and server, eliminating the risk of mismatched validation logic. Server errors are automatically mapped back to the correct form fields — no manual error mapping required.


TanStack Form: End-to-End Type Safety

TanStack Form (~100K downloads) takes a different approach: instead of using uncontrolled inputs with a resolver, it uses controlled form.Field render components where TypeScript can infer the type of each field's value, errors, and handlers without any casting.

npm install @tanstack/react-form zod @tanstack/zod-form-adapter
// TanStack Form — fully typed field components
import { useForm } from '@tanstack/react-form';
import { zodValidator } from '@tanstack/zod-form-adapter';
import { z } from 'zod';

const profileSchema = z.object({
  name: z.string().min(1, 'Name is required'),
  email: z.string().email('Invalid email'),
  age: z.number().min(18, 'Must be 18 or older'),
});

export function ProfileForm() {
  const form = useForm({
    defaultValues: {
      name: '',
      email: '',
      age: 0,
    },
    validators: {
      onChange: profileSchema,
    },
    validatorAdapter: zodValidator(),
    onSubmit: async ({ value }) => {
      // value is typed as { name: string; email: string; age: number }
      // No assertion needed — TypeScript knows the shape
      await updateProfile(value);
    },
  });

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        form.handleSubmit();
      }}
      className="space-y-4"
    >
      <form.Field
        name="name"
        children={(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)}
              // field.state.value is typed as string — no casting
            />
            {field.state.meta.errors.length > 0 && (
              <p className="text-red-500 text-sm">
                {field.state.meta.errors.join(', ')}
              </p>
            )}
          </div>
        )}
      />

      <form.Field
        name="age"
        children={(field) => (
          <div>
            <label htmlFor={field.name}>Age</label>
            <input
              id={field.name}
              type="number"
              value={field.state.value}
              onChange={(e) => field.handleChange(Number(e.target.value))}
              // field.state.value is typed as number — no String/Number conversion needed
            />
            {field.state.meta.errors.length > 0 && (
              <p className="text-red-500 text-sm">
                {field.state.meta.errors.join(', ')}
              </p>
            )}
          </div>
        )}
      />

      <button type="submit">Save Profile</button>
    </form>
  );
}

The key benefit of TanStack Form is that TypeScript knows the type of field.state.value for each field individually — string for text fields, number for numeric fields, without you writing a cast. This eliminates a class of runtime errors where you accidentally treat a number field as a string.

TanStack Form is framework-agnostic: @tanstack/react-form, @tanstack/vue-form, and @tanstack/solid-form share the same core logic. Teams building cross-framework design systems can share form validation logic across React and Vue implementations.

The important caveat: the API was still evolving in early 2026. If you adopt TanStack Form, pin your version and check the changelog before upgrading. The core concepts are stable, but method signatures and options have changed between minor versions.


Formik: The Original, Now Declining

Formik (~7M weekly downloads) was the dominant React form library from roughly 2018 through 2021. It popularized the <Formik> wrapper component pattern and useFormik() hook, bringing organized form state management to React for the first time at scale.

By 2026, Formik is in maintenance mode. Development has slowed significantly, and the library hasn't adopted modern React patterns like concurrent mode compatibility or Server Actions integration. The download numbers remain high because of existing codebases — but for new projects, React Hook Form is uniformly recommended over Formik.

// Formik — legacy pattern still found in many codebases
import { Formik, Form, Field, ErrorMessage } from 'formik';
import * as Yup from 'yup';  // Formik commonly uses Yup rather than Zod

const validationSchema = Yup.object({
  email: Yup.string().email('Invalid email').required('Required'),
  password: Yup.string().min(8, 'At least 8 characters').required('Required'),
});

export function LegacyLoginForm() {
  return (
    <Formik
      initialValues={{ email: '', password: '' }}
      validationSchema={validationSchema}
      onSubmit={async (values, { setSubmitting }) => {
        await signIn(values.email, values.password);
        setSubmitting(false);
      }}
    >
      {({ isSubmitting }) => (
        <Form>
          <Field type="email" name="email" placeholder="Email" />
          <ErrorMessage name="email" component="p" />

          <Field type="password" name="password" />
          <ErrorMessage name="password" component="p" />

          <button type="submit" disabled={isSubmitting}>
            Sign In
          </button>
        </Form>
      )}
    </Formik>
  );
}

If you're maintaining a Formik codebase, there's no urgent reason to migrate — it still works. For new features in existing Formik apps, you can introduce React Hook Form incrementally since both can coexist. For new projects, start with React Hook Form.


Package Health Comparison

LibraryWeekly DownloadsControlledServer ActionsZod IntegrationBundle SizeStability
react-hook-form~12MUncontrolledAdded support@hookform/resolvers~30KBStable
@conform-to/react~300KUncontrolledNative@conform-to/zod~15KBStable
@tanstack/react-form~100KControlledPlugin@tanstack/zod-form-adapter~20KBBeta/RC
formik~7MControlledNot supportedVia Yup primarily~45KBMaintenance

When to Choose

React Hook Form — The default for the vast majority of React applications. 12M weekly downloads means extensive ecosystem support, Stack Overflow coverage, and library integrations (shadcn/ui form components use RHF). Use it for standard forms in React apps that don't specifically need Server Actions progressive enhancement. The useFieldArray hook covers dynamic forms. zodResolver covers type-safe validation. This is the safe, well-supported choice.

Conform — Next.js 15+ applications using Server Actions where progressive enhancement matters. If your forms need to work before JavaScript loads (e-commerce checkout, critical authentication flows, accessibility requirements), Conform's approach is architecturally correct. The server-side parseWithZod pattern eliminates the risk of client/server validation drift. Growing rapidly because the Next.js ecosystem has embraced Server Actions.

TanStack Form — Applications where end-to-end type safety is the top priority and you're willing to accept API evolution. The render-component approach provides TypeScript inference that neither RHF nor Conform can match. Best for form-heavy applications (multi-step wizards, complex data entry UIs) where the type safety payoff justifies tracking a beta library. Check the docs for the latest API before starting.

Formik — Only for maintaining existing Formik codebases. Do not start new projects with Formik. The library is in maintenance mode and lacks Server Actions support, concurrent mode compatibility, and the performance characteristics of uncontrolled-input libraries.


Complex Form Patterns

Multi-step wizards, dynamic field arrays, and conditional validation are where the capability differences between form libraries become practical rather than theoretical. React Hook Form's useFormContext and useFieldArray hooks make these patterns workable without major architecture changes.

Multi-step wizard forms — where a single logical form spans multiple pages or steps — require sharing form state across components without lifting all state to a parent. React Hook Form's FormProvider and useFormContext enable this: you initialize one useForm instance at the wizard root, wrap steps in a FormProvider, and each step accesses the shared form state via useFormContext. Validation can be scoped to the current step using the trigger function with field names: trigger(['email', 'name']) validates only those fields, allowing the user to proceed to the next step while deferring validation of later steps.

Dynamic field arrays — line items, team members, skill entries — are handled by useFieldArray. The key implementation detail is that useFieldArray requires a control object from useForm and a field name that points to an array in your schema. Each entry gets a stable id from React Hook Form (not the index), which is critical for React's reconciliation during add/remove/reorder operations. Using the RHF-provided field.id as the React key instead of the array index prevents input state from getting attached to the wrong field when rows are removed.

Conditional validation — fields that are required only when another field has a specific value — is cleanest with Zod's .discriminatedUnion() or .superRefine(). The pattern is to define your full schema with conditional branches, and Zod evaluates only the relevant branch based on the discriminant field's value. React Hook Form sees the entire schema and runs whichever validation path Zod activates. The alternative approach — using the watch function to observe a field's value and imperatively calling register/unregister for dependent fields — works but requires careful cleanup to avoid stale validation errors.

TanStack Form handles all three patterns through its form.Field render component model, which provides more explicit control over when each field renders and re-renders. The tradeoff is verbosity: each dynamic field needs its own form.Field render prop, making dynamic field arrays more code than React Hook Form's map over fields. For form-heavy applications where the type safety justifies the verbosity, TanStack Form's approach eliminates a class of runtime errors around field name string literals that React Hook Form's register-based API is prone to.


Performance at Scale

The re-render cost of form libraries becomes measurable when forms grow beyond roughly 20 fields. A form with 50+ fields — data entry UIs, detailed configuration screens, complex business forms — exposes the behavioral differences between controlled and uncontrolled input approaches in production settings.

React Hook Form's uncontrolled input model means the 50-field form triggers no React renders during typing. The browser handles keystroke input natively. React Hook Form reads the form values from the DOM only when needed: on submit, and on validation trigger. This is the same performance model as a plain HTML form, with the form library adding validation, error state, and submission handling on top without degrading input responsiveness.

The useFormState hook is the precision tool for optimizing re-renders in complex forms. When you have a disabled submit button that should update only when isValid or isSubmitting changes, isolate it in a child component that subscribes to only those state slices via useFormState({ control, name: ['isValid', 'isSubmitting'] }). The parent form component and the 50 field components do not re-render when submission state changes — only the button component does. This pattern scales.

For forms with genuinely expensive field components — rich text editors, complex date pickers, file upload previews — the Controller component from React Hook Form wraps controlled third-party components while keeping React Hook Form's registration and validation in sync. The shouldUnregister option controls whether field values are cleared when their component unmounts, which matters in multi-step forms where steps mount and unmount.

Formik's controlled model becomes a performance liability at scale. A 50-field form re-renders the entire component tree on every keystroke in every field. With React DevTools Profiler, you can observe this as a continuous stream of renders during typing. The workaround in Formik is memoizing field components with React.memo and ensuring stable prop references with useCallback, but this is engineering effort to approximate what React Hook Form provides by default. For forms with more than about 15 fields, React Hook Form's uncontrolled model is the architecturally correct choice, not a premature optimization.


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.