All posts
19 min read

shadcn/ui Forms: React Hook Form, TanStack Form, and Formisch on One Field System

shadcn no longer ships a form abstraction. It ships form markup, a library-agnostic Field primitive set, and lets you bring your own state engine. Here is the shared substrate explained once, then React Hook Form, TanStack Form, and Formisch wired into the exact same components, with the validation, array, and server-boundary details the official docs leave out.

shadcnformsreact-hook-formtanstack-formvalidation

For two years the answer to "how do I build a form with shadcn/ui" was a single import: Form, FormField, FormItem, FormControl, FormMessage. That set was welded to React Hook Form. The FormField component wrapped RHF's Controller, and FormMessage read from RHF's error context. If you wanted a different state library you were on your own, re-implementing the markup by hand.

That is no longer how it works. The current shadcn forms guides describe a different architecture, and the shift is easy to miss because the end result looks similar. shadcn stopped shipping a form abstraction and started shipping form markup: a set of unstyled, framework-neutral Field primitives with one styling contract. The state engine is now yours to choose. The official docs prove it by wiring the same components to three different libraries, React Hook Form, TanStack Form, and Formisch.

Each of those docs shows one library in isolation and stops there. None of them explains the layer underneath that all three share, or how the choice between them actually plays out on a real form. That gap is what this post fills. We will build the shared substrate once, wire all three engines into it, and then get into the parts nobody documents: when errors should appear, how array fields differ, and where the whole thing sits relative to the server.

The reframe: markup is the product, state is a plugin

The reason this decoupling matters is the same reason the copy-paste model matters at all. When shadcn hands you the Field components as source you own, "which form library" stops being a framework decision baked into a dependency and becomes a local wiring detail you can change per form. We wrote about why owning the source beats depending on a package in Why the Copy-Paste Component Model Beats the Library; form state is the cleanest example of that principle paying off. The button you copied does not care who owns its click handler. The Field you copied does not care who owns its value.

So the mental model is two layers, kept strictly apart:

Get the markup layer right once and the state layer becomes swappable. That is the whole thesis. Let us build the markup layer first, because it is the part all three share and the part the docs gloss over fastest.

The Field primitives, explained once

Here is the full cast. Every one of these is plain markup with design tokens, and every form integration below uses the same components with zero changes.

ComponentRole
FieldThe row wrapper. Carries data-invalid and an orientation prop.
FieldLabelAccessible label, linked to its control with htmlFor.
FieldDescriptionHelper text rendered below the control.
FieldErrorError region. Takes an errors array of { message } objects.
FieldGroupStacks related fields with consistent spacing.
FieldSet + FieldLegendSemantic grouping for a set of related controls (a native fieldset/legend).
FieldContent, FieldTitleLayout helpers for richer field rows (title plus control plus description).

Two attributes carry the entire invalid-state contract, and understanding them is what makes all three libraries interchangeable:

You need both, on different elements, because they do different jobs. This split is deliberate and it is the same reasoning behind the accessibility model in the primitives themselves, which we covered in Base UI Is the New shadcn/ui Default. Styling state and announced state are not the same state, and conflating them is how you ship a form that looks broken but reads fine, or reads broken but looks fine.

FieldError is not a message string, it is a contract

FieldError does not accept text. It accepts errors, an array of objects shaped { message: string }. That shape is the seam that lets one component render errors from three libraries. React Hook Form hands it [fieldState.error], which already has a .message. TanStack Form hands it field.state.meta.errors directly. Formisch hands it an array of strings that you map into { message }. Same component, three source shapes, one prop.

The other thing worth internalizing before we touch state: a form is a client island. Every one of these integrations runs in a component that starts with "use client", because forms are interaction by definition. The interesting engineering question is not whether the form is a client component, it is how small you can keep that island and where its boundary sits relative to the server work around it. That is a whole topic on its own, and we get to it at the end, but keep Where the use client Boundary Actually Goes in the back of your mind: the form is the clearest case of a leaf that should be client while everything around it stays on the server.

Now, three engines, same markup.

React Hook Form: the uncontrolled default

React Hook Form is the incumbent, and its defining trait is that it is uncontrolled. It keeps values in refs, not React state, so typing into an input does not re-render your component tree. You reach into that world through Controller, which bridges an uncontrolled store to a controlled component like shadcn's Input.

The setup is a useForm call with a Zod resolver:

tsx
"use client"

import { Controller, useForm, useFieldArray } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import * as z from "zod"

import { Button } from "@/components/ui/button"
import { Field, FieldLabel, FieldError, FieldGroup } from "@/components/ui/field"
import { Input } from "@/components/ui/input"

const schema = z.object({
  name: z.string().min(2, "Use at least 2 characters."),
  email: z.string().email("That does not look like an email."),
})

type Values = z.infer<typeof schema>

export function SignUpForm() {
  const form = useForm<Values>({
    resolver: zodResolver(schema),
    mode: "onTouched",
    defaultValues: { name: "", email: "" },
  })

  function onSubmit(values: Values) {
    console.log("submit", values)
  }

  return (
    <form onSubmit={form.handleSubmit(onSubmit)}>
      <FieldGroup>
        <Controller
          name="name"
          control={form.control}
          render={({ field, fieldState }) => (
            <Field data-invalid={fieldState.invalid}>
              <FieldLabel htmlFor="name">Name</FieldLabel>
              <Input {...field} id="name" aria-invalid={fieldState.invalid} />
              {fieldState.invalid && <FieldError errors={[fieldState.error]} />}
            </Field>
          )}
        />
        <Controller
          name="email"
          control={form.control}
          render={({ field, fieldState }) => (
            <Field data-invalid={fieldState.invalid}>
              <FieldLabel htmlFor="email">Email</FieldLabel>
              <Input {...field} id="email" type="email" aria-invalid={fieldState.invalid} />
              {fieldState.invalid && <FieldError errors={[fieldState.error]} />}
            </Field>
          )}
        />
      </FieldGroup>
      <Button type="submit" className="mt-4">Create account</Button>
    </form>
  )
}

Two things carry the whole pattern. {...field} spreads value, onChange, onBlur, name, and ref onto the Input in one shot, which is why RHF fields are so terse. And fieldState.invalid drives both the data-invalid styling hook and the aria-invalid announcement, from the same source, so they can never drift apart.

The prop you will tune most is mode, and it is the one most people set once and never revisit. It decides when validation first fires for a field:

modeFirst validationFeels like
onSubmit (default)On submit onlyQuiet until you try to submit. Can feel like a wall of errors at the end.
onBlurWhen a field loses focusErrors after you leave a field. Calm and predictable.
onTouchedOn first blur, then on every changeForgiving on first pass, live once touched. The best default for most forms.
onChangeOn every keystrokeImmediate. Good for password rules, noisy for everything else.
allBlur and changeMaximum feedback, maximum noise.

onTouched is the setting to reach for by default. It does not scold you before you have finished typing a field the first time, but once you have interacted with a field it corrects you live. That is the interaction most users read as "helpful" rather than "impatient." The docs default to onSubmit, which is the safe choice for a code sample and rarely the right choice for a product.

Here is a real sign-up card built on exactly this markup layer, rendered live:

Centered Sign Up Card
View block
Loading preview…
A Centered Sign Up Card built on the same Field primitives every integration below shares.

TanStack Form: controlled, granular, resolver-free

TanStack Form takes the opposite stance from React Hook Form on almost every axis, and the trade is worth understanding rather than treating the two as interchangeable. It is controlled: values live in a reactive store, and each field subscribes only to its own slice. There is no Controller bridge because there is nothing to bridge; the field component is the subscription. There is also no resolver package. You hand the schema straight to a validators object, and TanStack Form speaks the Standard Schema interface that Zod, Valibot, and ArkType all implement.

tsx
"use client"

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

import { Button } from "@/components/ui/button"
import { Field, FieldLabel, FieldError, FieldGroup } from "@/components/ui/field"
import { Input } from "@/components/ui/input"

const schema = z.object({
  name: z.string().min(2, "Use at least 2 characters."),
  email: z.string().email("That does not look like an email."),
})

export function SignUpForm() {
  const form = useForm({
    defaultValues: { name: "", email: "" },
    validators: { onChange: schema, onSubmit: schema },
    onSubmit: async ({ value }) => {
      console.log("submit", value)
    },
  })

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault()
        form.handleSubmit()
      }}
    >
      <FieldGroup>
        <form.Field
          name="name"
          children={(field) => {
            const invalid = field.state.meta.isTouched && !field.state.meta.isValid
            return (
              <Field data-invalid={invalid}>
                <FieldLabel htmlFor="name">Name</FieldLabel>
                <Input
                  id="name"
                  name={field.name}
                  value={field.state.value}
                  onBlur={field.handleBlur}
                  onChange={(e) => field.handleChange(e.target.value)}
                  aria-invalid={invalid}
                />
                {invalid && <FieldError errors={field.state.meta.errors} />}
              </Field>
            )
          }}
        />
        <form.Field
          name="email"
          children={(field) => {
            const invalid = field.state.meta.isTouched && !field.state.meta.isValid
            return (
              <Field data-invalid={invalid}>
                <FieldLabel htmlFor="email">Email</FieldLabel>
                <Input
                  id="email"
                  type="email"
                  name={field.name}
                  value={field.state.value}
                  onBlur={field.handleBlur}
                  onChange={(e) => field.handleChange(e.target.value)}
                  aria-invalid={invalid}
                />
                {invalid && <FieldError errors={field.state.meta.errors} />}
              </Field>
            )
          }}
        />
      </FieldGroup>
      <Button type="submit" className="mt-4">Create account</Button>
    </form>
  )
}

The wiring is more verbose than RHF, and that is the honest cost of the model. Where RHF spreads {...field}, TanStack Form asks you to bind value, onChange, and onBlur explicitly, and onChange reads e.target.value because the field handler wants a value, not an event. In exchange you get two things that RHF makes harder.

First, the invalid signal is composed from primitives you can read and reshape: field.state.meta.isTouched and field.state.meta.isValid. The invalid local here is "touched and not valid," which reproduces onTouched behavior by hand. Because you are computing it, you can change the rule per field without a global mode switch. A password field can go live immediately while the rest of the form waits for blur, with no library-level configuration.

Second, the reactivity is genuinely granular. Each form.Field is its own subscriber, so a keystroke in the email field re-renders the email field and nothing else. On a short form that is invisible. On a form with fifty fields, or fields that render expensive children, it is the difference between smooth and sluggish. This is the same bundle-and-render discipline that decides whether a page feels fast, and it is worth being deliberate about which model you are buying into.

Standard Schema means the resolver package disappears

Because TanStack Form validates against the Standard Schema interface directly, there is no @hookform/resolvers equivalent to install. You pass the Zod (or Valibot, or ArkType) schema straight into validators. One fewer dependency, and switching schema libraries later touches only the import, not the form wiring.

Formisch: schema-first with a signals-shaped API

Formisch is the newest of the three and the most opinionated. It is built around Valibot rather than Zod, and it treats the schema as the literal single source of truth for both the runtime validation and the static types. There is no separate type Values; the form derives its types from the schema. The API also reads differently from the other two: fields are addressed by a path array, values come back as field.input, and the mutation helpers (reset, insert, remove, move) are top-level functions you call with the form as the first argument, a shape that will feel familiar if you have used a signals library.

tsx
"use client"

import { Form, Field as FormischField, useForm } from "@formisch/react"
import type { SubmitHandler } from "@formisch/react"
import * as v from "valibot"

import { Button } from "@/components/ui/button"
import { Field, FieldLabel, FieldError, FieldGroup } from "@/components/ui/field"
import { Input } from "@/components/ui/input"

const FormSchema = v.object({
  name: v.pipe(v.string(), v.minLength(2, "Use at least 2 characters.")),
  email: v.pipe(v.string(), v.email("That does not look like an email.")),
})

export function SignUpForm() {
  const form = useForm({
    schema: FormSchema,
    initialInput: { name: "", email: "" },
    validate: "blur",
    revalidate: "input",
  })

  const handleSubmit: SubmitHandler<typeof FormSchema> = (output) => {
    console.log("submit", output)
  }

  return (
    <Form of={form} onSubmit={handleSubmit}>
      <FieldGroup>
        <FormischField of={form} path={["name"]}>
          {(field) => (
            <Field data-invalid={field.errors !== null}>
              <FieldLabel htmlFor="name">Name</FieldLabel>
              <Input
                {...field.props}
                id="name"
                value={field.input ?? ""}
                aria-invalid={field.errors !== null}
              />
              {field.errors && (
                <FieldError errors={field.errors.map((message) => ({ message }))} />
              )}
            </Field>
          )}
        </FormischField>
        <FormischField of={form} path={["email"]}>
          {(field) => (
            <Field data-invalid={field.errors !== null}>
              <FieldLabel htmlFor="email">Email</FieldLabel>
              <Input
                {...field.props}
                id="email"
                type="email"
                value={field.input ?? ""}
                aria-invalid={field.errors !== null}
              />
              {field.errors && (
                <FieldError errors={field.errors.map((message) => ({ message }))} />
              )}
            </Field>
          )}
        </FormischField>
      </FieldGroup>
      <Button type="submit" className="mt-4">Create account</Button>
    </Form>
  )
}

Notice three details that are distinct to Formisch. The <Form of={form}> wrapper owns submission, so onSubmit receives the parsed output directly rather than you calling a handleSubmit yourself. The {...field.props} spread handles name, ref, and the event handlers, which brings the per-field verbosity back down toward RHF levels, while value={field.input ?? ""} stays explicit so the input never goes uncontrolled on an undefined initial value. And validation timing is split into two separate props: validate controls when a field is checked the first time, revalidate controls when it is re-checked after it already has an error. That separation is the most precise control over error timing of any of the three, and it maps directly onto the interaction most people actually want.

validate plus revalidate is the UX most forms are reaching for

The pattern users read as polite is: do not flag a field until they leave it (validate: "blur"), but once it is showing an error, correct it live as they fix it (revalidate: "input"). React Hook Form approximates this with the single onTouched mode; TanStack Form makes you assemble it from isTouched and isValid. Formisch gives it to you as two named props. If error timing is something you care about tuning, this is the cleanest model of the three.

The same decisions, three shapes

Strip away the syntax and every integration answers the same four questions. Seeing them side by side is more useful than any single example, because it shows what you are actually choosing between:

ConcernReact Hook FormTanStack FormFormisch
State modelUncontrolled (refs)Controlled (reactive store)Controlled (signals-shaped)
Field bindingController + {...field}form.Field + explicit value/onChangeFormischField + {...field.props}
Schema libraryZod via zodResolverAny Standard Schema, no resolverValibot, schema is the source of types
Error timingone mode propcomposed from meta flagsvalidate + revalidate props
Re-render on keystrokenone (uncontrolled)only the edited fieldonly the edited field
Invalid contractfieldState.invalidisTouched && !isValidfield.errors !== null

The FieldError component sits at the bottom of all three unchanged, because each integration converges its native error shape onto the same { message } array. That convergence is the payoff of the whole architecture. The markup did not bend to the library. The library bent to the markup.

Array fields: where the abstractions diverge most

Single fields look similar across the three. Dynamic collections, adding and removing rows, are where the models show their real differences, and it is also where the docs are thinnest. Consider a form that collects one or more email addresses.

React Hook Form uses the useFieldArray hook. It returns a stable fields array whose id you must use as the React key, plus append and remove:

tsx
const { fields, append, remove } = useFieldArray({ control: form.control, name: "emails" })

// ...
{fields.map((row, index) => (
  <Controller
    key={row.id}
    name={`emails.${index}.address`}
    control={form.control}
    render={({ field, fieldState }) => (
      <Field data-invalid={fieldState.invalid}>
        <Input {...field} aria-invalid={fieldState.invalid} />
        {fieldState.invalid && <FieldError errors={[fieldState.error]} />}
      </Field>
    )}
  />
))}
<Button type="button" onClick={() => append({ address: "" })}>Add email</Button>

The row.id detail is not optional. Using the array index as the key is the classic bug: remove a middle row and React reuses the wrong DOM node, so a field keeps the value that belonged to its neighbor.

TanStack Form makes the parent field array-aware with mode="array" and mutates through handlers on the field itself, pushValue and removeValue:

tsx
<form.Field name="emails" mode="array">
  {(arrayField) => (
    <FieldGroup>
      {arrayField.state.value.map((_, index) => (
        <form.Field key={index} name={`emails[${index}].address`}>
          {(field) => (
            <Field data-invalid={field.state.meta.isTouched && !field.state.meta.isValid}>
              <Input
                value={field.state.value}
                onBlur={field.handleBlur}
                onChange={(e) => field.handleChange(e.target.value)}
              />
            </Field>
          )}
        </form.Field>
      ))}
      <Button type="button" onClick={() => arrayField.pushValue({ address: "" })}>
        Add email
      </Button>
    </FieldGroup>
  )}
</form.Field>

Formisch goes furthest toward treating array operations as first-class. It has a dedicated FieldArray component and a full set of top-level mutators, not just add and remove but move, swap, and replace:

tsx
import { FieldArray, insert, remove } from "@formisch/react"

// ...
<FieldArray of={form} path={["emails"]}>
  {(array) => (
    <>
      {array.items.map((item, index) => (
        <FormischField key={item} of={form} path={["emails", index, "address"]}>
          {(field) => (
            <Field data-invalid={field.errors !== null}>
              <Input {...field.props} value={field.input ?? ""} />
            </Field>
          )}
        </FormischField>
      ))}
      <Button
        type="button"
        onClick={() => insert(form, { path: ["emails"], initialInput: { address: "" } })}
      >
        Add email
      </Button>
    </>
  )}
</FieldArray>

The pattern to notice: RHF keys by a library-generated id, Formisch keys by a stable item token from its items array, and TanStack Form keys by index, which is acceptable there only because its store tracks identity separately from render order. If you build reorderable lists, drag to sort, move up, move down, Formisch's move and swap mean you are not reimplementing array surgery by hand. That is a genuine reason to pick it that has nothing to do with validation.

Multi-step forms lean on the same machinery, splitting fields across steps while one form instance holds the whole value:

Multi-Step Sign Up Wizard
View block
Loading preview…
A Multi-Step Sign Up Wizard. One form instance spans every step; each step renders a slice of the same Field primitives.

Where the form sits relative to the server

All three integrations are client components, and stopping the analysis there is how forms quietly turn entire routes into client bundles. The question worth asking is narrower: what is the smallest client island the form can be, and how does its schema relate to the server that ultimately trusts nothing the client sends?

Two rules keep the boundary honest.

Keep the client island at the leaf. The page, its layout, its headings, and any data it loads should stay server components. Only the interactive form itself carries "use client". A sign-up route is a server component that renders a small SignUpForm client leaf, not a client component that happens to contain some text. The full reasoning, including how the module graph decides what ships, is in Where the use client Boundary Actually Goes, and forms are the textbook case: high interactivity, small surface, easy to isolate.

Share the schema, never trust the client. The schema you validate against in the browser is a UX affordance. It makes errors appear before a round trip. It is not security. The server must validate the same payload again with the same schema before it touches a database. The good news is that a Zod or Valibot schema is a plain module, so it imports cleanly into a server action or a route handler:

tsx
// schema.ts, imported by both the client form and the server action
import * as z from "zod"

export const signUpSchema = z.object({
  name: z.string().min(2),
  email: z.string().email(),
})
tsx
// actions.ts
"use server"

import { signUpSchema } from "./schema"

export async function createAccount(raw: unknown) {
  const parsed = signUpSchema.safeParse(raw)
  if (!parsed.success) {
    return { ok: false, errors: parsed.error.flatten() }
  }
  // parsed.data is trusted now, and only now
  return { ok: true }
}

One schema, imported on both sides, is the concrete win of schema-first validation. The client uses it to render FieldError messages early, the server uses it to gate the write, and neither definition can drift from the other because there is only one definition. This is exactly the leverage that made schema libraries worth adopting in the first place, and it is why the choice of form library above matters less than the fact that all three are schema-driven.

Which one to reach for

There is no universal winner, and anyone who tells you otherwise is selling one library. The decision is a small number of trade-offs:

Because shadcn ships the Field primitives as source you own, this is not a decision you make once for a whole app and regret forever. The markup is shared. Two forms in the same codebase can use two different engines, and migrating one form from RHF to TanStack Form changes the wiring inside the component, not the components themselves. That is the quiet superpower of shipping markup instead of an abstraction: the expensive, opinionated part became the swappable part.

Start with React Hook Form. Move a specific form to TanStack Form or Formisch the day that form gives you a concrete reason to, a fifty-field editor that re-renders too much, or a drag-to-reorder list that wants real array surgery. The Field layer will not notice you switched.