React Hook Form vs TanStack Form: Which One Should You Actually Use?

Forms are where front-end development gets genuinely painful. Not because forms are hard — they’re not — but because every project eventually produces the same mess: scattered validation logic, race conditions in async checks, and components that re-render on every keystroke for no good reason.

Two libraries dominate the modern React ecosystem right now: React Hook Form (RHF), the battle-tested incumbent with millions of weekly downloads, and TanStack Form, the newer challenger from Tanner Linsley’s increasingly influential TanStack family. Both solve the same core problem. They solve it very differently, and picking the wrong one for your use case will cost you.

This article walks through both libraries side by side — setup, validation, async patterns, TypeScript experience, and performance — with real code, not toy examples.


The Contestants

React Hook Form lives at github.com/react-hook-form/react-hook-form. It’s been around since 2019, hit v7 in 2021, and has stayed remarkably stable since. The philosophy: lean on native HTML form inputs, use uncontrolled components by default, minimize re-renders.

TanStack Form lives at github.com/TanStack/form. It’s the youngest sibling of TanStack Query and TanStack Router. Framework-agnostic by design — React is just one adapter. The philosophy: explicit, type-safe, composable field-level control with zero surprises.


Setup and First Impressions

# React Hook Form
npm install react-hook-form

# TanStack Form (React adapter)
npm install @tanstack/react-form

# Schema validation adapters — pick your poison
npm install zod @hookform/resolvers          # RHF + Zod
npm install zod @tanstack/zod-form-adapter   # TanStack Form + Zod

The bare-minimum login form in RHF looks like this:

import { useForm } from 'react-hook-form'

type LoginForm = {
  email: string
  password: string
}

export function LoginForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<LoginForm>()

  const onSubmit = async (data: LoginForm) => {
    await loginUser(data)
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input
        type="email"
        {...register('email', { required: 'Email is required' })}
      />
      {errors.email && <span>{errors.email.message}</span>}

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

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

Clean, familiar, and it works exactly how you’d expect. The register spread is the "uncontrolled component" trick — RHF attaches refs and event handlers without keeping value in React state.

The same form in TanStack Form:

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

export function LoginForm() {
  const form = useForm({
    defaultValues: {
      email: '',
      password: '',
    },
    onSubmit: async ({ value }) => {
      await loginUser(value)
    },
    validatorAdapter: zodValidator(),
  })

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

      {/* password field omitted for brevity */}

      <form.Subscribe selector={(state) => state.isSubmitting}>
        {(isSubmitting) => (
          <button type="submit" disabled={isSubmitting}>
            Login
          </button>
        )}
      </form.Subscribe>
    </form>
  )
}

More verbose upfront, no question. The render-prop pattern (<form.Field>) and the <form.Subscribe> component are deliberate — they’re how TanStack Form achieves surgical re-renders without you having to think about it.


Validation: The Meat of It

Sync validation

RHF gives you two modes: inline rules (the register options) or a schema resolver. For anything beyond trivial, you want the resolver:

import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'

const schema = z.object({
  email: z.string().email(),
  password: z.string().min(8, 'At least 8 characters'),
  confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
  message: "Passwords don't match",
  path: ['confirmPassword'],
})

const { register, handleSubmit, formState: { errors } } = useForm({
  resolver: zodResolver(schema),
})

This is RHF at its best. The schema lives outside the component, validation runs on submit by default (configurable via mode: 'onChange'), and the API is intuitive.

TanStack Form validates at the field level by default, but supports form-level validators too:

const form = useForm({
  defaultValues: { email: '', password: '', confirmPassword: '' },
  validators: {
    // Form-level validator — runs after all field validators pass
    onSubmit: ({ value }) => {
      if (value.password !== value.confirmPassword) {
        return 'Passwords do not match'
      }
      return undefined
    },
  },
  validatorAdapter: zodValidator(),
  onSubmit: async ({ value }) => { /* ... */ },
})

Field-level validators go on the field:

<form.Field
  name="password"
  validators={{
    onChange: z.string().min(8, 'At least 8 characters'),
    onBlur: z.string().min(8), // can attach to different events
  }}
>

The granularity here is genuinely useful. You can validate onChange for some fields and onBlur for others, per field, without touching global config.

Async validation

This is where the two libraries diverge significantly.

RHF handles async validation inside the resolver — you write a custom resolver or use a validate function that returns a Promise:

<input
  {...register('username', {
    validate: async (value) => {
      const taken = await checkUsernameAvailable(value)
      return taken ? true : 'Username already taken'
    },
  })}
/>

Simple, and it works. But there’s a catch: RHF doesn’t debounce this for you. If your field validates onChange, that async call fires on every keystroke. You need to handle debouncing yourself, usually by wrapping the validate function.

TanStack Form has async validation as a first-class concern:

<form.Field
  name="username"
  validators={{
    onChangeAsync: async ({ value }) => {
      const taken = await checkUsernameAvailable(value)
      return taken ? undefined : 'Username already taken'
    },
    onChangeAsyncDebounceMs: 500, // built-in debouncing
  }}
>
  {(field) => (
    <>
      <input
        value={field.state.value}
        onChange={(e) => field.handleChange(e.target.value)}
      />
      {field.state.meta.isValidating && <span>Checking...</span>}
      {field.state.meta.errors}
    </>
  )}
</form.Field>

The onChangeAsyncDebounceMs prop and field.state.meta.isValidating flag are things you’d spend an afternoon building yourself in RHF. TanStack Form also handles cancellation — if a new async validation fires before the previous one resolves, the stale result is discarded. No race conditions by default.


Performance

RHF’s performance story is its strongest selling point. Uncontrolled inputs mean that typing into a field doesn’t trigger React re-renders at all — the DOM handles it. Re-renders only happen when validation state, form state, or subscription values change.

// This re-renders the whole form component on errors change only
const { register, formState: { errors } } = useForm()

The downside: you’re opting into a mental model where React doesn’t know about your field values until submit (or until you use watch). Using watch carelessly is the most common performance pitfall in RHF:

// DON'T do this in a large form — re-renders on every keystroke
const watchedValues = watch()

// DO this instead — watch only what you need
const email = watch('email')

TanStack Form achieves similar performance through a different mechanism: the <form.Subscribe> component and render props mean only the subscribed piece of UI re-renders when the relevant state changes.

// Only re-renders this button when isSubmitting changes
<form.Subscribe selector={(state) => state.isSubmitting}>
  {(isSubmitting) => <button disabled={isSubmitting}>Submit</button>}
</form.Subscribe>

In practice, both libraries are fast enough for any real-world form. Where TanStack Form has a genuine edge is in forms with many fields and complex interdependencies — the fine-grained subscription model makes it easier to avoid accidental cascading re-renders without thinking hard about it.


TypeScript Support

RHF has solid TypeScript support. The useForm<T> generic gives you typed register calls and typed errors. What it won’t catch at compile time: runtime mismatch between your Zod schema and your form type if you define them separately. A common footgun:

// These are technically independent — TypeScript won't catch drift
type LoginForm = { email: string; password: string }
const schema = z.object({ email: z.string(), password: z.string() })

// The safe pattern: derive the type from the schema
type LoginForm = z.infer<typeof schema>

TanStack Form is more aggressively typed by design. Field paths are validated at compile time — if you typo a field name, TypeScript catches it. The value in your onSubmit callback is fully typed from defaultValues without any extra annotation:

const form = useForm({
  defaultValues: { email: '', age: 0 },
  onSubmit: async ({ value }) => {
    // value.email is string, value.age is number — inferred automatically
    console.log(value.age.toFixed(2)) // compiles; TypeScript knows it's a number
  },
})

Field path inference also means form.Field name="emial" fails at compile time instead of silently doing nothing. For large forms maintained by multiple people, this matters.


Gotchas

React Hook Form gotchas:

  • watch is expensive. Calling watch() without arguments subscribes your component to every field change. In a 20-field form, that’s 20× the re-renders you need. Always watch specific fields.
  • Controlled inputs need Controller. Third-party inputs like Select2, react-select, or date pickers don’t expose a native ref. You need <Controller> or useController. Easy to forget when you’re in the flow.
  • defaultValues matters more than you think. Changing defaultValues after mount doesn’t update field values unless you call reset(). Stale defaults are a common source of bugs when data loads asynchronously.
  • Async debouncing is on you. As mentioned — validate with async logic and mode: 'onChange' will hammer your API. Build or import a debounce wrapper.

TanStack Form gotchas:

  • The render-prop API feels unfamiliar. Coming from RHF, the <form.Field>{(field) => ...}</form.Field> pattern takes an hour to internalize. It’s not bad — it’s just different.
  • Verbosity at scale. A 15-field form with TanStack Form produces noticeably more JSX than the equivalent in RHF. Whether that’s "explicit and maintainable" or "tedious" depends on your team.
  • Fewer ready-made UI library integrations. RHF has <Controller> adapters for every major component library already documented. TanStack Form is newer; you’ll write more glue code for react-select, Radix, etc.
  • Schema adapter is a separate install. Unlike RHF where @hookform/resolvers covers Zod, Yup, Joi, and Valibot in one package, TanStack Form has individual adapters. Minor friction, but worth knowing upfront.

Production-Ready Patterns

Async username check with proper cancellation (TanStack Form):

<form.Field
  name="username"
  validators={{
    onChangeAsync: async ({ value, signal }) => {
      // signal is an AbortSignal — pass it to fetch for true cancellation
      const res = await fetch(`/api/check-username?u=${value}`, { signal })
      const { available } = await res.json()
      return available ? undefined : 'Username taken'
    },
    onChangeAsyncDebounceMs: 400,
  }}
>

Global error handling on submit (RHF):

const { setError, handleSubmit } = useForm()

const onSubmit = async (data) => {
  try {
    await submitForm(data)
  } catch (err) {
    if (err.fieldErrors) {
      // Map server-side field errors back onto the form
      Object.entries(err.fieldErrors).forEach(([field, message]) => {
        setError(field, { type: 'server', message })
      })
    } else {
      setError('root.serverError', { type: 'server', message: err.message })
    }
  }
}

Avoid re-renders on watch with a custom selector hook (RHF):

// Watches a single field with stable reference
function useField<T>(name: string) {
  return useWatch<T>({ name }) // useWatch is more re-render-efficient than watch()
}

When to Use Which

Pick React Hook Form when:

  • Your team already knows it, or the project is mid-flight
  • You’re building straightforward forms and want minimal boilerplate
  • You need broad third-party component library compatibility out of the box
  • You’re adding forms to an existing codebase and want zero migration risk

Pick TanStack Form when:

  • You’re starting a greenfield project and already use TanStack Query/Router
  • You need real async validation with debouncing and cancellation without rolling your own
  • TypeScript correctness matters deeply — typo-proof field names, inferred submit types
  • The form is complex enough that explicit subscriptions are preferable to "who’s watching what?"
  • You’re building something framework-agnostic that might ship a Vue or Solid version later

The Honest Verdict

React Hook Form isn’t going anywhere. It’s mature, fast, well-documented, and the ecosystem around it — resolvers, DevTools, integrations — is genuinely excellent. If you’re productive in it, there’s no reason to migrate.

TanStack Form is the better design. The explicit subscription model, first-class async support, and TypeScript-first API are all clearly superior choices in isolation. The trade-off is verbosity and a smaller, less mature ecosystem.

The practical answer: if you’re starting a new project in 2026 and especially if you’re already on the TanStack ecosystem, reach for TanStack Form. For everything else, React Hook Form is still a great choice — just respect the watch gotchas.

👁 Views: 202,912 · Unique visitors: 57,425