splitforms.com
All articles/ COMPARISONS16 MIN READPublished May 5, 2026

Best React form library in 2026: an honest comparison

Honest 2026 comparison of React Hook Form, Formik, TanStack Form, react-final-form, and pure useState — bundle size, DX, validation compat, and when each one wins.

✶ Written by
splitforms.com / blog

Founder of splitforms — the form backend API for developers. Writes about form UX, anti-spam, and shipping web apps without backend code.

LibraryGzippedDefault modelTS inferencePick when
React Hook Form~9 KBUncontrolledExcellentMost projects
TanStack Form~6 KBControlled (opt-in uncontrolled)Excellent (strictest)Already in TanStack ecosystem
react-final-form~5 KBSubscription-basedOKTiny bundle, simple forms
Formik~13 KBControlledVerboseExisting Formik codebases only
useState0 KBControlledYou write itUnder 5 fields, no arrays

What you actually need from a React form library

Before you compare libraries, get clear on which jobs you actually need done. Most React form libraries solve a slightly different overlap of these concerns:

  • State management. Controlled (every keystroke flows through React state) vs uncontrolled (refs read values on submit). Uncontrolled is faster on large forms because it skips re-renders; controlled is simpler when you need to derive UI from values mid-typing.
  • Validation. Sync (instant, blocking) vs async (debounced server checks like "is this email taken"). You want both, with clean composition.
  • Field arrays / dynamic fields. Adding and removing rows in a list (invoice line items, additional emails). This is where naive useState falls apart — index-based keys and stale closures get painful fast.
  • Accessibility. Wiring aria-invalid, aria-describedby, and focus management on submit failure. The library should make doing the right thing the path of least resistance.
  • TypeScript ergonomics. A single source-of-truth type that flows through register, watch, errors, and submit. No casts.
  • Bundle weight. Forms ship to every visitor. A few KB matter on landing pages.
  • Submission flow. This is orthogonal. Form libraries hand you validated data; getting that data to a server is a separate problem (and where splitforms sits — see the section below).

If you only need three of these (state, sync validation, basic submit), useState plus the platform's native HTML validation can do the job in zero KB. If you need all seven, you want a real library.

One job people often forget to evaluate: form persistence and recovery. If a user types a 200-word message and accidentally refreshes, do they lose it? None of these libraries handle that natively, but RHF's watch() + localStorage takes about 6 lines. Formik, TanStack Form, and react-final-form all expose values similarly. useState is the same effort.

Another: conditional fields. Show field B only when field A has a certain value. RHF's watch("a") and TanStack Form's useStore selector are clean. Formik's render-props give you the value but force a full re-render. With useState, it's trivial — but you lose validation orchestration when the conditional field unmounts mid-submit.

The contenders

React Hook Form (RHF)

The default in 2026. Around 9 KB gzipped, uncontrolled by default, ~3 million weekly NPM downloads. It made its name on performance — by reading values from refs on submit, it avoids the per-keystroke re-render storm that plagues controlled libraries on large forms.

A canonical 5-field contact form:

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

const Schema = z.object({
  name: z.string().min(1, "Required"),
  email: z.string().email("Invalid email"),
  company: z.string().optional(),
  budget: z.enum(["<10k", "10-50k", "50k+"]),
  message: z.string().min(20, "Tell us a bit more"),
});
type FormValues = z.infer<typeof Schema>;

export function ContactForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<FormValues>({ resolver: zodResolver(Schema) });

  async function onSubmit(values: FormValues) {
    await fetch("/api/contact", { method: "POST", body: JSON.stringify(values) });
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("name")} aria-invalid={!!errors.name} />
      {errors.name && <span role="alert">{errors.name.message}</span>}

      <input type="email" {...register("email")} aria-invalid={!!errors.email} />
      {errors.email && <span role="alert">{errors.email.message}</span>}

      <input {...register("company")} />

      <select {...register("budget")}>
        <option value="<10k">Under $10k</option>
        <option value="10-50k">$10-50k</option>
        <option value="50k+">$50k+</option>
      </select>

      <textarea {...register("message")} aria-invalid={!!errors.message} />
      {errors.message && <span role="alert">{errors.message.message}</span>}

      <button type="submit" disabled={isSubmitting}>Send</button>
    </form>
  );
}

Validation library compat: excellent. Official resolvers exist for Zod, Valibot, Yup, Joi, ArkType, and Superstruct. Pick when: any form with more than five fields, anything with field arrays, or any project where you want first-class TypeScript inference without thinking about it.

Formik

The previous king. Around 13 KB gzipped (plus 30 KB of Yup if you use the canonical pairing). Controlled by default, render-props or hooks API, mature ecosystem. The downsides have caught up: it re-renders the whole form on every keystroke, the bundle is heavy, the TypeScript story is verbose, and the project is in maintenance mode (releases have slowed dramatically since 2023).

import { Formik, Form, Field, ErrorMessage } from "formik";
import * as Yup from "yup";

const Schema = Yup.object({
  name: Yup.string().required(),
  email: Yup.string().email().required(),
  company: Yup.string(),
  budget: Yup.string().oneOf(["<10k", "10-50k", "50k+"]).required(),
  message: Yup.string().min(20).required(),
});

export function ContactForm() {
  return (
    <Formik
      initialValues={{ name: "", email: "", company: "", budget: "<10k", message: "" }}
      validationSchema={Schema}
      onSubmit={async (values) => {
        await fetch("/api/contact", { method: "POST", body: JSON.stringify(values) });
      }}
    >
      {({ isSubmitting }) => (
        <Form>
          <Field name="name" />
          <ErrorMessage name="name" component="span" role="alert" />

          <Field name="email" type="email" />
          <ErrorMessage name="email" component="span" role="alert" />

          <Field name="company" />

          <Field name="budget" as="select">
            <option value="<10k">Under $10k</option>
            <option value="10-50k">$10-50k</option>
            <option value="50k+">$50k+</option>
          </Field>

          <Field name="message" as="textarea" />
          <ErrorMessage name="message" component="span" role="alert" />

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

Validation library compat: good for Yup, awkward for everything else. Zod and Valibot work via community adapters, but the ergonomics are clearly Yup-first. Pick when: you have an existing Formik codebase and migrating isn't worth the cost. Otherwise, don't.

TanStack Form

The newest serious contender, from the team behind TanStack Query and Router. Around 6 KB gzipped, framework-agnostic core (works in React, Vue, Solid, Lit), headless. The TypeScript story is the strictest of any library here — it will catch typos in field names at compile time.

import { useForm } from "@tanstack/react-form";
import { z } from "zod";

const Schema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
  company: z.string().optional(),
  budget: z.enum(["<10k", "10-50k", "50k+"]),
  message: z.string().min(20),
});

export function ContactForm() {
  const form = useForm({
    defaultValues: { name: "", email: "", company: "", budget: "<10k" as const, message: "" },
    validators: { onChange: Schema },
    onSubmit: async ({ value }) => {
      await fetch("/api/contact", { method: "POST", body: JSON.stringify(value) });
    },
  });

  return (
    <form onSubmit={(e) => { e.preventDefault(); form.handleSubmit(); }}>
      <form.Field name="name">
        {(f) => (
          <>
            <input value={f.state.value} onChange={(e) => f.handleChange(e.target.value)} />
            {f.state.meta.errors[0] && <span role="alert">{f.state.meta.errors[0]}</span>}
          </>
        )}
      </form.Field>
      {/* Repeat for email, company, budget, message */}
      <button type="submit">Send</button>
    </form>
  );
}

Validation library compat: excellent. Standard Schema support means Zod, Valibot, ArkType, and Effect Schema all work without an adapter. Pick when: you're already in the TanStack ecosystem (Query, Router) and want consistency, or when you want the strictest TypeScript story available.

react-final-form

The minimalist option. Around 5 KB gzipped (including the final-form core). Subscription-based: each Field component subscribes only to the parts of state it needs, so re-renders are surgical. Slow momentum — releases are infrequent and the ecosystem is small.

import { Form, Field } from "react-final-form";

const required = (v: string) => (v ? undefined : "Required");

export function ContactForm() {
  return (
    <Form
      onSubmit={async (values) => {
        await fetch("/api/contact", { method: "POST", body: JSON.stringify(values) });
      }}
      render={({ handleSubmit, submitting }) => (
        <form onSubmit={handleSubmit}>
          <Field name="name" validate={required}>
            {({ input, meta }) => (
              <>
                <input {...input} aria-invalid={meta.touched && !!meta.error} />
                {meta.touched && meta.error && <span role="alert">{meta.error}</span>}
              </>
            )}
          </Field>
          {/* email, company, budget, message follow the same pattern */}
          <button type="submit" disabled={submitting}>Send</button>
        </form>
      )}
    />
  );
}

Validation library compat: OK. No native schema integration; you write a validate function or wire Yup/Zod manually. Pick when: bundle size is your top constraint and forms are simple. Avoid for greenfield projects expecting active maintenance.

Pure useState (no library)

The zero-dependency option. Don't dismiss it. For a 5-field contact form with synchronous validation, you can ship working, accessible code in fewer lines than any library example above:

import { useState } from "react";

export function ContactForm() {
  const [values, setValues] = useState({ name: "", email: "", message: "" });
  const [errors, setErrors] = useState<Record<string, string>>({});
  const [submitting, setSubmitting] = useState(false);

  function set<K extends keyof typeof values>(k: K, v: string) {
    setValues((s) => ({ ...s, [k]: v }));
  }

  async function onSubmit(e: React.FormEvent) {
    e.preventDefault();
    const next: Record<string, string> = {};
    if (!values.name) next.name = "Required";
    if (!/^\S+@\S+$/.test(values.email)) next.email = "Invalid email";
    if (values.message.length < 20) next.message = "Tell us more";
    setErrors(next);
    if (Object.keys(next).length) return;

    setSubmitting(true);
    await fetch("/api/contact", { method: "POST", body: JSON.stringify(values) });
    setSubmitting(false);
  }

  return (
    <form onSubmit={onSubmit} noValidate>
      <input value={values.name} onChange={(e) => set("name", e.target.value)} aria-invalid={!!errors.name} />
      {errors.name && <span role="alert">{errors.name}</span>}
      <input type="email" value={values.email} onChange={(e) => set("email", e.target.value)} aria-invalid={!!errors.email} />
      {errors.email && <span role="alert">{errors.email}</span>}
      <textarea value={values.message} onChange={(e) => set("message", e.target.value)} aria-invalid={!!errors.message} />
      {errors.message && <span role="alert">{errors.message}</span>}
      <button type="submit" disabled={submitting}>Send</button>
    </form>
  );
}

It breaks down when you add field arrays, async validation, or want shared validation between client and server. For everything else, it's the right answer. See the HTML form action guide for an even simpler approach that uses the platform's native form action attribute.

Bundle size comparison

Numbers below are minified + gzipped, measured against current versions in May 2026 with esbuild and a single useForm import. Real-world cost depends on tree-shaking and which sub-components you import.

LibraryCore+ Zod resolver+ Yup resolverNotes
React Hook Form~9 KB~14 KB~40 KB (Yup is heavy)Tree-shakes well
TanStack Form~6 KB~11 KB (no adapter needed)~36 KBStandard Schema native
react-final-form~5 KB~10 KB (manual)~35 KBTiny but no adapters
Formik~13 KB~18 KB~43 KBHeaviest core
useState0 KB~5 KB (Zod alone)~30 KBZero abstraction cost

The headline: Zod is the cheapest validator across every library. If bundle is a concern and you're reaching for Yup out of habit, switch to Zod or Valibot — Valibot is even smaller (~2 KB) because of its modular API.

Validation library compatibility

Five validators dominate the React ecosystem. Their fit with each form library varies meaningfully:

ValidatorRHFTanStack FormFormikreact-final-form
ZodOfficial resolverNative (Standard Schema)Community adapterManual
ValibotOfficial resolverNativeManualManual
YupOfficial resolverAdapterFirst-class (canonical)Manual
JoiOfficial resolverAdapterAdapterManual
SuperstructOfficial resolverAdapterAdapterManual

Practical advice for 2026:

  • Default to Zod. Excellent TypeScript inference, sane API, supported everywhere, ~5 KB. The z.infer trick that gives you a TypeScript type from a runtime schema is genuinely the killer feature — your form's shape lives in one place.
  • Reach for Valibot if you care intensely about bundle size or you want tree-shakable imports. The API is similar enough to Zod that switching is a 30-minute job.
  • Skip Yup for new projects. It's heavy, the TypeScript inference is worse than Zod's, and the ecosystem has moved on.
  • Use Joi or Superstruct only if you have an existing investment.

One subtle gotcha: when you share a Zod schema between client and server, you have to be careful with refinements that only make sense on one side. Server-only checks (like "email is unique in the database") belong in async refinements that you skip on the client by passing a different schema variant. Both RHF and TanStack Form support per-field async validation natively; Formik and react-final-form make you wire it up manually with validate functions per field.

TypeScript inference and ergonomics

For TypeScript users, this is often the deciding factor. Here is the same form typed with each library:

React Hook Form

type FormValues = { name: string; email: string; age: number };

const { register, handleSubmit, watch } = useForm<FormValues>();

// register("name") → typed string field
// register("nope") → compile error
// watch("email") → string | undefined
// handleSubmit((data) => data.age + 1) // data is FormValues, no cast needed

Field names are checked at compile time. Errors are FieldErrors<FormValues>. Refactoring (renaming a field) is safe — the compiler finds every usage.

TanStack Form

const form = useForm({
  defaultValues: { name: "", email: "", age: 0 },
});

// form.Field name="name" → typed string
// form.Field name="nope" → compile error
// form.useStore((s) => s.values.email) → string

The strictest of the bunch. The form type is inferred from defaultValues, so you don't even write the generic. Field paths are template-literal-typed for nested objects.

Formik

type FormValues = { name: string; email: string; age: number };

<Formik<FormValues> initialValues={{ name: "", email: "", age: 0 }} onSubmit={(values) => {}}>
  {({ values }) => <Field name="name" />}
</Formik>

// Field name="nope" → does NOT compile-error
// values.name → typed

Workable, but field name strings aren't type-checked. Typos pass the compiler and crash at runtime. The render-props pattern also doesn't play well with hooks-based code.

react-final-form

<Field<string> name="name">
  {({ input, meta }) => <input {...input} />}
</Field>

You manually annotate field types. It works, but it's the most verbose of the four.

Verdict: RHF and TanStack Form are tied for best TypeScript story. Pick TanStack Form if you want the strictest possible types, RHF if you want a smaller API surface.

Accessibility comparison

None of these libraries automatically make your form accessible — you still wire ARIA yourself. But they make it more or less convenient:

  • React Hook Form. The register spread gives you the ref needed for programmatic focus on submit failure. setFocus("email") is a one-liner. Errors live on formState.errors[name], easy to wire to aria-invalid and aria-describedby. shouldFocusError: true (default) auto-focuses the first invalid field on submit — the single most important a11y behavior.
  • TanStack Form. Each Field exposes state.meta.errors directly. Focus management is manual but straightforward via the input ref.
  • Formik. ErrorMessage renders inline, but it doesn't auto-set role="alert" — you pass it explicitly. No built-in focus-on-error; you wire it via useEffect on the errors object.
  • react-final-form. meta.error and meta.touched are exposed per field. No focus management built in.
  • useState. Whatever you build. Easy to forget aria-invalid, aria-describedby, and the focus dance. The platform's native HTML validation gives you free announcements (browsers handle required and type=email messages), which is underrated.

For all libraries, the manual checklist is the same: every input gets a label with htmlFor; errors are linked via aria-describedby; the submit button focuses the first invalid field on failure; and the error region uses role="alert" or aria-live="polite" so screen readers announce changes. None of the libraries do this for you.

Two patterns are worth standardizing across your form components regardless of library: a FieldError wrapper that always renders role="alert" and links itself by id, and a useFocusFirstError hook that watches the errors object and calls .focus() on the first invalid input after submit. With both in place, your form components stop accidentally regressing when someone copies a snippet.

If you do nothing else, get the label and aria-invalid right. Native screen-reader announcements on form errors are surprisingly good when those two are in place — even before you wire up custom role="alert" regions.

My recommendation by use case

The right library depends entirely on what you're building. Match yourself to the closest case:

Brand-new SPA, simple forms (1-5 fields, sync validation only)

Use useState. Don't add a dependency for what the platform gives you free. Use the browser's built-in validation (required, type=email, pattern) and FormData for submission. You'll write less code and ship zero KB of library overhead. If the form grows past five fields or sprouts dynamic field arrays, migrate to RHF — that migration takes an afternoon.

Complex forms with field arrays, async validation, multi-step

Use React Hook Form. The useFieldArray hook is the cleanest field-array implementation in any library. trigger() handles per-field async validation. watch() lets you derive UI from form values without performance penalties (because uncontrolled inputs only re-render the watching subtree). Pair with Zod for shared client/server validation.

You already use TanStack Query / Router

Use TanStack Form. Consistent API design across the suite, the same maintainers, similar mental model. The Standard Schema integration means you don't need a Zod resolver — Zod just works. You also get the strictest TypeScript inference of any library, which compounds well with TanStack Query's already-strong types.

You're migrating off Formik

Migrate to React Hook Form. It's the closest API match, the largest user base (so the most StackOverflow answers), and the migration is mostly mechanical: FormikuseForm, Fieldregister, ErrorMessage → conditional render of errors.x.message. Plan one day per medium form. TanStack Form is also fine, but the API is further from Formik's, so the diff is larger.

You need a tiny bundle (landing pages, embedded widgets)

Use react-final-form if you want a real library, or useState if a simple 1-3 field form will do. Skip Formik (too heavy) and consider whether you actually need any library at all. For a single email-capture input, useState + a fetch is the right answer.

You want best-in-class TypeScript

Tie between React Hook Form and TanStack Form. RHF if you want a smaller API surface, TanStack Form if you want the strictest possible types and don't mind the larger surface.

You're building a form-heavy admin panel (dozens of forms)

Use React Hook Form and invest one day building thin wrapper components: TextField, SelectField, CheckboxField, DateField. Each wraps RHF's register plus your design system, plus the a11y wiring (aria-invalid, aria-describedby, error rendering). After that, every new form is 20 minutes and looks consistent. This is RHF's sweet spot — the API is composable enough that wrappers stay thin.

You ship to a CDN with strict bundle budgets

If your route bundle budget is < 30 KB total, every dependency matters. react-final-form + Valibot is the smallest serious combo (~7 KB combined). useState + native HTML validation is zero. Avoid Formik (heavy core) and Yup (heavy validator). Code-split your form route so the form library only loads when the visitor reaches the form page.

The submission backend (where splitforms fits)

Form libraries don't deliver submissions — they manage local form state. Once you have validated data, you still need somewhere to send it. Most teams pick one of:

  • Your own API. Express, Fastify, NestJS, or a Next.js / Remix route handler. Most flexible, most maintenance. See /forms/react for patterns.
  • Serverless. AWS Lambda, Cloudflare Workers, Vercel functions. Cheap, scales to zero, but you maintain the function and database.
  • Hosted form backend. splitforms, Formspree, Web3Forms, Basin. You skip the server entirely — POST directly from the browser, the service handles storage, email, spam filtering, and webhooks.

Form libraries are agnostic to which you pick. Here's React Hook Form posting validated data to splitforms:

async function onSubmit(values: FormValues) {
  await fetch("https://splitforms.com/api/submit", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ access_key: process.env.NEXT_PUBLIC_SPLITFORMS_KEY, ...values }),
  });
}

Five lines. Same pattern works with Formik, TanStack Form, react-final-form, or no library at all — the form library hands you the validated object, the backend persists it.

Honest take: if you already run a backend and have an API endpoint, splitforms isn't relevant — POST to your own server. If you're building a static site, a Vite SPA without a backend, or you'd rather not run another service for one contact form, splitforms is the simplest option. Free tier is 1,000 submissions/month with webhooks and AI spam filtering — see the free contact form page.

For framework-specific guidance (server actions, SSR submissions, route handlers), see the Next.js form library comparison — that post covers the same libraries from a Next.js angle and is a better fit if you're on the App Router.

FAQ

What's the best React form library in 2026?

For most apps, React Hook Form (RHF). It has the largest user base, the smallest bundle of any feature-complete library, first-class TypeScript inference, and the best Zod / Valibot / Yup adapters. The exceptions: if you're already in the TanStack ecosystem (Query, Router) pick TanStack Form for consistency; if your forms are 1-3 fields use plain useState and skip the library.

React Hook Form or Formik?

React Hook Form, in 2026, for almost every new project. RHF is ~3x smaller gzipped, uncontrolled by default (so it doesn't re-render on every keystroke), and its TypeScript inference is significantly better. Formik isn't broken — it still works — but it's in maintenance mode, the bundle is heavier, and the controlled-by-default model causes performance issues on forms with 20+ fields. New projects should default to RHF.

Do I need a form library at all?

No, often you don't. If your form has fewer than 5 fields, no field arrays, no async validation, and no complex error UI, plain useState plus the browser's native HTML validation (required, pattern, type=email) is enough. Form libraries pay off when you have 10+ fields, dynamic field arrays, cross-field validation, or shared validation between client and server.

Is Formik dead?

Not dead — but in maintenance mode and losing share fast. Formik's last meaningful release was years ago, and most of the ecosystem has moved to RHF. It's still a fine choice if you have an existing Formik codebase; just don't start new projects on it. NPM weekly downloads tell the story: RHF passed Formik in 2022 and is now ~3x ahead.

What's the best TypeScript-first React form library?

React Hook Form. The useForm<T>() generic flows through the whole API — register, watch, setValue, handleSubmit all narrow correctly. TanStack Form is also excellent (and arguably stricter) but the API surface is larger. Formik's TypeScript story is workable but verbose; you end up writing a lot of type assertions.

How do I submit a React form to a backend?

The form library only manages local state. You still need somewhere to POST the data. Options: (1) your own API route (Express, Fastify, Next.js route handler, Remix action); (2) a serverless function (Lambda, Cloudflare Worker); (3) a hosted form backend like splitforms or Formspree if you don't want to run a server. The form library hands you the validated data; the backend persists it.

Can I use splitforms with React Hook Form?

Yes. RHF gives you validated data in handleSubmit; you POST that data to the splitforms /api/submit endpoint with your access key. Five lines of code total. There's a working example in this article. The same pattern works with Formik, TanStack Form, react-final-form, or no library at all.

What's the smallest React form library?

Of the maintained options, react-final-form (~5KB gzipped including final-form core) is the lightest dedicated library. TanStack Form is also small. RHF is ~9KB. Formik is ~13KB plus a Yup dependency that adds another ~30KB. If bundle size is critical and your forms are simple, you can also skip the library entirely — useState + native HTML validation is 0 bytes.

About the author
✻ ✻ ✻

Get your free contact form API key in 60 seconds.

1,000 free form submissions per month. No credit card. No SDK, no PHP, no plugin. Drop one POST endpoint in your form and submissions land in your inbox.

Generate access key →Read the docs
v0.1 · founders pricing locked in · early access open