Skip to main content

TanStack Form vs react-hook-form vs Conform: React Form Libraries in 2026

·PkgPulse Team

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

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 →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.