Skip to main content

Guide

TanStack Form vs react-hook-form vs Conform 2026

Compare TanStack Form v1, react-hook-form v7, and Conform for React forms in 2026. Type safety, server actions, Zod integration, performance, and key tradeoffs.

·PkgPulse Team·
0

TL;DR

react-hook-form is the pragmatic default — most widely used, excellent performance via uncontrolled inputs, mature ecosystem, and works in any React setup. TanStack Form v1 (stable in 2025) brings the TanStack philosophy to forms — end-to-end type safety, framework-agnostic, and deep async validation. Conform is the modern choice for Next.js App Router and Remix — it's designed around server actions and progressive enhancement, treating the server as the source of truth. The choice increasingly depends on your data flow model.

Key Takeaways

  • react-hook-form: ~4.5M weekly downloads — most popular, uncontrolled inputs, best performance
  • @tanstack/react-form: ~600K weekly downloads — end-to-end types, framework-agnostic, async validation
  • conform-to: ~300K weekly downloads — server-first, progressive enhancement, Next.js/Remix native
  • react-hook-form for most React apps — the safe, proven choice
  • Conform for Next.js App Router with Server Actions or Remix with Actions
  • TanStack Form if you're already in the TanStack ecosystem

PackageWeekly DownloadsApproachServer ActionsControlled
react-hook-form~4.5MUncontrolled refsOpt-in
@tanstack/react-form~600KControlled⚠️ Manual
conform-to~300KServer-first✅ Native

react-hook-form

react-hook-form uses uncontrolled inputs (refs, not state) — this means minimal re-renders and excellent performance for large forms.

Basic Form

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

const schema = z.object({
  packageName: z.string().min(1, "Required").max(214, "Too long"),
  threshold: z.coerce.number().min(1, "Must be at least 1"),
  email: z.string().email("Invalid email"),
  alertType: z.enum(["downloads_drop", "version_update", "security"]),
})

type FormData = z.infer<typeof schema>

function CreateAlertForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting, isDirty },
    setError,
    reset,
  } = useForm<FormData>({
    resolver: zodResolver(schema),
    defaultValues: {
      alertType: "downloads_drop",
      threshold: 10,
    },
  })

  const onSubmit = async (data: FormData) => {
    try {
      await createAlert(data)
      reset()  // Clear form on success
    } catch (err) {
      // Set server-side errors:
      setError("packageName", {
        type: "server",
        message: "Package not found in registry",
      })
    }
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate>
      <div>
        <label htmlFor="packageName">Package Name</label>
        <input
          id="packageName"
          {...register("packageName")}  // ← registers input without causing re-renders
          placeholder="e.g. react"
        />
        {errors.packageName && <p role="alert">{errors.packageName.message}</p>}
      </div>

      <div>
        <label htmlFor="threshold">Drop Threshold (%)</label>
        <input id="threshold" type="number" {...register("threshold")} />
        {errors.threshold && <p role="alert">{errors.threshold.message}</p>}
      </div>

      <div>
        <label htmlFor="email">Alert Email</label>
        <input id="email" type="email" {...register("email")} />
        {errors.email && <p role="alert">{errors.email.message}</p>}
      </div>

      <select {...register("alertType")}>
        <option value="downloads_drop">Downloads Drop</option>
        <option value="version_update">Version Update</option>
        <option value="security">Security Advisory</option>
      </select>

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

Controller for Controlled Components

import { useForm, Controller } from "react-hook-form"

// For custom UI components (DatePicker, Select, etc.):
function PackageSearchForm() {
  const { control, handleSubmit } = useForm<FormData>()

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {/* Controlled component — e.g., shadcn Select: */}
      <Controller
        name="category"
        control={control}
        rules={{ required: "Please select a category" }}
        render={({ field, fieldState }) => (
          <Select value={field.value} onValueChange={field.onChange}>
            <SelectTrigger>
              <SelectValue placeholder="Select category" />
            </SelectTrigger>
            <SelectContent>
              <SelectItem value="ui">UI Components</SelectItem>
              <SelectItem value="utils">Utilities</SelectItem>
              <SelectItem value="database">Database</SelectItem>
            </SelectContent>
          </Select>
        )}
      />
    </form>
  )
}

TanStack Form

@tanstack/react-form is the full-stack-first approach to forms — deeply typed with first-class support for async server validation.

Basic Form

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

function CreateAlertForm() {
  const form = useForm({
    defaultValues: {
      packageName: "",
      threshold: 10,
      email: "",
    },
    onSubmit: async ({ value }) => {
      // value is fully typed — TypeScript infers from schema
      await createAlert(value)
    },
    validatorAdapter: zodValidator(),
  })

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault()
        e.stopPropagation()
        form.handleSubmit()
      }}
    >
      <form.Field
        name="packageName"
        validators={{
          onChange: z.string().min(1, "Required"),
          // Async validatorruns after onChange debounce:
          onChangeAsync: z.string().refine(
            async (val) => {
              const exists = await checkPackageExists(val)
              return exists
            },
            "Package not found"
          ),
          onChangeAsyncDebounceMs: 500,
        }}
        children={(field) => (
          <div>
            <label htmlFor={field.name}>Package Name</label>
            <input
              id={field.name}
              value={field.state.value}
              onBlur={field.handleBlur}
              onChange={(e) => field.handleChange(e.target.value)}
            />
            {field.state.meta.errors.map((error) => (
              <p key={error} role="alert">{error}</p>
            ))}
          </div>
        )}
      />

      <form.Field
        name="threshold"
        validators={{ onChange: z.number().min(1) }}
        children={(field) => (
          <div>
            <label htmlFor={field.name}>Threshold %</label>
            <input
              id={field.name}
              type="number"
              value={field.state.value}
              onChange={(e) => field.handleChange(Number(e.target.value))}
            />
            {field.state.meta.errors.join(", ")}
          </div>
        )}
      />

      <form.Subscribe
        selector={(state) => [state.canSubmit, state.isSubmitting]}
        children={([canSubmit, isSubmitting]) => (
          <button type="submit" disabled={!canSubmit}>
            {isSubmitting ? "Creating..." : "Create Alert"}
          </button>
        )}
      />
    </form>
  )
}

TanStack Form's strengths:

// Arrays and nested objects:
const form = useForm({
  defaultValues: {
    packages: [{ name: "", threshold: 10 }],
    settings: { notifyEmail: true, notifySlack: false },
  },
})

// Dynamic field arrays:
<form.Field name="packages" mode="array">
  {(field) => (
    <>
      {field.state.value.map((_, i) => (
        <form.Field key={i} name={`packages[${i}].name`}>
          {/* ... */}
        </form.Field>
      ))}
      <button onClick={() => field.pushValue({ name: "", threshold: 10 })}>
        Add Package
      </button>
    </>
  )}
</form.Field>

Conform

Conform is built for server-side form validation with Next.js Server Actions and Remix Form Actions:

Next.js App Router with Server Actions

// app/actions.ts — server action:
"use server"
import { parseWithZod } from "@conform-to/zod"
import { z } from "zod"
import { redirect } from "next/navigation"

const schema = z.object({
  packageName: z.string().min(1),
  threshold: z.coerce.number().min(1).max(100),
  email: z.string().email(),
})

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

  // Return errors if invalid:
  if (submission.status !== "success") {
    return submission.reply()
  }

  // Create the alert:
  await db.alert.create({ data: submission.value })

  redirect("/alerts")
}
// app/create-alert/page.tsx — client component:
"use client"
import { useForm, getFormProps, getInputProps } from "@conform-to/react"
import { parseWithZod } from "@conform-to/zod"
import { useActionState } from "react"
import { createAlertAction } from "./actions"
import { z } from "zod"

const schema = z.object({
  packageName: z.string().min(1, "Required"),
  threshold: z.coerce.number().min(1, "Must be positive"),
  email: z.string().email("Invalid email"),
})

export default function CreateAlertPage() {
  const [lastResult, action] = useActionState(createAlertAction, undefined)

  const [form, fields] = useForm({
    lastResult,   // Server errors are fed back into the form
    onValidate({ formData }) {
      // Client-side validation with same schema as server:
      return parseWithZod(formData, { schema })
    },
    shouldValidateOnChange: false,
    shouldRevalidateOnInput: true,
  })

  return (
    <form {...getFormProps(form)} action={action}>
      <div>
        <label htmlFor={fields.packageName.id}>Package Name</label>
        <input
          {...getInputProps(fields.packageName, { type: "text" })}
          placeholder="e.g. react"
        />
        <div id={fields.packageName.errorId}>{fields.packageName.errors}</div>
      </div>

      <div>
        <label htmlFor={fields.threshold.id}>Threshold %</label>
        <input {...getInputProps(fields.threshold, { type: "number" })} />
        <div id={fields.threshold.errorId}>{fields.threshold.errors}</div>
      </div>

      <div>
        <label htmlFor={fields.email.id}>Alert Email</label>
        <input {...getInputProps(fields.email, { type: "email" })} />
        <div id={fields.email.errorId}>{fields.email.errors}</div>
      </div>

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

Why Conform for Next.js App Router:

  • Form works without JavaScript (true progressive enhancement)
  • Server validation errors are rendered without client-side state management
  • useActionState integration gives typed server action results back to the form
  • Same Zod schema on both client and server — no duplication

Feature Comparison

Featurereact-hook-formTanStack FormConform
Rendering modelUncontrolledControlledUncontrolled
Server Actions⚠️ Manual✅ Native
Progressive enhancement
TypeScript✅ Excellent
Zod integration✅ @hookform/resolvers✅ @tanstack/zod-form-adapter✅ @conform-to/zod
Async validation✅ Built-in
Field arrays✅ useFieldArray✅ mode="array"
Nested objects
Performance✅ Excellent✅ Good✅ Good
Remix support✅ useForm
File uploads⚠️ Basic⚠️ Basic✅ FormData native

When to Use Each

Choose react-hook-form if:

  • Most React apps without server actions
  • You want the most mature ecosystem with battle-tested patterns
  • Forms with complex UI components (custom selects, date pickers, rich text)
  • Performance-critical forms with many fields

Choose TanStack Form if:

  • You're building with the TanStack ecosystem (Query, Router, Table)
  • Async server validation is a core requirement
  • You want the most type-safe form experience
  • Framework-agnostic — same API for React, Vue, Angular, Svelte

Choose Conform if:

  • Next.js App Router with Server Actions
  • Remix with form Actions
  • Progressive enhancement matters (forms work without JS)
  • Server is the source of truth for validation

shadcn/ui Integration and Real-World Component Patterns

All three libraries need to work with custom UI component libraries, and the integration story varies significantly. react-hook-form's Controller component is the standard bridge for any controlled component — shadcn/ui's Select, Checkbox, RadioGroup, and DatePicker all use Controller with field.value and field.onChange. The shadcn/ui documentation explicitly shows react-hook-form + Zod as the canonical form pattern, so there are copy-paste-ready examples for every component in the library. This ecosystem alignment means react-hook-form has the lowest friction when assembling forms from UI kit components.

TanStack Form's form.Field render-prop API requires slightly more boilerplate for custom components — you spread field.state.value and field.handleChange onto the component props manually rather than using a Controller abstraction. The payoff is more explicit data flow: you can see exactly where the value comes from and where changes go. For complex custom inputs that need to interact with field metadata (like showing a character counter that reads from field.state.meta.errors), TanStack Form's approach produces cleaner code than nesting Controller callbacks.

Conform generates form attribute objects (getInputProps, getSelectProps, getTextareaProps) rather than component wrappers. These return plain HTML attribute objects that spread onto native elements or custom components. For shadcn/ui integration, this means passing {...getInputProps(fields.email, { type: "email" })} directly to a shadcn Input component. The approach is less ergonomic than react-hook-form's register spread for simple cases, but it pays off because the same attribute objects work with Server Action forms — the form remains functional even when JavaScript hasn't loaded.

File Uploads and FormData Handling

react-hook-form handles file inputs with register("file") producing a FileList in the form data, but submitting to a server action that expects raw FormData requires an additional serialization step. Most react-hook-form forms use handleSubmit which passes a typed JavaScript object to the submit handler, meaning file uploads need special handling to extract the raw File objects and reconstruct a FormData for server submission.

Conform's native FormData-centric design makes file uploads straightforward. Since Conform works directly with the browser's FormData API (the same data structure that server actions receive), file fields are handled natively — the FormData that Conform manages is the same one that flows to the server action. Parsing with parseWithZod supports file validation via Zod's z.instanceof(File) alongside string fields. This is a meaningful advantage for upload-heavy forms like profile photo editors, document uploaders, or content management tools.

TanStack Form v1 has basic file input support but treats file uploads as a less-common scenario compared to its core typed-value approach. Teams building complex multi-file upload interfaces with progress tracking typically reach for specialized libraries (react-dropzone, uppy) alongside their form library rather than relying on form library file primitives.

Validation Timing Strategies

Each library offers different built-in strategies for when validation runs, and choosing the right one significantly affects perceived form quality. react-hook-form's default is mode: "onSubmit" — validation only runs on submit, which prevents errors appearing while the user is still typing. Changing to mode: "onChange" or mode: "onTouched" (validate after blur) is a one-line config change. The reValidateMode: "onChange" option controls re-validation after the first submit — useful for showing green checkmarks as users fix errors.

TanStack Form exposes validators.onChange, validators.onBlur, and validators.onSubmit as separate functions per field, giving the finest control. You can run a cheap client-side regex check on change, an expensive async API call on blur, and a final server validation on submit — all using different validators on the same field. The onChangeAsyncDebounceMs option prevents rapid-fire async validation calls while the user types.

Conform defers most validation timing to the server action by default, but the shouldValidateOnChange and shouldRevalidateOnInput options enable progressive client-side validation. The typical Conform pattern validates on submit (server-side) first to establish correctness, then adds shouldRevalidateOnInput: true to clear field errors as users fix them — a pattern that works even with JavaScript disabled for the initial submit.


Methodology

Download data from npm registry (weekly average, February 2026). Feature comparison based on react-hook-form v7.x, @tanstack/react-form v1.x, and conform-to v1.x documentation.

Compare form library packages on PkgPulse →

The server action ecosystem around Conform deserves specific mention for Next.js App Router developers. Conform works with useActionState (the React 19 API that replaced useFormState) to feed server action results back to the form state without any client-side state management. This means the round-trip from form submit to error display to corrected re-submit is entirely server-driven: the server action runs, returns a submission.reply() with field-level errors, and Conform's useForm hook automatically maps those errors back to the correct field error elements by field name. For teams who have tried to implement this pattern manually with react-hook-form and setError(), the Conform version is significantly less code and handles the loading state, optimistic UI, and error state transitions correctly by default via React's built-in mechanisms.

A real-world ergonomics difference that affects daily development more than benchmark numbers: react-hook-form's formState.errors object is only populated for fields that have been registered. If you conditionally render fields — a common pattern for multi-step forms or dynamic field arrays — fields that haven't mounted yet will not appear in errors even if the schema would mark them invalid. This is by design (unregistered fields are not validated), but it means that "submit and validate everything" behavior requires calling trigger() explicitly for all field names rather than relying on the submit handler to catch all errors. TanStack Form validates all fields regardless of render state because it stores field values in its own state, not in DOM refs. Conform's server-action approach sidesteps this entirely — the full FormData including all named inputs is validated server-side on submit.

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.