A Shadcn TanStack Form setup gives you three things that are difficult to get from one form stack: fine-grained React updates, validation that is type-safe at compile time and enforced at runtime, and UI components you can change without fighting a library theme.
In this tutorial, you will build a production-ready registration form with TanStack Form, Zod, and shadcn/ui. Along the way, you will learn how to choose a validation strategy, connect accessible error messages, debounce server checks, model complex controls, and avoid the subtle mistakes that make forms frustrating.
The examples focus on the patterns that matter. The repository contains the complete form, including every import and repeated option.
Why TanStack Form, Zod, and shadcn/ui Work Well Together
Each tool has one clear responsibility:
| Tool | Responsibility | Why it matters |
|---|---|---|
| TanStack Form | Field state, subscriptions, validation events, submission | A change can update one field instead of the entire form |
| Zod | Runtime validation and TypeScript inference | One schema describes valid data on both sides of the type boundary |
| shadcn/ui | Inputs, labels, descriptions, errors, and layout | You own the component source and can adapt its markup and styles |
TanStack Form is headless. It does not decide what an input looks like. shadcn/ui does not prescribe where your form state must live. That separation is the reason the pairing feels natural: one system owns behavior, while the other owns presentation.
If you are beginning with an application shell instead of an empty repository, a Shadcn template can provide the navigation, authentication screens, and design tokens around the form.
Before building individual fields, the Shadcn Theme Generator can establish the colors, typography, radius, and component style that the finished form will inherit.
What You Will Build
The finished form demonstrates:
- Text, email, password, number, select, checkbox, switch, and date inputs
- A Zod schema with inferred TypeScript types
- Submit-first validation with immediate revalidation after an error
- Debounced asynchronous username validation
- Cross-field rules such as password confirmation
- Conditional and dependent fields
- Dynamic array fields
- Accessible labels, descriptions, error states, and focus behavior
- Loading, reset, and server-error states
1. Create the Project
Initialize a new shadcn project:
pnpm dlx shadcn@latest init
Choose Next.js when the CLI asks for a framework. You can use either the Base UI or Radix component base; this tutorial relies on the public shadcn Field API and standard React event handling rather than a base-specific trick.
Install the form dependencies:
pnpm add @tanstack/react-form zod date-fns
Add the UI components used throughout the form:
pnpm dlx shadcn@latest add button input textarea select checkbox radio-group switch calendar popover field
After setup, the important part of the project looks like this:
app/
page.tsx
components/
registration-form.tsx
ui/
button.tsx
field.tsx
input.tsx
...
lib/
utils.ts
The generated files live in your repository. If a field needs different spacing, error markup, or focus styling, you edit the component rather than overriding a package from the outside.
2. Model Valid Data with Zod
Start components/registration-form.tsx with a client boundary and a schema:
"use client";
import * as React from "react";
import { revalidateLogic, useForm } from "@tanstack/react-form";
import { z } from "zod";
const registrationSchema = z
.object({
fullName: z
.string()
.trim()
.min(1, "Enter your full name.")
.min(3, "Use at least 3 characters.")
.max(60, "Use 60 characters or fewer."),
email: z
.string()
.trim()
.min(1, "Enter your email address.")
.pipe(z.email("Enter a valid email address.")),
username: z
.string()
.trim()
.min(3, "Use at least 3 characters.")
.max(24, "Use 24 characters or fewer.")
.regex(
/^[a-z0-9_]+$/,
"Use lowercase letters, numbers, and underscores only."
),
password: z
.string()
.min(8, "Use at least 8 characters.")
.regex(/[a-z]/, "Add a lowercase letter.")
.regex(/[A-Z]/, "Add an uppercase letter.")
.regex(/\d/, "Add a number."),
confirmPassword: z.string(),
accountType: z.enum(["personal", "business", "enterprise"]),
companyName: z.string(),
age: z.number().int("Enter a whole number.").min(18, "You must be 18."),
topics: z.array(z.string()).min(1, "Choose at least one topic.").max(3),
productUpdates: z.boolean(),
terms: z.boolean().refine(accepted => accepted, {
message: "Accept the terms to continue.",
}),
country: z.string(),
province: z.string(),
links: z
.array(
z.object({
id: z.string(),
label: z.string().min(1, "Enter a label."),
url: z.url("Enter a valid URL."),
})
)
.max(5, "Add up to five links."),
})
.superRefine((values, context) => {
if (values.password !== values.confirmPassword) {
context.addIssue({
code: "custom",
path: ["confirmPassword"],
message: "Passwords do not match.",
});
}
if (values.accountType !== "personal" && !values.companyName.trim()) {
context.addIssue({
code: "custom",
path: ["companyName"],
message: "Enter a company name for this account type.",
});
}
});
type RegistrationValues = z.infer<typeof registrationSchema>;
There are several useful details here:
.trim()prevents whitespace-only names from passing validation..pipe(z.email())keeps the required message separate from the malformed-email message.- Each password rule has a focused message rather than one vague “invalid password” error.
superRefinehandles rules involving multiple fields.pathattaches a cross-field error to the control where the user can resolve it.
Client validation improves feedback, but it is not a security boundary. Run the same schema, or an equivalent server schema, before trusting submitted data.
3. Initialize TanStack Form
Create a default value for every field. TanStack Form uses this object to infer the form shape, and React controls behave more predictably when text and array fields never begin as undefined.
const defaultValues: RegistrationValues = {
fullName: "",
email: "",
username: "",
password: "",
confirmPassword: "",
accountType: "personal",
companyName: "",
age: 18,
topics: [],
productUpdates: true,
terms: false,
country: "",
province: "",
links: [],
};
export function RegistrationForm() {
const form = useForm({
defaultValues,
validationLogic: revalidateLogic({
mode: "submit",
modeAfterSubmission: "change",
}),
validators: {
onDynamic: registrationSchema,
},
onSubmit: async ({ value }) => {
const response = await fetch("/api/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(value),
});
if (!response.ok) {
throw new Error("Registration failed. Please try again.");
}
},
});
return (
<form
noValidate
onSubmit={event => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
{/* Fields go here */}
</form>
);
}
This validation strategy is a strong general default:
- Before the first submission, users can type without being interrupted.
- Submitting reveals all actionable errors.
- After that first attempt, errors update on change so users can see when each problem is fixed.
noValidate disables the browser’s competing validation bubbles. Semantic attributes such as type="email", required, and autoComplete are still valuable, but one validation system should control the visible messages.
4. Connect a Text Field
Every field needs the same bridge between TanStack Form and shadcn/ui:
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
<form.Field name="fullName">
{field => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>Full name</FieldLabel>
<Input
id={field.name}
name={field.name}
autoComplete="name"
value={field.state.value}
onBlur={field.handleBlur}
onChange={event => field.handleChange(event.target.value)}
aria-invalid={isInvalid}
/>
<FieldDescription>Use the name shown on your account.</FieldDescription>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}}
</form.Field>;
The pieces have separate jobs:
valuecomes from the field’s store subscription.handleChangewrites the next value to that store.handleBlurrecords the interaction and triggers blur validation when configured.data-invalidgives the wrapper an error styling hook.aria-invalidexposes the state to assistive technology.FieldErrorrenders the validation errors associated with the field.
Do not show errors based only on !isValid. An untouched required field starts invalid, so that approach fills a new form with red messages before the user does anything. Gate the message with interaction or your chosen submission strategy.
5. Choose the Right Validation Event
TanStack Form supports field-level and form-level validators. The important question is not merely what to validate, but when feedback becomes useful.
| Event | Runs | Good for |
|---|---|---|
onChange | After each value change | Character limits, password strength, live formatting |
onBlur | When focus leaves the field | Email format, names, most text fields |
onSubmit | When the form is submitted | Final validation and business invariants |
onDynamic | According to validationLogic | Submit-first behavior that changes after submission |
onChangeAsync | After a value change, asynchronously | Username, invite code, or account checks |
Avoid validating an email format on every keystroke. Displaying “invalid email” after the first letter is technically correct but unhelpful. Use blur validation or the submit-first dynamic strategy for calm feedback.
For a field that genuinely benefits from immediate feedback, attach a focused validator:
<form.Field
name="password"
validators={{
onChange: ({ value }) =>
value.length > 0 && value.length < 8
? "Use at least 8 characters."
: undefined,
}}
>
{field => {
// Render the password input.
}}
</form.Field>
Keep field validators narrow. The Zod form schema remains the final definition of valid submitted data.
6. Add Debounced Async Validation
Some questions can only be answered by the server. A username can match every local rule and still be unavailable.
<form.Field
name="username"
asyncDebounceMs={500}
validators={{
onChange: ({ value }) => {
if (value.length < 3) return "Use at least 3 characters.";
if (!/^[a-z0-9_]+$/.test(value)) {
return "Use lowercase letters, numbers, and underscores only.";
}
return undefined;
},
onChangeAsync: async ({ value, signal }) => {
const response = await fetch(
`/api/usernames/${encodeURIComponent(value)}`,
{ signal }
);
const result: { available: boolean } = await response.json();
return result.available ? undefined : "That username is already taken.";
},
}}
>
{field => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>Username</FieldLabel>
<Input
id={field.name}
name={field.name}
autoComplete="username"
value={field.state.value}
onBlur={field.handleBlur}
onChange={event => field.handleChange(event.target.value)}
aria-invalid={isInvalid}
/>
{field.state.meta.isValidating && (
<FieldDescription aria-live="polite">
Checking availability…
</FieldDescription>
)}
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}}
</form.Field>
Three details prevent this from becoming noisy or expensive:
- The synchronous validator runs first, so malformed usernames do not reach the API.
asyncDebounceMswaits for the user to pause instead of sending a request per keystroke.- Passing the supplied
AbortSignaltofetchallows obsolete requests to be cancelled.
The endpoint must still check availability during registration. Async field validation improves the experience; it cannot reserve the username or eliminate a race condition.
7. Handle Numbers Without Accidental Zeros
The browser reports input values as strings, even for type="number". Convert deliberately at the edge:
<form.Field name="age">
{field => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>Age</FieldLabel>
<Input
id={field.name}
name={field.name}
type="number"
min={18}
inputMode="numeric"
value={field.state.value}
onBlur={field.handleBlur}
onChange={event => field.handleChange(event.target.valueAsNumber)}
aria-invalid={isInvalid}
/>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}}
</form.Field>
For a number field that must support an empty editing state, widen the form value to number | undefined and map an empty string to undefined. Do not convert "" with Number(""), because that produces 0 and can turn “not answered” into real data.
8. Build a Checkbox Array
Checkbox groups usually map to an array of identifiers:
const topicOptions = [
{ id: "engineering", label: "Engineering" },
{ id: "design", label: "Design" },
{ id: "product", label: "Product" },
{ id: "company", label: "Company news" },
];
<form.Field name="topics">
{field => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<FieldLabel>Topics</FieldLabel>
<FieldDescription>Choose between one and three.</FieldDescription>
{topicOptions.map(topic => (
<label key={topic.id} className="flex items-center gap-2">
<Checkbox
checked={field.state.value.includes(topic.id)}
onCheckedChange={checked => {
field.handleChange(current =>
checked
? [...current, topic.id]
: current.filter(value => value !== topic.id)
);
}}
/>
<span>{topic.label}</span>
</label>
))}
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}}
</form.Field>
Use the updater form of handleChange when the next array depends on the current array. This avoids maintaining a second piece of React state that can drift out of sync.
For production markup, group related checkboxes in FieldSet with a FieldLegend. A visual heading is not always a semantic group label.
9. Show Conditional Fields Without Losing Control
Business and enterprise accounts require a company name, while personal accounts do not. Subscribe only to the value that controls the conditional UI:
<form.Subscribe selector={state => state.values.accountType}>
{accountType =>
accountType === "personal" ? null : (
<form.Field name="companyName">
{field => (
<Field>
<FieldLabel htmlFor={field.name}>Company name</FieldLabel>
<Input
id={field.name}
name={field.name}
autoComplete="organization"
value={field.state.value}
onBlur={field.handleBlur}
onChange={event => field.handleChange(event.target.value)}
/>
{!field.state.meta.isValid && (
<FieldError errors={field.state.meta.errors} />
)}
</Field>
)}
</form.Field>
)
}
</form.Subscribe>
form.Subscribe is useful inside JSX because its children re-render only when the selected state changes. In component logic, use a selector against the form store rather than subscribing to the entire state object.
Decide what should happen when a conditional field disappears. You can preserve its value in case the user changes their mind, or clear it with a listener if hidden data must never be submitted. Neither behavior is universally correct; make it an explicit product decision.
10. Use Listeners for Effects, Not Errors
Validators answer, “Is this value valid?” Listeners answer, “This value changed; what else should happen?”
For example, changing a country can clear a province that is no longer valid:
<form.Field
name="country"
listeners={{
onChange: () => {
form.setFieldValue("province", "");
},
}}
>
{field => {
// Render the country select.
}}
</form.Field>
Listeners are also appropriate for draft persistence and suggestions. Debounce an expensive effect with onChangeDebounceMs:
listeners={{
onChangeDebounceMs: 600,
onChange: ({ value }) => saveDraft(value),
}}
Do not return validation messages from listeners. Keeping effects and validity separate makes the form easier to reason about and test.
11. Add Dynamic Array Fields
Use mode="array" for lists the user can add to or remove from:
type Link = {
id: string;
label: string;
url: string;
};
<form.Field name="links" mode="array">
{linksField => (
<div className="space-y-4">
{linksField.state.value.map((link, index) => (
<div key={link.id} className="grid gap-3 sm:grid-cols-[1fr_1fr_auto]">
<form.Field name={`links[${index}].label`}>
{field => (
<Input
aria-label={`Link ${index + 1} label`}
value={field.state.value}
onChange={event => field.handleChange(event.target.value)}
/>
)}
</form.Field>
<form.Field name={`links[${index}].url`}>
{field => (
<Input
aria-label={`Link ${index + 1} URL`}
type="url"
value={field.state.value}
onChange={event => field.handleChange(event.target.value)}
/>
)}
</form.Field>
<Button
type="button"
variant="outline"
onClick={() => linksField.removeValue(index)}
>
Remove
</Button>
</div>
))}
<Button
type="button"
onClick={() =>
linksField.pushValue({
id: crypto.randomUUID(),
label: "",
url: "",
})
}
>
Add link
</Button>
</div>
)}
</form.Field>
Use a stable ID as the React key. Keying by array index can make input values appear to jump between rows after a removal because React reuses the wrong DOM node.
TanStack Form also provides helpers such as insertValue, replaceValue, swapValues, moveValue, and clearValues. Put list constraints in Zod so the server and client agree on the maximum size and row shape.
12. Render Submission State Efficiently
A submit button only needs a small slice of form state:
import { Button } from "@/components/ui/button";
<form.Subscribe selector={state => state.isSubmitting}>
{isSubmitting => (
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Creating account…" : "Create account"}
</Button>
)}
</form.Subscribe>;
Disabling while the request is in flight prevents duplicate submissions. Disabling whenever the form is invalid is often less helpful: users cannot submit, but they may not know which untouched field blocks them. Letting the first submit reveal the errors usually creates a clearer path forward.
Use TanStack Form for resetting as well:
<Button type="button" variant="outline" onClick={() => form.reset()}>
Reset
</Button>
Avoid combining a native type="reset" action with form.reset(). The browser resets controls according to HTML defaults, while TanStack Form resets according to defaultValues; allowing both can produce inconsistent state.
13. Handle Server Errors Deliberately
Network failures, expired sessions, and uniqueness conflicts can happen after client validation succeeds. Give these failures a visible home instead of logging them and leaving the user on an unchanged form.
A practical submission flow should:
- Parse and validate again on the server.
- Return structured field errors for problems the user can correct.
- Return a form-level message for general failures.
- Preserve entered values after a recoverable error.
- Move focus to the error summary or first invalid field.
- Clear sensitive fields when the security model requires it.
Do not reveal more than necessary in account-related checks. For example, “Unable to create this account” can be safer than confirming whether a private email address is already registered.
14. Accessibility Checklist
Correct components are the beginning of accessible form behavior, not the end. Verify the complete interaction:
- Every control has a programmatically associated label.
- Related checkboxes and radio buttons use a fieldset and legend.
- Invalid controls expose
aria-invalid. - Help text and errors are associated with the relevant control.
- Errors do not rely on color alone.
- Async status text is announced with a polite live region.
- Focus moves to a useful location after an unsuccessful submit.
- Popovers, selects, and date pickers work with a keyboard.
- Loading states remain understandable without animation.
- Touch targets are comfortably large on mobile.
Also test browser autofill. Meaningful name and autoComplete values such as name, email, username, new-password, and organization reduce work for users and improve password-manager behavior.
15. Common Shadcn TanStack Form Mistakes
Validating everything on every keystroke
- Immediate feedback is useful only when the user can act on it immediately. Prefer submit-first or blur validation for most text fields.
Omitting default values
- Missing defaults create awkward unions, uncontrolled-to-controlled warnings, and incomplete field-name inference. Define the whole initial form shape.
Treating client validation as authorization
- Client code can be bypassed. Validate on the server and enforce permissions independently of the form.
Subscribing to the entire form
- Broad subscriptions give away the performance benefit of a fine-grained store. Select only the value or state needed by a conditional section, summary, or button.
Keeping duplicate local state
- If TanStack Form owns a value, avoid mirroring it with
useState. The two sources will eventually disagree. Local React state is still appropriate for purely presentational state such as whether a password is visible or a popover is open.
Forgetting the empty state of numeric and date inputs
- Forms have temporary values that submitted data does not. Model the editing state deliberately, then let the final schema reject an incomplete submission.
Using unstable keys in an array
- Array indices describe positions, not identities. Generate an ID for each row and preserve it while the row moves.
Hiding all request progress
- Show
isValidatingfor async field checks andisSubmittingfor form submission. Silent delays encourage repeated clicks and make a healthy application feel broken.
Once the form logic is ready, you can place it inside reusable settings, authentication, or preference Shadcn blocks instead of rebuilding the surrounding layout from scratch.
For multi-step onboarding, checkout, or account-management journeys, complete Shadcn pages can help you decide where this form belongs and whether a long flow should be divided into smaller steps.
If the interface is designed before implementation, the Shadcn Figma Plugin helps designers work with components that map more closely to the shadcn primitives used throughout this tutorial.
Production Checklist
Before shipping, confirm that:
- The server validates the submitted payload.
- The submit handler catches and presents expected failures.
- Duplicate submissions are prevented.
- Async checks are debounced and cancellable.
- Sensitive values are not written to logs, analytics, or draft storage.
- Error messages tell users how to recover.
- The first invalid field can be reached quickly.
- Conditional fields have an intentional preservation or clearing policy.
- Dynamic arrays have client and server limits.
- The form works with keyboard navigation, autofill, and a screen reader.
- The mobile layout is tested with long labels and error messages.
Final Takeaways
A strong Shadcn TanStack Form architecture comes from clear boundaries:
- Let TanStack Form own values, field metadata, subscriptions, and submission state.
- Let Zod define the final valid data shape and cross-field rules.
- Let shadcn/ui provide accessible, editable presentation primitives.
- Use validators for errors and listeners for side effects.
- Subscribe to the smallest useful slice of form state.
- Validate again on the server before trusting anything.
Start with the submit-first revalidation strategy, add immediate validation only where it improves the interaction, and treat loading and error recovery as part of the form rather than optional polish. The result is a form system that stays predictable as it grows from five fields to fifty.
Resources
- TanStack Form documentation
- TanStack Form validation guide
- shadcn/ui Field documentation
- Zod documentation
- Complete example repository
- Interactive demo