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:
- The markup layer.
Field,FieldLabel,FieldError,FieldDescription, and their grouping siblings. Pure presentation and accessibility. No knowledge of any form library. Identical across all three integrations. - The state layer. React Hook Form, TanStack Form, or Formisch. Owns values, validation, submission, and array operations. Talks to the markup layer through props.
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.
| Component | Role |
|---|---|
Field | The row wrapper. Carries data-invalid and an orientation prop. |
FieldLabel | Accessible label, linked to its control with htmlFor. |
FieldDescription | Helper text rendered below the control. |
FieldError | Error region. Takes an errors array of { message } objects. |
FieldGroup | Stacks related fields with consistent spacing. |
FieldSet + FieldLegend | Semantic grouping for a set of related controls (a native fieldset/legend). |
FieldContent, FieldTitle | Layout 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:
data-invalidgoes on theFieldwrapper. It is a styling hook. Your CSS targets[data-invalid] ...to turn labels, borders, and helper text into the error color. It does nothing for accessibility on its own.aria-invalidgoes on the actual control (Input,SelectTrigger,Checkbox, and so on). This is the accessibility half: it is what a screen reader announces.
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:
"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:
mode | First validation | Feels like |
|---|---|---|
onSubmit (default) | On submit only | Quiet until you try to submit. Can feel like a wall of errors at the end. |
onBlur | When a field loses focus | Errors after you leave a field. Calm and predictable. |
onTouched | On first blur, then on every change | Forgiving on first pass, live once touched. The best default for most forms. |
onChange | On every keystroke | Immediate. Good for password rules, noisy for everything else. |
all | Blur and change | Maximum 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:
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.
"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.
"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:
| Concern | React Hook Form | TanStack Form | Formisch |
|---|---|---|---|
| State model | Uncontrolled (refs) | Controlled (reactive store) | Controlled (signals-shaped) |
| Field binding | Controller + {...field} | form.Field + explicit value/onChange | FormischField + {...field.props} |
| Schema library | Zod via zodResolver | Any Standard Schema, no resolver | Valibot, schema is the source of types |
| Error timing | one mode prop | composed from meta flags | validate + revalidate props |
| Re-render on keystroke | none (uncontrolled) | only the edited field | only the edited field |
| Invalid contract | fieldState.invalid | isTouched && !isValid | field.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:
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:
<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:
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:
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:
// 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(),
})// 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:
- Reach for React Hook Form when you want the least code per field, the largest ecosystem, and the most Stack Overflow answers, and when your forms are not so large that uncontrolled re-render avoidance stops mattering. It is the safe default and it earned that position.
- Reach for TanStack Form when you want per-field reactivity on large or expensive forms, when you value composing validation timing from primitives rather than a single mode, or when you want to stay resolver-free and schema-agnostic across the rest of a TanStack-heavy stack.
- Reach for Formisch when array and reordering operations are central, when you have standardized on Valibot, or when you want the most precise separation between first-validation and re-validation timing without hand-assembling it.
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.