Skip to main content

Best React Form Libraries (2026)

·PkgPulse Team
0

React Hook Form has 12 million weekly npm downloads. Its dominance is so complete that comparing it to alternatives feels unfair — but TanStack Form has first-class TypeScript inference that React Hook Form can't match, and Conform handles progressive enhancement with server actions better than either. The right choice depends on what "form handling" means in your application's architecture.

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 on blur, on change, 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. Understanding these tradeoffs helps you pick the right library rather than defaulting to the most popular one regardless of context.

TL;DR

React Hook Form remains the default choice for most React applications in 2026 — battle-tested, performant, great ecosystem, and works well with React 19 server actions. TanStack Form is the right choice if you have deeply nested dynamic forms with complex TypeScript requirements. Conform is the right choice when progressive enhancement and server actions are architectural requirements (Next.js App Router forms that work without JavaScript).

Key Takeaways

  • React Hook Form: ~12M weekly downloads, ~41K GitHub stars
  • Conform (@conform-to/react): ~200K weekly downloads — fast growing with server actions adoption
  • TanStack Form (@tanstack/react-form): ~400K weekly downloads — growing
  • RHF uses uncontrolled inputs (faster); TanStack uses controlled inputs with fine-grained subscription
  • Conform is the only library with first-class progressive enhancement (works without JS)
  • All three integrate with Zod for validation
  • TanStack Form has the best TypeScript inference for nested/dynamic forms

React Hook Form

Package: react-hook-form Weekly downloads: ~12M GitHub stars: 41K Bundle: ~9 kB gzipped

The dominant form library in the React ecosystem. Its core innovation: using uncontrolled inputs (native browser inputs, ref-based) instead of controlled inputs means components only re-render on submit and on blur by default, not on every keystroke.

Basic Usage

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, 'Password must be at least 8 characters'),
  name: z.string().min(2, 'Name too short'),
});

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 createUser(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('name')} placeholder="Name" />
      {errors.name && <span>{errors.name.message}</span>}

      <input {...register('email')} placeholder="Email" />
      {errors.email && <span>{errors.email.message}</span>}

      <input {...register('password')} type="password" placeholder="Password" />
      {errors.password && <span>{errors.password.message}</span>}

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Creating...' : 'Create Account'}
      </button>
    </form>
  );
}

With Server Actions (React 19)

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { createPost } from '@/actions/post'; // Server action

function CreatePostForm() {
  const { register, handleSubmit, formState: { errors } } = useForm({
    resolver: zodResolver(postSchema),
  });

  // Bridge RHF validation with server action
  async function onSubmit(data: PostFormData) {
    const result = await createPost(data);
    if (result?.error) {
      // Handle server errors
    }
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {/* ... */}
    </form>
  );
}

Dynamic Fields

import { useFieldArray } from 'react-hook-form';

function TagsForm() {
  const { control, register } = useForm<{ tags: { value: string }[] }>();
  const { fields, append, remove } = useFieldArray({ control, name: 'tags' });

  return (
    <>
      {fields.map((field, index) => (
        <div key={field.id}>
          <input {...register(`tags.${index}.value`)} />
          <button type="button" onClick={() => remove(index)}>Remove</button>
        </div>
      ))}
      <button type="button" onClick={() => append({ value: '' })}>Add Tag</button>
    </>
  );
}

For a more realistic multi-field array scenario — team members on a project, line items in an invoice — useFieldArray scales to multiple typed fields per row:

// React Hook Form — useFieldArray with multiple fields per row
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" />
      {errors.projectName && <p className="text-red-500">{errors.projectName.message}</p>}

      {fields.map((field, index) => (
        // Use field.id (not the index) as the React key.
        // When rows are removed, field.id keeps input state attached to the
        // correct field rather than shifting to the next row.
        <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>
      ))}

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

Ecosystem

@hookform/resolvers connects RHF to any validation library:

npm install @hookform/resolvers
# Then use with Zod, Yup, Valibot, ArkType, or Superstruct

Conform

Package: @conform-to/react, @conform-to/zod Weekly downloads: ~200K (growing rapidly with server actions adoption) GitHub stars: 4.5K

Conform is purpose-built for progressive enhancement with React server actions. Forms built with Conform work without JavaScript enabled — they degrade gracefully to native HTML form behavior.

Why Progressive Enhancement Matters

Progressive enhancement has a direct impact on initial page load. With React Hook Form, forms don't work until JavaScript loads — the submit handler is a JavaScript function, so without JS there's nothing to call. With Conform, forms work immediately on page load because they submit as native HTML forms. JavaScript enhances the experience by adding client-side validation and optimistic UI updates, but it's not required for the form to submit. Users on slow connections or with JavaScript disabled see a working form, not a broken one.

In Next.js App Router, server actions can be called from a <form action={serverAction}>. Without progressive enhancement, JavaScript must load before the form works. With Conform, the form submits via native HTTP POST if JavaScript hasn't loaded yet.

Basic Usage

// Server Action
'use server';
import { parseWithZod } from '@conform-to/zod';
import { redirect } from 'next/navigation';

const schema = z.object({
  email: z.string().email(),
  name: z.string().min(2),
});

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

  if (submission.status !== 'success') {
    return submission.reply(); // Returns structured errors to client
  }

  await createUser(submission.value);
  redirect('/dashboard');
}
// Client Component
'use client';
import { useForm, getInputProps } from '@conform-to/react';
import { parseWithZod } from '@conform-to/zod';
import { useFormState } from 'react-dom';
import { signupAction } from '@/actions/signup';

function SignupForm() {
  const [lastResult, action] = useFormState(signupAction, null);

  const [form, fields] = useForm({
    lastResult,
    onValidate({ formData }) {
      // Client-side validation with the same Zod schema
      return parseWithZod(formData, { schema });
    },
    shouldValidate: 'onBlur',
    shouldRevalidate: 'onInput',
  });

  return (
    // `action` is the server action — works even without JS
    <form id={form.id} onSubmit={form.onSubmit} action={action} noValidate>
      <input
        {...getInputProps(fields.name, { type: 'text' })}
        placeholder="Name"
      />
      {fields.name.errors && <p id={fields.name.errorId}>{fields.name.errors[0]}</p>}

      <input
        {...getInputProps(fields.email, { type: 'email' })}
        placeholder="Email"
      />
      {fields.email.errors && <p id={fields.email.errorId}>{fields.email.errors[0]}</p>}

      <button type="submit">Sign Up</button>
    </form>
  );
}

The form works as a standard HTML form (with server-side validation only) without JavaScript, and adds client-side validation enhancement when JavaScript loads.

Nested Forms and Field Lists

Conform excels at complex form shapes:

const [form, fields] = useForm({ schema: orderSchema });
const lines = fields.orderLines.getFieldList();

return (
  <form {...getFormProps(form)}>
    {lines.map((line, index) => {
      const lineFields = line.getFieldset();
      return (
        <div key={line.key}>
          <input {...getInputProps(lineFields.productId, { type: 'hidden' })} />
          <input {...getInputProps(lineFields.quantity, { type: 'number' })} />
          <input {...getInputProps(lineFields.price, { type: 'number' })} />
          <button {...form.remove.getButtonProps({ name: fields.orderLines.name, index })}>
            Remove
          </button>
        </div>
      );
    })}
    <button {...form.insert.getButtonProps({ name: fields.orderLines.name })}>
      Add Line
    </button>
  </form>
);

TanStack Form

Package: @tanstack/react-form Weekly downloads: ~400K GitHub stars: 5K Creator: Tanner Linsley (TanStack)

TanStack Form is the newest of the three, and its differentiator is first-class TypeScript inference for complex, dynamic forms.

Basic Usage

import { useForm } from '@tanstack/react-form';
import { zodValidator } from '@tanstack/zod-form-adapter';
import { z } from 'zod';

function SignupForm() {
  const form = useForm({
    defaultValues: {
      email: '',
      password: '',
      preferences: { newsletter: false, notifications: true },
    },
    validatorAdapter: zodValidator(),
    onSubmit: async ({ value }) => {
      // value is fully typed as:
      // { email: string, password: string, preferences: { newsletter: boolean, notifications: boolean } }
      await createUser(value);
    },
  });

  return (
    <form onSubmit={(e) => { e.preventDefault(); form.handleSubmit(); }}>
      <form.Field
        name="email"
        validators={{
          onChange: z.string().email('Invalid email'),
        }}
      >
        {(field) => (
          <>
            <input
              value={field.state.value}
              onBlur={field.handleBlur}
              onChange={(e) => field.handleChange(e.target.value)}
            />
            {field.state.meta.errors.map(e => <span key={e}>{e}</span>)}
          </>
        )}
      </form.Field>

      <form.Subscribe selector={(state) => state.isSubmitting}>
        {(isSubmitting) => (
          <button type="submit" disabled={isSubmitting}>
            {isSubmitting ? 'Submitting...' : 'Submit'}
          </button>
        )}
      </form.Subscribe>
    </form>
  );
}

TypeScript Advantages

TanStack Form's biggest win: deep nested type inference:

const form = useForm({
  defaultValues: {
    user: {
      profile: {
        address: {
          street: '',
          city: '',
          zip: '',
        },
        preferences: { theme: 'light' as 'light' | 'dark' },
      },
    },
  },
});

// TypeScript KNOWS this is a 'light' | 'dark' string union
// React Hook Form doesn't provide this level of inference
<form.Field name="user.profile.preferences.theme">
  {(field) => <select value={field.state.value} ... />}
</form.Field>

Framework-Agnostic Core

TanStack Form is framework-agnostic: @tanstack/react-form, @tanstack/vue-form, and @tanstack/solid-form share the same core validation logic. Teams building cross-framework design systems can share form validation rules across React and Vue implementations without duplicating schemas. The important caveat is that the API was still evolving in early 2026 — method signatures and options changed between minor versions. If you adopt TanStack Form, pin your version and review the changelog before upgrading.

Fine-Grained Subscriptions

TanStack Form's subscription model prevents unnecessary re-renders:

// Only re-renders when isSubmitting changes (not on every field change)
<form.Subscribe selector={(state) => state.isSubmitting}>
  {(isSubmitting) => <SubmitButton loading={isSubmitting} />}
</form.Subscribe>

// Only re-renders when the email field is dirty
<form.Subscribe selector={(state) => state.fieldMeta.email?.isDirty}>
  {(isDirty) => isDirty && <span>Unsaved changes</span>}
</form.Subscribe>

Formik: Still in Millions of Codebases

Formik (~7M weekly downloads) was the dominant React form library from roughly 2018–2021. It popularized the <Formik> wrapper component pattern and useFormik() hook. 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, not new adoption.

// Formik — legacy pattern still common in existing apps
import { Formik, Form, Field, ErrorMessage } from 'formik';
import * as Yup from 'yup';

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, React Hook Form can be introduced incrementally since both libraries can coexist. For new projects, start with React Hook Form.

Feature Comparison

FeatureReact Hook FormConformTanStack FormFormik
Weekly downloads12M200K400K7M
Bundle size~9 kB~8 kB~14 kB~45 kB
TypeScript inferenceGoodGoodExcellentBasic
Uncontrolled inputsYes (default)YesNo (controlled)No (controlled)
Progressive enhancementNoYesNoNo
Server actionsWorksNativeWorksNot supported
Dynamic arraysuseFieldArrayBuilt-inBuilt-inFieldArray
Nested formsLimitedExcellentExcellentGood
Validation adapters10+Zod, YupZod, ValibotYup primarily
Re-render optimizationGood (uncontrolled)GoodExcellent (subscriptions)Poor (controlled)
Maintenance statusActiveActiveActiveMaintenance

When to Choose Each

Choose React Hook Form if:

  • You want the most battle-tested solution with the largest community
  • Your forms are standard contact/signup/settings forms
  • You're using RHF already in the codebase
  • You want the widest choice of validation adapter libraries

React Hook Form's 12M weekly downloads means extensive ecosystem support, Stack Overflow coverage, and library integrations — shadcn/ui form components use RHF out of the box. The useFieldArray hook covers dynamic forms and zodResolver covers type-safe validation. This is the safe, well-supported default.

Choose Conform if:

  • You're building forms in Next.js App Router with server actions
  • Progressive enhancement is required (government, accessibility requirements)
  • You prefer the formData-native server action pattern
  • Your forms are heavily nested with field arrays

Conform's parseWithZod pattern is the key architectural advantage: the same Zod schema runs on both the client (in onValidate) and the server (in the server action). This eliminates the risk of client/server validation drift — a common bug in RHF-plus-server-action setups where client and server schemas get out of sync over time. Server errors map back to the correct fields automatically, with no manual error mapping required.

Choose TanStack Form if:

  • You have deeply nested, dynamic forms with complex TypeScript types
  • You're already using TanStack ecosystem (Query, Router, Table)
  • Re-render optimization in complex forms matters
  • TypeScript autocompletion throughout the form is important

TanStack Form is best suited for form-heavy applications — multi-step wizards, complex data entry UIs, detailed configuration screens — where the type safety payoff justifies tracking a library with an evolving API. The render-component model provides TypeScript inference that neither RHF nor Conform can match, eliminating a class of runtime errors around field name string literals.

The Stack Recommendation for 2026

For most Next.js projects:

# Server actions + progressive enhancement
npm install @conform-to/react @conform-to/zod zod

# OR: Client forms + RHF
npm install react-hook-form @hookform/resolvers zod

Both patterns are production-proven. Choose based on whether your forms need progressive enhancement or follow a pure client-side model.

Accessibility and ARIA Integration

Form accessibility is an area where the choice of form library has real UX consequences. React Hook Form's register() function wires up id and name attributes but does not automatically link error messages to their input fields via aria-describedby — you must manually set aria-describedby={${fieldName}-error} and give your error element the matching id. Conform takes a more opinionated approach: getInputProps(fields.email) returns an aria-invalid attribute when the field has errors, and fields.email.errorId provides the id string to use on your error paragraph, making correct ARIA wiring much easier to implement consistently across a large form. TanStack Form similarly provides field.state.meta.errors and exposes field metadata for ARIA attributes, but the integration requires explicit wiring. For applications with strict WCAG compliance requirements — government, healthcare, or enterprise products — Conform's accessibility-first design reduces the audit surface for form-related violations.

Multi-Step Forms and Complex Form Orchestration

Multi-step wizard forms — onboarding flows, checkout sequences, application forms — require state management that persists across steps while validating only the current step's fields. React Hook Form handles multi-step forms through the mode configuration and manual trigger() calls: call trigger(['email', 'name']) to validate only the current step's fields before advancing, while keeping the full form values in the useForm instance across steps. TanStack Form's field-level subscription model makes partial validation more ergonomic — subscribe only to the fields visible in the current step and validate them independently. Conform's multi-step handling requires coordination between multiple form state instances or a single form with conditional validation based on a step field in the submission — workable but requires more upfront architecture. For extremely complex multi-step flows with conditional branching (different steps based on previous answers), consider whether a dedicated state machine library like XState or a simple useReducer is clearer than fighting a form library's validation lifecycle.

Performance Optimization for Large Forms

React Hook Form's uncontrolled input model shines in forms with many fields — a 50-field data entry form with controlled inputs would trigger 50 re-renders per keystroke across each field, while React Hook Form's ref-based approach triggers zero re-renders during typing. The re-render only happens when formState values change (on blur, on submit, or on explicit watch() calls). For dynamic forms where fields are added and removed frequently, useFieldArray's internal field ID tracking prevents React from re-mounting unchanged field components when the array changes. TanStack Form's fine-grained subscription model via form.Subscribe is similarly efficient — each subscriber re-renders only when its selected state slice changes. The practical performance difference between these libraries matters primarily for forms with 30+ fields or forms inside highly interactive UIs where render budget is constrained. For typical SaaS forms (signup, profile settings, payment details), all three libraries perform identically from a user perspective.

For a disabled submit button that should re-render only when isValid or isSubmitting changes, the useFormState hook enables targeted subscription isolation:

// Only the SubmitButton component re-renders when submission state changes.
// The 50-field parent form and all field components do not re-render.
import { useFormState, Control } from 'react-hook-form';

function SubmitButton({ control }: { control: Control }) {
  const { isValid, isSubmitting } = useFormState({
    control,
    name: ['isValid', 'isSubmitting'], // Subscribe only to these slices
  });
  return (
    <button type="submit" disabled={!isValid || isSubmitting}>
      {isSubmitting ? 'Submitting...' : 'Submit'}
    </button>
  );
}

This pattern is particularly useful in forms with expensive field components (rich text editors, custom date pickers, file upload previews) where you want to keep the submit button's re-render budget independent of the field components' render budget.

Server-Side Validation Integration and Error Hydration

Validation on the client should always be mirrored on the server — client-side validation is a user experience enhancement, not a security measure. React Hook Form's setError() function allows you to hydrate server-returned validation errors back into the form state after a failed submission: call setError('email', { message: 'This email is already registered' }) from your API error handler to display the server error alongside the relevant field. Conform's server action pattern handles this natively — the submission.reply() return value from a failed server action propagates structured field errors back to the client through React's form state, with no additional client-side error handling code. TanStack Form supports server error hydration through field.setMeta() and form.setErrorMap(). The key architectural principle is that server validation errors should appear in the same location as client validation errors — users should not have to search for what went wrong. All three libraries support this; the difference is how much ceremony it requires.

Compare these packages on PkgPulse.

See also: React vs Vue and React vs Svelte, Best React Form Libraries (2026).

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.