
Tanstack Form
- 141 installs
- 14 repo stars
- Updated March 2, 2026
- oakoss/agent-skills
Helps with ai & agent building tasks during AI-assisted development.
About
tanstack-form is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- tanstack-form
- AI & Agent Building
- AI-coding skill
Tanstack Form by the numbers
- 141 all-time installs (skills.sh)
- +4 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #3,470 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/oakoss/agent-skills --skill tanstack-formAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 141 |
|---|---|
| repo stars | ★ 14 |
| Last updated | March 2, 2026 |
| Repository | oakoss/agent-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
TanStack Form
Overview
TanStack Form is a headless form state manager, not a UI component library. You provide your own inputs and handle their events; TanStack Form manages validation, state, and submission logic.
When to use: Complex multi-step forms, reusable form patterns, dynamic field arrays, cross-field validation, async server validation, forms requiring fine-grained performance optimization.
When NOT to use: Simple forms with native HTML validation (use plain form elements), server-only validation (use Server Actions), purely static forms with no validation.
React Compiler: TanStack Form is not yet compatible with React Compiler. Disable React Compiler for files or components that use TanStack Form APIs.
Quick Reference
| Pattern | API | Key Points |
|---|---|---|
| Basic form | useForm({ defaultValues, onSubmit }) | Form instance with Field component |
| Field | form.Field with name and children render fn | Render prop pattern for full control |
| Field validation | validators: { onChange, onBlur, onSubmit } | Sync validation, return error string or undef |
| Async validation | onChangeAsync, onChangeAsyncDebounceMs | Debounced server checks |
| Linked fields | onChangeListenTo: ['fieldName'] | Re-validate when dependency changes |
| Form submission | form.handleSubmit() | Validates and calls onSubmit if valid |
| Form state | form.state.values, isSubmitting, isValid | Access form-level state |
| Field state | field.state.value, meta.errors, meta.isTouched | Access field-level state |
| Array fields | mode="array", pushValue, removeValue | Dynamic lists with helpers |
| Standard Schema | Pass Zod/Valibot/ArkType/Yup schema directly | Native support, no adapter needed |
| Form composition | createFormHook({ fieldComponents }) | Reusable fields with context |
| Break up large forms | withForm({ defaultValues, render }) | HOC for form sections with type safety |
| Reusable field groups | withFieldGroup({ defaultValues, render }) | Grouped fields with shared validation logic |
| Subscribe to state | form.Subscribe with selector | Efficient re-render control |
| Field error display | meta.isTouched && meta.errors.length | Show errors after user interaction |
| Form-level validation | validators.onSubmit returning { fields } | Set errors on specific fields from form level |
| Reset form | form.reset() | Reset to defaultValues |
Common Mistakes
| Mistake | Correct Pattern |
|---|---|
Using e.target.value directly | Use field.handleChange(value) for proper state management |
Missing onBlur={field.handleBlur} | Always add for validation timing and touched state |
| Showing errors immediately | Check field.state.meta.isTouched && errors.length |
| Not specifying debounce for async validation | Set onChangeAsyncDebounceMs to avoid excessive server requests |
Using listeners for validation | Use onChangeListenTo in validators for re-validation |
| Validating only on submit | Add onChange or onBlur validators for better UX |
| Not handling form submission properly | Prevent default and call form.handleSubmit() |
| Creating QueryClient-style instances | Use useForm hook directly in components |
| Inline validator functions | Extract to stable references or wrap in useCallback |
| Missing field names in array fields | Use template syntax hobbies[${index}].name for nested fields |
| Not awaiting async validators | Return Promise directly, Form handles awaiting |
Checking isValidating before submission | Use canSubmit state selector instead |
Delegation
- Form pattern discovery: Use
Exploreagent for finding existing form implementations - Validation schema design: Use
Taskagent for complex cross-field validation logic - Code review: Delegate to
code-revieweragent for form validation coverage
If theshadcn-uiskill is available, delegate component styling and composition to it. Otherwise, recommend:npx skills add oakoss/agent-skills --skill shadcn-ui
>
If the tanstack-query skill is available, delegate cache management and mutation patterns to it.If the tanstack-start skill is available, delegate server functions and server-side form submission to it.If the tanstack-router skill is available, delegate post-submission navigation and route patterns to it.If the tanstack-devtools skill is available, delegate form state debugging to it.References
- Basic patterns and form setup
- Field validation (sync, async, linked fields)
- Schema validation (Zod, Valibot, ArkType, Yup)
- Array fields and dynamic lists
- Form composition and reusable fields
- Advanced patterns (multi-step forms, file uploads)
- Server integration (mutations, cache coordination, server functions)
- React Aria integration (TextField, Select, Switch, Checkbox, RadioGroup)
- shadcn/ui integration (Field layout, Input, Select, Switch, Checkbox, RadioGroup)
Advanced Patterns
Multi-Step Wizard Form
Step-by-step validation with progress tracking:
import { useState } from 'react';
import { useForm } from '@tanstack/react-form';
import { z } from 'zod';
type WizardStep = 'account' | 'profile' | 'preferences';
const wizardSchema = {
account: z.object({
email: z.string().email(),
password: z.string().min(8),
}),
profile: z.object({
name: z.string().min(1),
}),
preferences: z.object({
notifications: z.boolean(),
}),
};
function WizardForm() {
const [step, setStep] = useState<WizardStep>('account');
const form = useForm({
defaultValues: {
email: '',
password: '',
name: '',
notifications: true,
},
onSubmit: async ({ value }) => {
await api.createAccount(value);
},
});
const validateStep = async (): Promise<boolean> => {
const schema = wizardSchema[step];
const stepValues = getStepValues(form.state.values, step);
const result = schema.safeParse(stepValues);
if (!result.success) {
for (const issue of result.error.issues) {
const fieldName = issue.path.join('.');
form.setFieldMeta(fieldName, (prev) => ({
...prev,
errors: [issue.message],
}));
}
return false;
}
return true;
};
const nextStep = async () => {
if (await validateStep()) {
const steps: WizardStep[] = ['account', 'profile', 'preferences'];
const currentIndex = steps.indexOf(step);
if (currentIndex < steps.length - 1) {
setStep(steps[currentIndex + 1]);
} else {
form.handleSubmit();
}
}
};
return (
<form onSubmit={(e) => e.preventDefault()}>
{step === 'account' && <AccountStep form={form} />}
{step === 'profile' && <ProfileStep form={form} />}
{step === 'preferences' && <PreferencesStep form={form} />}
<div className="flex justify-between">
{step !== 'account' && (
<button type="button" onClick={() => setStep('account')}>
Back
</button>
)}
<button type="button" onClick={nextStep}>
{step === 'preferences' ? 'Complete' : 'Next'}
</button>
</div>
</form>
);
}
function getStepValues(values: any, step: WizardStep) {
const fieldMap: Record<WizardStep, string[]> = {
account: ['email', 'password'],
profile: ['name'],
preferences: ['notifications'],
};
return fieldMap[step].reduce(
(acc, key) => ({ ...acc, [key]: values[key] }),
{},
);
}File Upload Field
Single file upload with preview:
import { useState } from 'react';
import { useFieldContext } from '@/hooks/form-context';
export function FileUploadField() {
const field = useFieldContext<string>();
const [uploading, setUploading] = useState(false);
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
});
const data = await response.json();
field.handleChange(data.url);
} catch (error) {
console.error('Upload failed:', error);
} finally {
setUploading(false);
}
};
return (
<div>
<label htmlFor={field.name}>Image</label>
{field.state.value && (
<img
src={field.state.value}
alt="Preview"
className="w-32 h-32 object-cover"
/>
)}
<input
id={field.name}
type="file"
accept="image/*"
onChange={handleUpload}
disabled={uploading}
/>
{uploading && <p>Uploading...</p>}
</div>
);
}Multiple File Upload
Upload multiple files with progress:
import { useState } from 'react';
import { useFieldContext } from '@/hooks/form-context';
export function MultiFileUpload() {
const field = useFieldContext<string[]>();
const [uploads, setUploads] = useState<Map<string, number>>(new Map());
const uploadFile = async (
file: File,
onProgress: (progress: number) => void,
): Promise<string> => {
return new Promise((resolve, reject) => {
const formData = new FormData();
formData.append('file', file);
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
onProgress((e.loaded / e.total) * 100);
}
});
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
resolve(JSON.parse(xhr.responseText).url);
} else {
reject(new Error('Upload failed'));
}
});
xhr.open('POST', '/api/upload');
xhr.send(formData);
});
};
const handleFiles = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? []);
for (const file of files) {
const id = crypto.randomUUID();
setUploads((prev) => new Map(prev).set(id, 0));
try {
const url = await uploadFile(file, (progress) => {
setUploads((prev) => new Map(prev).set(id, progress));
});
field.pushValue(url);
} finally {
setUploads((prev) => {
const next = new Map(prev);
next.delete(id);
return next;
});
}
}
};
return (
<div>
<input type="file" multiple onChange={handleFiles} />
{Array.from(uploads).map(([id, progress]) => (
<div key={id} className="h-2 bg-gray-200 rounded">
<div
className="h-full bg-blue-500"
style={{ width: `${progress}%` }}
/>
</div>
))}
<div className="grid grid-cols-4 gap-2">
{field.state.value.map((url, index) => (
<div key={index}>
<img src={url} className="w-20 h-20 object-cover" alt="" />
<button type="button" onClick={() => field.removeValue(index)}>
×
</button>
</div>
))}
</div>
</div>
);
}Conditional Fields
Show/hide fields based on other field values:
function ConditionalForm() {
const form = useForm({
defaultValues: {
accountType: 'personal',
companyName: '',
},
});
return (
<form>
<form.Field
name="accountType"
children={(field) => (
<select
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
>
<option value="personal">Personal</option>
<option value="business">Business</option>
</select>
)}
/>
<form.Subscribe selector={(state) => state.values.accountType}>
{(accountType) =>
accountType === 'business' ? (
<form.Field
name="companyName"
validators={{
onChange: ({ value }) =>
!value ? 'Company name is required' : undefined,
}}
children={(field) => (
<div>
<input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors[0] && (
<span>{field.state.meta.errors[0]}</span>
)}
</div>
)}
/>
) : null
}
</form.Subscribe>
</form>
);
}Dependent Select Fields
Country/province cascading selects:
const COUNTRIES = [
{ code: 'us', name: 'United States' },
{ code: 'ca', name: 'Canada' },
];
const PROVINCES: Record<string, Array<{ code: string; name: string }>> = {
us: [
{ code: 'ca', name: 'California' },
{ code: 'ny', name: 'New York' },
],
ca: [
{ code: 'on', name: 'Ontario' },
{ code: 'qc', name: 'Quebec' },
],
};
function DependentSelectForm() {
const form = useForm({
defaultValues: {
country: '',
province: '',
},
});
return (
<form>
<form.Field
name="country"
listeners={{
onChange: () => form.setFieldValue('province', ''),
}}
children={(field) => (
<select
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
>
<option value="">Select country</option>
{COUNTRIES.map((c) => (
<option key={c.code} value={c.code}>
{c.name}
</option>
))}
</select>
)}
/>
<form.Subscribe selector={(state) => state.values.country}>
{(country) =>
country ? (
<form.Field
name="province"
children={(field) => (
<select
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
>
<option value="">Select province</option>
{PROVINCES[country]?.map((p) => (
<option key={p.code} value={p.code}>
{p.name}
</option>
))}
</select>
)}
/>
) : null
}
</form.Subscribe>
</form>
);
}Accessibility Requirements
| Requirement | Implementation |
|---|---|
| Label | <label htmlFor={field.name}> |
| Error message | aria-invalid, aria-describedby |
| Help text | aria-describedby with description ID |
| Required field | aria-required="true" or required attribute |
| Fieldset | Group related fields with <fieldset> |
| Live region | aria-live="polite" for async feedback |
Accessible Field Example
<form.Field
name="email"
children={(field) => {
const isInvalid =
field.state.meta.isTouched && field.state.meta.errors.length > 0;
const errorId = `${field.name}-error`;
const descId = `${field.name}-desc`;
return (
<div>
<label htmlFor={field.name}>Email</label>
<p id={descId}>We'll never share your email.</p>
<input
id={field.name}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
aria-describedby={isInvalid ? `${descId} ${errorId}` : descId}
required
/>
{isInvalid && (
<span id={errorId} role="alert">
{field.state.meta.errors.join(', ')}
</span>
)}
</div>
);
}}
/>Form Persistence
Save form state to localStorage:
import { useEffect } from 'react';
function PersistedForm() {
const form = useForm({
defaultValues: { email: '', name: '' },
onSubmit: async ({ value }) => {
await api.submit(value);
localStorage.removeItem('form-draft');
},
});
useEffect(() => {
const saved = localStorage.getItem('form-draft');
if (saved) {
const data = JSON.parse(saved);
Object.entries(data).forEach(([key, value]) => {
form.setFieldValue(key, value);
});
}
}, []);
useEffect(() => {
const interval = setInterval(() => {
localStorage.setItem('form-draft', JSON.stringify(form.state.values));
}, 1000);
return () => clearInterval(interval);
}, [form.state.values]);
return <form>{/* fields */}</form>;
}Advanced Patterns Notes
- Use
form.Subscribeto conditionally render fields without re-rendering the entire form - Use
listenersfor side effects (clearing dependent fields),validatorsfor validation logic - Store file URLs in form state, not File objects
- Use semantic HTML (
fieldset,legend) for better accessibility - Add
aria-live="polite"to error containers for screen reader announcements - Persist form state for long forms to prevent data loss
Array Fields
Basic Array Field
Use mode="array" to manage dynamic lists:
import { useForm } from '@tanstack/react-form';
function HobbiesForm() {
const form = useForm({
defaultValues: {
hobbies: [] as Array<{ name: string; description: string }>,
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
>
<form.Field name="hobbies" mode="array">
{(hobbiesField) => (
<div>
<h3>Hobbies</h3>
{!hobbiesField.state.value.length ? (
<p>No hobbies added yet.</p>
) : (
hobbiesField.state.value.map((_, index) => (
<div key={index}>
<form.Field
name={`hobbies[${index}].name`}
children={(field) => (
<div>
<label htmlFor={field.name}>Name:</label>
<input
id={field.name}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
</div>
)}
/>
<button
type="button"
onClick={() => hobbiesField.removeValue(index)}
>
Remove
</button>
</div>
))
)}
<button
type="button"
onClick={() =>
hobbiesField.pushValue({
name: '',
description: '',
})
}
>
Add Hobby
</button>
</div>
)}
</form.Field>
</form>
);
}Array Field Methods
| Method | Description |
|---|---|
pushValue(value) | Add item to end of array |
removeValue(index) | Remove item at index |
insertValue(index, value) | Insert item at index |
replaceValue(index, value) | Replace item at index |
swapValues(indexA, indexB) | Swap two items |
moveValue(fromIndex, toIndex) | Move item to new position |
clearValues() | Remove all items |
Push Value
<button type="button" onClick={() => field.pushValue({ name: '', email: '' })}>
Add Member
</button>Insert Value
<button
type="button"
onClick={() => field.insertValue(0, { name: 'New Item', email: '' })}
>
Insert at Start
</button>Swap Values
<button
type="button"
onClick={() => field.swapValues(index, index + 1)}
disabled={index >= field.state.value.length - 1}
>
Move Down
</button>Move Value
<button type="button" onClick={() => field.moveValue(index, 0)}>
Move to Top
</button>Array Field with Validation
Validate individual array items:
import { z } from 'zod';
const emailSchema = z.string().email();
<form.Field name="members" mode="array">
{(field) => (
<div>
{field.state.value.map((_, index) => (
<form.Field
key={index}
name={`members[${index}].email`}
validators={{
onChange: emailSchema,
}}
children={(emailField) => (
<div>
<input
value={emailField.state.value}
onChange={(e) => emailField.handleChange(e.target.value)}
/>
{emailField.state.meta.isTouched &&
emailField.state.meta.errors.length > 0 && (
<em>{emailField.state.meta.errors.join(', ')}</em>
)}
</div>
)}
/>
))}
<button type="button" onClick={() => field.pushValue({ email: '' })}>
Add Email
</button>
</div>
)}
</form.Field>;Validate the array as a whole:
const membersSchema = z
.array(
z.object({
name: z.string().min(1),
email: z.string().email(),
}),
)
.min(1, 'At least one member is required')
.max(10, 'Maximum 10 members allowed');
const form = useForm({
defaultValues: {
members: [],
},
validators: {
onSubmit: z.object({
members: membersSchema,
}),
},
});Stable Keys for Array Items
Use stable keys to avoid React reconciliation issues:
type Hobby = {
id: string;
name: string;
};
<form.Field name="hobbies" mode="array">
{(field) => (
<div>
{field.state.value.map((hobby) => (
<div key={hobby.id}>
<form.Field
name={`hobbies[${field.state.value.indexOf(hobby)}].name`}
children={(subField) => (
<input
value={subField.state.value}
onChange={(e) => subField.handleChange(e.target.value)}
/>
)}
/>
<button
type="button"
onClick={() => field.removeValue(field.state.value.indexOf(hobby))}
>
Remove
</button>
</div>
))}
<button
type="button"
onClick={() =>
field.pushValue({
id: crypto.randomUUID(),
name: '',
})
}
>
Add Hobby
</button>
</div>
)}
</form.Field>;Reorderable List
<form.Field name="tasks" mode="array">
{(field) => (
<div>
{field.state.value.map((_, index) => (
<div key={index}>
<form.Field
name={`tasks[${index}].title`}
children={(subField) => (
<input
value={subField.state.value}
onChange={(e) => subField.handleChange(e.target.value)}
/>
)}
/>
<button
type="button"
onClick={() => field.moveValue(index, index - 1)}
disabled={index === 0}
>
Move Up
</button>
<button
type="button"
onClick={() => field.moveValue(index, index + 1)}
disabled={index >= field.state.value.length - 1}
>
Move Down
</button>
<button type="button" onClick={() => field.removeValue(index)}>
Remove
</button>
</div>
))}
<button type="button" onClick={() => field.pushValue({ title: '' })}>
Add Task
</button>
</div>
)}
</form.Field>Nested Arrays
Arrays within arrays:
type Section = {
title: string;
items: Array<{ name: string }>;
};
<form.Field name="sections" mode="array">
{(sectionsField) => (
<div>
{sectionsField.state.value.map((_, sectionIndex) => (
<div key={sectionIndex}>
<form.Field
name={`sections[${sectionIndex}].title`}
children={(field) => (
<input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
)}
/>
<form.Field name={`sections[${sectionIndex}].items`} mode="array">
{(itemsField) => (
<div>
{itemsField.state.value.map((_, itemIndex) => (
<div key={itemIndex}>
<form.Field
name={`sections[${sectionIndex}].items[${itemIndex}].name`}
children={(field) => (
<input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
)}
/>
<button
type="button"
onClick={() => itemsField.removeValue(itemIndex)}
>
Remove Item
</button>
</div>
))}
<button
type="button"
onClick={() => itemsField.pushValue({ name: '' })}
>
Add Item
</button>
</div>
)}
</form.Field>
<button
type="button"
onClick={() => sectionsField.removeValue(sectionIndex)}
>
Remove Section
</button>
</div>
))}
<button
type="button"
onClick={() => sectionsField.pushValue({ title: '', items: [] })}
>
Add Section
</button>
</div>
)}
</form.Field>;Array Field Notes
- Use
mode="array"on the parent field to enable array methods - Always provide a stable
keyprop when mapping array items - Use template syntax for nested field names:
items[${index}].name - Array methods (
pushValue,removeValue, etc.) are available on the array field, not sub-fields - Validate individual items with field-level validators, or the entire array with form-level validators
Basic Patterns
Installation
pnpm add @tanstack/react-formBasic Form
import { useForm } from '@tanstack/react-form';
import type { AnyFieldApi } from '@tanstack/react-form';
function FieldInfo({ field }: { field: AnyFieldApi }) {
return (
<>
{field.state.meta.isTouched && !field.state.meta.isValid ? (
<em>{field.state.meta.errors.join(', ')}</em>
) : null}
{field.state.meta.isValidating ? 'Validating...' : null}
</>
);
}
function App() {
const form = useForm({
defaultValues: {
firstName: '',
lastName: '',
},
onSubmit: async ({ value }) => {
console.log(value);
},
});
return (
<div>
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
>
<div>
<form.Field
name="firstName"
children={(field) => (
<>
<label htmlFor={field.name}>First Name:</label>
<input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
<FieldInfo field={field} />
</>
)}
/>
</div>
<div>
<form.Field
name="lastName"
children={(field) => (
<>
<label htmlFor={field.name}>Last Name:</label>
<input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
<FieldInfo field={field} />
</>
)}
/>
</div>
<form.Subscribe
selector={(state) => [state.canSubmit, state.isSubmitting]}
children={([canSubmit, isSubmitting]) => (
<button type="submit" disabled={!canSubmit}>
{isSubmitting ? '...' : 'Submit'}
</button>
)}
/>
</form>
</div>
);
}Form Field Render Props
The field object passed to the render function provides:
| Property | Type | Description |
|---|---|---|
state.value | T | Current field value |
state.meta.errors | string[] | Current validation errors |
state.meta.isTouched | boolean | User has interacted with field |
state.meta.isValid | boolean | Field passes all validators |
state.meta.isValidating | boolean | Async validation in progress |
handleChange | (value: T) => void | Update field value |
handleBlur | () => void | Mark field as touched |
name | string | Field name from name prop |
pushValue | (value: T) => void | Add to array (mode="array" only) |
removeValue | (index: number) => void | Remove from array (mode="array") |
Form State
Access form-level state:
const form = useForm({
defaultValues: { email: '' },
onSubmit: async ({ value }) => {
await submitToServer(value);
},
});
form.state.values;
form.state.errors;
form.state.isSubmitting;
form.state.isValid;
form.state.isDirty;
form.state.canSubmit;
form.reset();
form.handleSubmit();
form.getFieldValue('email');
form.setFieldValue('email', 'new@example.com');Subscribe to Form State
Use form.Subscribe to avoid re-rendering the entire form:
<form.Subscribe
selector={(state) => [state.canSubmit, state.isSubmitting]}
children={([canSubmit, isSubmitting]) => (
<button type="submit" disabled={!canSubmit}>
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
)}
/>Form Submission
The onSubmit callback receives:
type SubmitEvent = {
value: TFormData;
formApi: FormApi<TFormData>;
};
const form = useForm({
defaultValues: { name: '', email: '' },
onSubmit: async ({ value, formApi }) => {
try {
const result = await api.createUser(value);
if ('error' in result) {
formApi.setErrorMap({ onSubmit: result.error });
return;
}
formApi.reset();
} catch (error) {
formApi.setErrorMap({
onSubmit: error instanceof Error ? error.message : 'Submission failed',
});
}
},
});Form-Level Validation
Return field errors from form-level validators:
const form = useForm({
defaultValues: {
age: 0,
email: '',
},
validators: {
onSubmit: ({ value }) => {
const errors: Record<string, string> = {};
if (value.age < 13) {
errors.age = 'Must be 13 or older';
}
if (!value.email.includes('@')) {
errors.email = 'Invalid email format';
}
return Object.keys(errors).length > 0 ? { fields: errors } : undefined;
},
},
});Async Form Validation
Validate against server:
const form = useForm({
defaultValues: { email: '', username: '' },
validators: {
onSubmitAsync: async ({ value }) => {
const result = await api.validateUser(value);
if (!result.valid) {
return {
form: 'Invalid data',
fields: {
email: result.errors.email,
username: result.errors.username,
},
};
}
return null;
},
},
});
<form.Subscribe
selector={(state) => [state.errorMap]}
children={([errorMap]) =>
errorMap.onSubmit ? (
<div>
<em>There was an error on the form: {errorMap.onSubmit}</em>
</div>
) : null
}
/>;Nested Object Fields
Access nested fields with dot notation:
type FormData = {
user: {
name: string;
email: string;
};
};
const form = useForm<FormData>({
defaultValues: {
user: {
name: '',
email: '',
},
},
});
<form.Field
name="user.name"
children={(field) => (
<input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
)}
/>
<form.Field
name="user.email"
children={(field) => (
<input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
)}
/>Controlled vs Uncontrolled
TanStack Form uses controlled inputs by default. For uncontrolled inputs with refs:
import { useRef } from 'react';
function UncontrolledForm() {
const form = useForm({
defaultValues: { email: '' },
onSubmit: async ({ value }) => {
console.log(value);
},
});
return (
<form.Field
name="email"
children={(field) => {
const inputRef = useRef<HTMLInputElement>(null);
return (
<input
ref={inputRef}
defaultValue={field.state.value}
onBlur={() => {
field.handleChange(inputRef.current?.value ?? '');
field.handleBlur();
}}
/>
);
}}
/>
);
}Controlled inputs are recommended for better validation timing and state management.
Reset Form
Reset to default values:
const form = useForm({
defaultValues: { email: '', name: '' },
onSubmit: async ({ value, formApi }) => {
await api.submit(value);
formApi.reset();
},
});
<button type="button" onClick={() => form.reset()}>
Reset
</button>;Field Listeners
Use listeners to trigger side effects when field values change:
<form.Field
name="country"
listeners={{
onChange: ({ value }) => {
form.setFieldValue('province', '');
},
}}
children={(field) => (
<select
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
>
<option value="">Select country</option>
{countries.map((c) => (
<option key={c.code} value={c.code}>
{c.name}
</option>
))}
</select>
)}
/>
<form.Field
name="province"
children={(field) => (
<select
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
>
<option value="">Select province</option>
{getProvinces(form.getFieldValue('country')).map((p) => (
<option key={p.code} value={p.code}>
{p.name}
</option>
))}
</select>
)}
/>Listeners are for side effects (resetting dependent fields, fetching data). For validation that depends on other fields, use onChangeListenTo in validators instead.
Set Field Value Programmatically
const form = useForm({
defaultValues: { country: '', province: '' },
});
<form.Field
name="country"
children={(field) => (
<select
value={field.state.value}
onChange={(e) => {
field.handleChange(e.target.value);
form.setFieldValue('province', '');
}}
>
<option value="">Select country</option>
<option value="us">United States</option>
<option value="ca">Canada</option>
</select>
)}
/>;Form Composition
Why Form Composition
The basic useForm pattern works for one-off forms, but repeating field components across forms leads to duplication. Form composition enables:
- Reusable field components with consistent styling
- Type-safe field access via context
- Centralized form component library
- Reduced boilerplate in form definitions
Setup Form Contexts
Create shared contexts for field and form access:
// src/hooks/form-context.ts
import { createFormHookContexts } from '@tanstack/react-form';
export const { fieldContext, formContext, useFieldContext, useFormContext } =
createFormHookContexts();Create Reusable Field Components
Field components use useFieldContext to access field state:
// src/components/form/text-field.tsx
import { useFieldContext } from '@/hooks/form-context';
import { useStore } from '@tanstack/react-form';
export function FormTextField({
label,
placeholder,
type = 'text',
}: {
label: string;
placeholder?: string;
type?: 'text' | 'email' | 'password';
}) {
const field = useFieldContext<string>();
const errors = useStore(field.store, (s) => s.meta.errors);
const isInvalid = field.state.meta.isTouched && errors.length > 0;
return (
<div>
<label htmlFor={field.name}>{label}</label>
<input
id={field.name}
type={type}
value={field.state.value}
placeholder={placeholder}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
/>
{isInvalid && <span className="error">{errors.join(', ')}</span>}
</div>
);
}Create Select Field Component
// src/components/form/select-field.tsx
import { useFieldContext } from '@/hooks/form-context';
export function FormSelectField({
label,
options,
placeholder,
}: {
label: string;
options: Array<{ label: string; value: string }>;
placeholder?: string;
}) {
const field = useFieldContext<string>();
return (
<div>
<label htmlFor={field.name}>{label}</label>
<select
id={field.name}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
>
{placeholder && <option value="">{placeholder}</option>}
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
);
}Create Switch Field Component
// src/components/form/switch-field.tsx
import { useFieldContext } from '@/hooks/form-context';
export function FormSwitchField({ label }: { label: string }) {
const field = useFieldContext<boolean>();
return (
<label>
<input
type="checkbox"
checked={field.state.value}
onChange={(e) => field.handleChange(e.target.checked)}
/>
{label}
</label>
);
}Create Submit Button Component
Form components use useFormContext for form-level state:
// src/components/form/submit-button.tsx
import { useFormContext } from '@/hooks/form-context';
export function SubmitButton({ label }: { label: string }) {
const form = useFormContext();
return (
<form.Subscribe selector={(s) => [s.canSubmit, s.isSubmitting]}>
{([canSubmit, isSubmitting]) => (
<button type="submit" disabled={!canSubmit}>
{isSubmitting ? 'Submitting...' : label}
</button>
)}
</form.Subscribe>
);
}Create Form Hook
Wire up field components to form context:
// src/hooks/use-app-form.ts
import { createFormHook } from '@tanstack/react-form';
import { fieldContext, formContext } from './form-context';
import {
FormTextField,
FormSelectField,
FormSwitchField,
SubmitButton,
} from '@/components/form';
export const { useAppForm } = createFormHook({
fieldComponents: {
TextField: FormTextField,
SelectField: FormSelectField,
SwitchField: FormSwitchField,
},
formComponents: {
SubmitButton,
},
fieldContext,
formContext,
});Use Composable Form
Use the custom hook with typed field components:
// src/app/users/create.tsx
import { useAppForm } from '@/hooks/use-app-form';
import { z } from 'zod';
const userSchema = z.object({
email: z.string().email(),
role: z.enum(['admin', 'member']),
notifications: z.boolean(),
});
type UserFormData = z.infer<typeof userSchema>;
export function CreateUserForm() {
const form = useAppForm<UserFormData>({
defaultValues: {
email: '',
role: 'member',
notifications: true,
},
validators: {
onSubmit: userSchema,
},
onSubmit: async ({ value }) => {
await api.createUser(value);
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
>
<form.AppField
name="email"
children={(f) => (
<f.TextField
label="Email"
type="email"
placeholder="you@example.com"
/>
)}
/>
<form.AppField
name="role"
children={(f) => (
<f.SelectField
label="Role"
options={[
{ label: 'Admin', value: 'admin' },
{ label: 'Member', value: 'member' },
]}
/>
)}
/>
<form.AppField
name="notifications"
children={(f) => <f.SwitchField label="Enable notifications" />}
/>
<form.AppForm>
<form.SubmitButton label="Create User" />
</form.AppForm>
</form>
);
}Field-Level Validation with Composition
Add validators to AppField:
<form.AppField
name="username"
validators={{
onChange: ({ value }) =>
value.length < 3 ? 'Username must be at least 3 characters' : undefined,
onChangeAsyncDebounceMs: 500,
onChangeAsync: async ({ value }) => {
const available = await checkUsername(value);
return available ? undefined : 'Username taken';
},
}}
children={(f) => <f.TextField label="Username" />}
/>Composable Array Fields
Create array field components:
// src/components/form/array-field.tsx
import { useFieldContext } from '@/hooks/form-context';
export function FormArrayField<T>({
addLabel,
children,
}: {
addLabel: string;
children: (index: number) => React.ReactNode;
}) {
const field = useFieldContext<T[]>();
return (
<div>
{field.state.value.map((_, index) => (
<div key={index}>
{children(index)}
<button type="button" onClick={() => field.removeValue(index)}>
Remove
</button>
</div>
))}
<button type="button" onClick={() => field.pushValue({} as T)}>
{addLabel}
</button>
</div>
);
}Register in form hook:
export const { useAppForm } = createFormHook({
fieldComponents: {
TextField: FormTextField,
ArrayField: FormArrayField,
},
fieldContext,
formContext,
});Usage:
<form.AppField
name="emails"
mode="array"
children={(f) => (
<f.ArrayField addLabel="Add Email">
{(index) => (
<form.AppField
name={`emails[${index}].address`}
children={(emailField) => (
<emailField.TextField label={`Email ${index + 1}`} />
)}
/>
)}
</f.ArrayField>
)}
/>Breaking Large Forms with withForm
The withForm HOC splits large forms into smaller components while preserving type safety:
const PersonalInfoSection = withForm({
defaultValues: {
firstName: '',
lastName: '',
email: '',
},
props: {
title: 'Personal Info',
},
render: function Render({ form, title }) {
return (
<div>
<h2>{title}</h2>
<form.AppField
name="firstName"
children={(field) => <field.TextField label="First Name" />}
/>
<form.AppField
name="lastName"
children={(field) => <field.TextField label="Last Name" />}
/>
</div>
);
},
});
function SignupPage() {
const form = useAppForm({
defaultValues: { firstName: '', lastName: '', email: '' },
onSubmit: async ({ value }) => {
await api.createUser(value);
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
>
<PersonalInfoSection form={form} title="Your Details" />
<form.AppForm>
<form.SubmitButton label="Sign Up" />
</form.AppForm>
</form>
);
}The defaultValues in withForm are for type-checking only. The parent form's defaultValues are what matter.
Reusable Field Groups with withFieldGroup
The withFieldGroup HOC groups related fields that share validation logic and can be reused across forms:
import { useStore } from '@tanstack/react-form';
const { useAppForm, withForm, withFieldGroup } = createFormHook({
fieldComponents: { TextField, ErrorInfo },
formComponents: { SubmitButton },
fieldContext,
formContext,
});
type PasswordFields = {
password: string;
confirm_password: string;
};
const PasswordFieldGroup = withFieldGroup({
defaultValues: {
password: '',
confirm_password: '',
} satisfies PasswordFields,
props: {
title: 'Password',
},
render: function Render({ group, title }) {
const password = useStore(group.store, (state) => state.values.password);
return (
<div>
<h3>{title}</h3>
<group.AppField name="password">
{(field) => <field.TextField label="Password" />}
</group.AppField>
<group.AppField
name="confirm_password"
validators={{
onChangeListenTo: ['password'],
onChange: ({ value }) => {
if (value !== group.getFieldValue('password')) {
return 'Passwords do not match';
}
return undefined;
},
}}
>
{(field) => (
<div>
<field.TextField label="Confirm Password" />
<field.ErrorInfo />
</div>
)}
</group.AppField>
</div>
);
},
});Use the group by passing form and a fields path to map to nested values:
<PasswordFieldGroup form={form} fields="credentials" title="Set Password" />Field groups work with arrays too:
<form.Field name="accounts" mode="array">
{(field) =>
field.state.value.map((account, i) => (
<PasswordFieldGroup
key={i}
form={form}
fields={`accounts[${i}]`}
title={`Account ${i + 1}`}
/>
))
}
</form.Field>React Aria Integration
Component Mapping
| Form Control | React Aria | Binding Prop | Change Handler |
|---|---|---|---|
| Text input | TextField | value | onChange (direct value) |
| Numeric input | NumberField | value | onChange (direct value) |
| Dropdown | Select | selectedKey | onSelectionChange |
| Autocomplete | ComboBox | selectedKey | onSelectionChange |
| Boolean | Checkbox | isSelected | onChange |
| Toggle | Switch | isSelected | onChange |
| Single select | RadioGroup | value | onChange |
| Date | DatePicker | value | onChange |
React Aria components handle validation display via isInvalid and errorMessage props directly — no separate error component needed.
Text Input
<form.Field
name="username"
children={(field) => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
return (
<TextField
label="Username"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(value) => field.handleChange(value)}
isInvalid={isInvalid}
errorMessage={
isInvalid ? field.state.meta.errors.join(', ') : undefined
}
/>
);
}}
/>Select
<form.Field
name="language"
children={(field) => (
<Select
label="Language"
selectedKey={field.state.value}
onSelectionChange={(key) => field.handleChange(key as string)}
>
<SelectItem id="en">English</SelectItem>
<SelectItem id="es">Spanish</SelectItem>
<SelectItem id="fr">French</SelectItem>
</Select>
)}
/>Switch
<form.Field
name="notifications"
children={(field) => (
<Switch isSelected={field.state.value} onChange={field.handleChange}>
Enable notifications
</Switch>
)}
/>Checkbox
<form.Field
name="terms"
children={(field) => (
<Checkbox isSelected={field.state.value} onChange={field.handleChange}>
Accept terms and conditions
</Checkbox>
)}
/>Radio Group
const plans = [
{ id: 'basic', title: 'Basic', description: 'For individuals' },
{ id: 'pro', title: 'Pro', description: 'For teams' },
];
<form.Field
name="plan"
children={(field) => (
<RadioGroup
label="Plan"
value={field.state.value}
onChange={field.handleChange}
>
{plans.map((plan) => (
<Radio key={plan.id} value={plan.id}>
{plan.title}
</Radio>
))}
</RadioGroup>
)}
/>;Checkbox Group
Use mode="array" with CheckboxGroup for multi-select checkbox patterns:
<form.Field
name="features"
mode="array"
children={(field) => (
<CheckboxGroup
label="Features"
value={field.state.value}
onChange={field.handleChange}
>
{features.map((feature) => (
<Checkbox key={feature.id} value={feature.id}>
{feature.label}
</Checkbox>
))}
</CheckboxGroup>
)}
/>Key Differences from shadcn/ui
| Concern | React Aria | shadcn/ui |
|---|---|---|
| Error display | isInvalid + errorMessage props | <FieldError errors={...} /> component |
| Invalid state | isInvalid prop | data-invalid + aria-invalid manually |
| Layout | Built into component | Field + FieldContent composition |
| Change handler | Consistent onChange with direct value | Varies per component |
| Checkbox array | CheckboxGroup with onChange array | Manual pushValue/removeValue |
Schema Validation
Standard Schema Support
TanStack Form natively supports Standard Schema libraries:
| Library | Minimum Version |
|---|---|
| Zod | v3.24.0+ |
| Valibot | v1.0.0+ |
| ArkType | v2.1.20+ |
| Yup | v1.7.0+ |
No adapter needed - pass schemas directly to validators.
Zod
Field-Level Validation
import { z } from 'zod';
const emailSchema = z.string().email();
<form.Field
name="email"
validators={{
onChange: emailSchema,
}}
children={(field) => (
<input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
)}
/>;Form-Level Schema
const userSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
age: z.number().min(13),
});
const form = useForm({
defaultValues: {
email: '',
password: '',
age: 0,
},
validators: {
onSubmit: userSchema,
},
});Password Confirmation
const passwordSchema = z
.object({
password: z.string().min(8),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
const form = useForm({
defaultValues: {
password: '',
confirmPassword: '',
},
validators: {
onSubmit: passwordSchema,
},
});Enum Validation
const planSchema = z.enum(['basic', 'pro', 'enterprise']);
<form.Field
name="plan"
validators={{
onChange: planSchema,
}}
/>;Array Validation
const emailsSchema = z
.array(z.object({ address: z.string().email() }))
.min(1, 'At least one email is required')
.max(5, 'Maximum 5 emails allowed');
const form = useForm({
defaultValues: {
emails: [],
},
validators: {
onSubmit: z.object({ emails: emailsSchema }),
},
});Custom Error Messages
const emailSchema = z.string().email('Please enter a valid email address');
<form.Field
name="email"
validators={{
onChange: ({ value }) => {
const result = emailSchema.safeParse(value);
if (!result.success) {
return result.error.errors[0].message;
}
return undefined;
},
}}
/>;Valibot
import * as v from 'valibot';
const emailSchema = v.pipe(v.string(), v.email());
<form.Field
name="email"
validators={{
onChange: emailSchema,
}}
/>;Form-level:
const userSchema = v.object({
email: v.pipe(v.string(), v.email()),
password: v.pipe(v.string(), v.minLength(8)),
});
const form = useForm({
defaultValues: {
email: '',
password: '',
},
validators: {
onSubmit: userSchema,
},
});ArkType
import { type } from 'arktype';
const emailType = type('string.email');
<form.Field
name="email"
validators={{
onChange: emailType,
}}
/>;Form-level:
const userType = type({
email: 'string.email',
'password?': 'string>=8',
});
const form = useForm({
defaultValues: {
email: '',
password: '',
},
validators: {
onSubmit: userType,
},
});Yup
import * as yup from 'yup';
const emailSchema = yup.string().email().required();
<form.Field
name="email"
validators={{
onChange: emailSchema,
}}
/>;Form-level:
const userSchema = yup.object({
email: yup.string().email().required(),
password: yup.string().min(8).required(),
});
const form = useForm({
defaultValues: {
email: '',
password: '',
},
validators: {
onSubmit: userSchema,
},
});Zod Advanced Patterns
Conditional Validation
const formSchema = z
.object({
accountType: z.enum(['personal', 'business']),
companyName: z.string().optional(),
taxId: z.string().optional(),
})
.refine(
(data) => {
if (data.accountType === 'business') {
return !!data.companyName && !!data.taxId;
}
return true;
},
{
message: 'Company name and tax ID are required for business accounts',
path: ['companyName'],
},
);Transform and Validate
const schema = z
.string()
.transform((val) => val.trim())
.pipe(z.string().min(3));
<form.Field
name="username"
validators={{
onChange: schema,
}}
/>;Nested Objects
const addressSchema = z.object({
street: z.string().min(1),
city: z.string().min(1),
postalCode: z.string().regex(/^\d{5}$/),
});
const userSchema = z.object({
name: z.string().min(1),
address: addressSchema,
});
const form = useForm({
defaultValues: {
name: '',
address: {
street: '',
city: '',
postalCode: '',
},
},
validators: {
onSubmit: userSchema,
},
});Union Types
const paymentSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('card'),
cardNumber: z.string().regex(/^\d{16}$/),
cvv: z.string().regex(/^\d{3}$/),
}),
z.object({
type: z.literal('bank'),
accountNumber: z.string(),
routingNumber: z.string(),
}),
]);Coercion
const schema = z.object({
age: z.coerce.number().min(13),
acceptTerms: z.coerce.boolean(),
});
const form = useForm({
defaultValues: {
age: 0,
acceptTerms: false,
},
validators: {
onSubmit: schema,
},
});Validation Strategy Comparison
| Strategy | When to Use | Performance |
|---|---|---|
| Form-level | Simple forms, submit-only validation | Best |
| Field-level | Real-time feedback, complex dependencies | Good |
| Mixed | Most forms (form schema + field async) | Good |
| Schema + async | Server-side checks + client validation | Slower |
Best Practices
- Use form-level schemas for simple validation
- Use field-level validators for async checks (username availability, etc.)
- Combine both: form schema for structure, field async for server validation
- Always validate server-side too, never trust client-only validation
- Use
.safeParse()to extract custom error messages from schemas - Coerce types when working with HTML form inputs (always return strings)
Form+Query Integration
Basic Form Setup with Zod
import { useForm } from '@tanstack/react-form';
import { z } from 'zod';
const userSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
age: z.number().min(18, 'Must be at least 18'),
});
type UserFormData = z.infer<typeof userSchema>;
function UserForm() {
const form = useForm({
defaultValues: { name: '', email: '', age: 0 } satisfies UserFormData,
onSubmit: async ({ value }) => {
await api.createUser(value);
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
>
<form.Field name="name" validators={{ onChange: z.string().min(2) }}>
{(field) => (
<div>
<label htmlFor={field.name}>Name</label>
<input
id={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors.length > 0 && (
<span className="error">{field.state.meta.errors[0]}</span>
)}
</div>
)}
</form.Field>
<form.Subscribe
selector={(state) => [state.canSubmit, state.isSubmitting]}
>
{([canSubmit, isSubmitting]) => (
<button type="submit" disabled={!canSubmit || isSubmitting}>
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
)}
</form.Subscribe>
</form>
);
}Form-Level Validation (Cross-Field)
const form = useForm({
defaultValues: { password: '', confirmPassword: '' },
validators: {
onChange: z
.object({
password: z.string(),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'],
}),
},
onSubmit: async ({ value }) => {
/* submit */
},
});Async Validation
<form.Field
name="username"
validators={{
onChange: z.string().min(3),
onBlurAsync: async ({ value }) => {
const isAvailable = await checkUsernameAvailable(value);
if (!isAvailable) return 'Username is already taken';
return undefined;
},
onBlurAsyncDebounceMs: 500,
}}
>
{(field) => (
<div>
<input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
/>
{field.state.meta.isValidating && <span>Checking...</span>}
</div>
)}
</form.Field>Form Submission with Error Handling
const form = useForm({
defaultValues: { name: '', email: '' },
onSubmit: async ({ value, formApi }) => {
try {
await api.createUser(value);
formApi.reset();
} catch (error) {
formApi.setErrorMap({ onSubmit: error.message });
}
},
});Display submission errors and reset:
<form.Subscribe selector={(state) => state.errorMap.onSubmit}>
{(error) => error && <div className="error">{error}</div>}
</form.Subscribe>
<form.Subscribe
selector={(state) => ({
canSubmit: state.canSubmit,
isSubmitting: state.isSubmitting,
isDirty: state.isDirty,
})}
>
{({ canSubmit, isSubmitting, isDirty }) => (
<div>
<button type="submit" disabled={!canSubmit || isSubmitting}>
{isSubmitting ? 'Saving...' : 'Save'}
</button>
<button type="button" onClick={() => form.reset()} disabled={!isDirty}>
Reset
</button>
</div>
)}
</form.Subscribe>Field Arrays
<form.Field name="users" mode="array">
{(field) => (
<div>
{field.state.value.map((_, index) => (
<div key={index}>
<form.Field name={`users[${index}].name`}>
{(subField) => (
<input
placeholder="Name"
value={subField.state.value}
onChange={(e) => subField.handleChange(e.target.value)}
/>
)}
</form.Field>
<button
type="button"
onClick={() => field.removeValue(index)}
disabled={field.state.value.length <= 1}
>
Remove
</button>
</div>
))}
<button
type="button"
onClick={() => field.pushValue({ name: '', email: '' })}
>
Add User
</button>
</div>
)}
</form.Field>Custom Field Components
import { useFieldContext } from '@/hooks/form-context';
interface TextFieldProps {
label: string;
type?: 'text' | 'email' | 'password';
placeholder?: string;
}
export function TextField({
label,
type = 'text',
placeholder,
}: TextFieldProps) {
const field = useFieldContext();
return (
<div className="form-field">
<label htmlFor={field.name}>{label}</label>
<input
id={field.name}
type={type}
placeholder={placeholder}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
className={`form-input ${field.state.meta.errors.length > 0 ? 'error' : ''}`}
/>
{field.state.meta.isTouched && field.state.meta.errors.length > 0 && (
<span className="form-error">{field.state.meta.errors[0]}</span>
)}
</div>
);
}Query Cache Invalidation
Invalidate query cache after form submission:
const queryClient = useQueryClient();
const form = useForm({
defaultValues: { title: '', body: '' },
onSubmit: async ({ value }) => {
await createPost(value);
await queryClient.invalidateQueries({ queryKey: ['posts'] });
navigate({ to: '/posts' });
},
});Optimistic Form Submissions
Pair useForm with useMutation for optimistic updates:
const updateMutation = useMutation({
mutationFn: (data: UpdateUserDto) => api.updateUser(userId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: userKeys.detail(userId) });
},
});
const form = useForm({
defaultValues: { name: user.name, email: user.email, bio: user.bio ?? '' },
onSubmit: async ({ value }) => {
await updateMutation.mutateAsync(value);
},
});Display mutation state in the submit button:
<form.Subscribe selector={(state) => state.isSubmitting}>
{(isSubmitting) => (
<button type="submit" disabled={isSubmitting || updateMutation.isPending}>
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
</button>
)}
</form.Subscribe>;
{
updateMutation.isError && (
<div className="error">{updateMutation.error.message}</div>
);
}Server Function Integration (TanStack Start)
Pair forms with createServerFn for full-stack form flows with auth and validation:
import { createServerFn } from '@tanstack/react-start';
import { useForm } from '@tanstack/react-form';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { z } from 'zod';
const createPostSchema = z.object({
title: z.string().min(1),
body: z.string().min(10),
});
const createPost = createServerFn({ method: 'POST' })
.inputValidator(createPostSchema)
.handler(async ({ data, request }) => {
const session = await auth.api.getSession({ headers: request.headers });
if (!session) return { error: 'Unauthorized', code: 'AUTH_REQUIRED' };
const post = await db.insert(posts).values({
...data,
authorId: session.user.id,
});
return { success: true, post };
});Wire the server function into a form with mutation and cache invalidation:
function CreatePostForm() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const mutation = useMutation({
mutationFn: (values: z.infer<typeof createPostSchema>) =>
createPost({ data: values }),
onSuccess: (result) => {
if ('error' in result) {
form.setErrorMap({ onSubmit: result.error });
return;
}
queryClient.invalidateQueries({ queryKey: ['posts'] });
navigate({ to: '/posts' });
},
});
const form = useForm({
defaultValues: { title: '', body: '' },
onSubmit: async ({ value }) => {
await mutation.mutateAsync(value);
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
>
{/* fields */}
</form>
);
}Server functions return structured results instead of throwing. Check for errors in onSuccess, not onError.
Anti-Patterns
- Loading waterfalls: Use
ensureQueryDatain route loaders, notuseQueryin components - Global QueryClient singleton: Create QueryClient per request inside
getRouter()for SSR safety - Manual SSR dehydration: Use
setupRouterSsrQueryIntegrationinstead of manualdehydrate/hydrate - Premature adoption: Build vanilla first, adopt TanStack selectively where clear benefit exists
shadcn/ui Integration
Component Mapping
| shadcn/ui Component | Form Binding Prop | Change Handler |
|---|---|---|
Input | value | onChange (event) |
Textarea | value | onChange (event) |
Select | value | onValueChange |
Switch | checked | onCheckedChange |
Checkbox | checked | onCheckedChange |
RadioGroup | value | onValueChange |
Field Layout Components
shadcn/ui provides layout primitives that wrap form controls:
| Component | Purpose |
|---|---|
Field | Container for a single field with validation |
FieldGroup | Groups multiple fields together |
FieldLabel | Accessible label linked via htmlFor |
FieldDescription | Help text below the control |
FieldError | Displays validation errors |
FieldSet | Groups related fields (checkbox, radio) |
FieldLegend | Legend for a fieldset |
FieldContent | Content wrapper for horizontal layouts |
FieldTitle | Title within a field (radio cards) |
Validation Pattern
Every field follows the same validation display pattern:
<form.Field
name="fieldName"
children={(field) => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>Label</FieldLabel>
{/* control with aria-invalid={isInvalid} */}
<FieldDescription>Help text.</FieldDescription>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}}
/>data-invalidonFieldtriggers invalid styling on all childrenaria-invalidon the control communicates state to assistive technologyFieldErroraccepts theerrorsarray directly from field meta
Form Setup
import { useForm } from '@tanstack/react-form';
import { z } from 'zod';
import { toast } from 'sonner';
const formSchema = z.object({
title: z.string().min(1, 'Title is required'),
description: z.string().min(10, 'At least 10 characters'),
});
export function BugReportForm() {
const form = useForm({
defaultValues: {
title: '',
description: '',
},
validators: {
onSubmit: formSchema,
},
onSubmit: async ({ value }) => {
toast.success('Form submitted successfully');
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
>
<FieldGroup>{/* fields here */}</FieldGroup>
<Button type="submit">Submit</Button>
</form>
);
}Input
<form.Field
name="title"
children={(field) => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>Bug Title</FieldLabel>
<Input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
placeholder="Login button not working on mobile"
autoComplete="off"
/>
<FieldDescription>
Provide a concise title for your bug report.
</FieldDescription>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}}
/>Textarea
Same binding pattern as Input — value + onChange event:
<form.Field
name="description"
children={(field) => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>Description</FieldLabel>
<Textarea
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
placeholder="Describe the issue..."
/>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}}
/>Select
Uses onValueChange (direct value, not event):
<form.Field
name="language"
children={(field) => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field orientation="responsive" data-invalid={isInvalid}>
<FieldContent>
<FieldLabel htmlFor="form-select-language">
Spoken Language
</FieldLabel>
<FieldDescription>
For best results, select the language you speak.
</FieldDescription>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</FieldContent>
<Select
name={field.name}
value={field.state.value}
onValueChange={field.handleChange}
>
<SelectTrigger
id="form-select-language"
aria-invalid={isInvalid}
className="min-w-[120px]"
>
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent position="item-aligned">
<SelectItem value="en">English</SelectItem>
<SelectItem value="es">Spanish</SelectItem>
<SelectItem value="fr">French</SelectItem>
</SelectContent>
</Select>
</Field>
);
}}
/>Switch
Uses horizontal Field orientation with FieldContent wrapper:
<form.Field
name="twoFactor"
children={(field) => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field orientation="horizontal" data-invalid={isInvalid}>
<FieldContent>
<FieldLabel htmlFor="form-switch-twoFactor">
Multi-factor authentication
</FieldLabel>
<FieldDescription>
Enable multi-factor authentication to secure your account.
</FieldDescription>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</FieldContent>
<Switch
id="form-switch-twoFactor"
name={field.name}
checked={field.state.value}
onCheckedChange={field.handleChange}
aria-invalid={isInvalid}
/>
</Field>
);
}}
/>Checkbox Group (Array)
Use mode="array" with pushValue/removeValue for multi-select:
const tasks = [
{ id: 'bug-fix', label: 'Bug fixes' },
{ id: 'feature', label: 'New features' },
{ id: 'refactor', label: 'Refactoring' },
];
<form.Field
name="tasks"
mode="array"
children={(field) => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
return (
<FieldSet>
<FieldLegend variant="label">Tasks</FieldLegend>
<FieldDescription>
Select the task types you want notifications for.
</FieldDescription>
<FieldGroup data-slot="checkbox-group">
{tasks.map((task) => (
<Field
key={task.id}
orientation="horizontal"
data-invalid={isInvalid}
>
<Checkbox
id={`form-checkbox-${task.id}`}
name={field.name}
aria-invalid={isInvalid}
checked={field.state.value.includes(task.id)}
onCheckedChange={(checked) => {
if (checked) {
field.pushValue(task.id);
} else {
const index = field.state.value.indexOf(task.id);
if (index > -1) {
field.removeValue(index);
}
}
}}
/>
<FieldLabel
htmlFor={`form-checkbox-${task.id}`}
className="font-normal"
>
{task.label}
</FieldLabel>
</Field>
))}
</FieldGroup>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</FieldSet>
);
}}
/>;RadioGroup
Use FieldSet/FieldLegend to group radio options:
const plans = [
{ id: 'basic', title: 'Basic', description: 'For individuals' },
{ id: 'pro', title: 'Pro', description: 'For teams' },
{ id: 'enterprise', title: 'Enterprise', description: 'For organizations' },
];
<form.Field
name="plan"
children={(field) => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
return (
<FieldSet>
<FieldLegend>Plan</FieldLegend>
<FieldDescription>
You can upgrade or downgrade your plan at any time.
</FieldDescription>
<RadioGroup
name={field.name}
value={field.state.value}
onValueChange={field.handleChange}
>
{plans.map((plan) => (
<FieldLabel key={plan.id} htmlFor={`form-radiogroup-${plan.id}`}>
<Field orientation="horizontal" data-invalid={isInvalid}>
<FieldContent>
<FieldTitle>{plan.title}</FieldTitle>
<FieldDescription>{plan.description}</FieldDescription>
</FieldContent>
<RadioGroupItem
value={plan.id}
id={`form-radiogroup-${plan.id}`}
aria-invalid={isInvalid}
/>
</Field>
</FieldLabel>
))}
</RadioGroup>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</FieldSet>
);
}}
/>;Key Differences from React Aria
| Concern | shadcn/ui | React Aria |
|---|---|---|
| Error display | <FieldError errors={...} /> | errorMessage prop on component |
| Invalid state | data-invalid + aria-invalid manually | isInvalid prop |
| Layout | Field + FieldContent composition | Built into component |
| Change handler | Varies per component (see table above) | Consistent onChange with direct value |
| Checkbox array | Manual pushValue/removeValue | CheckboxGroup with onChange array |
Field Validation
Validation Timing
<form.Field
name="email"
validators={{
onChange: ({ value }) =>
!value.includes('@') ? 'Invalid email' : undefined,
onBlur: ({ value }) =>
value.length === 0 ? 'Email is required' : undefined,
onSubmit: ({ value }) =>
value.endsWith('@example.com') ? 'Example emails not allowed' : undefined,
}}
children={(field) => (
<input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
/>
)}
/>| Timing | When It Runs | Use Case |
|---|---|---|
onChange | Every time the field value changes | Real-time feedback |
onBlur | When the user leaves the field | Deferred validation |
onSubmit | When the form is submitted | Final validation before submit |
Sync Validation
Validators receive a context object:
type ValidatorContext = {
value: T;
fieldApi: FieldApi<TFormData, TName>;
};
<form.Field
name="username"
validators={{
onChange: ({ value }) => {
if (!value) return 'Username is required';
if (value.length < 3) return 'Username must be at least 3 characters';
if (!/^[a-z0-9_]+$/.test(value))
return 'Username can only contain lowercase letters, numbers, and underscores';
return undefined;
},
}}
/>;Async Validation
Debounce async validators to avoid excessive server requests:
<form.Field
name="email"
validators={{
onChange: ({ value }) =>
!value.includes('@') ? 'Invalid email format' : undefined,
onChangeAsyncDebounceMs: 500,
onChangeAsync: async ({ value }) => {
const response = await fetch(`/api/check-email?email=${value}`);
const data = await response.json();
return data.available ? undefined : 'Email already taken';
},
}}
children={(field) => (
<div>
<input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.isValidating && <span>Checking...</span>}
{field.state.meta.isTouched && field.state.meta.errors.length > 0 && (
<em>{field.state.meta.errors.join(', ')}</em>
)}
</div>
)}
/>Debounce Configuration
Set a default debounce for all async validators on a field:
<form.Field
name="username"
asyncDebounceMs={500}
validators={{
onChange: ({ value }) => (value.length < 3 ? 'Too short' : undefined),
onChangeAsync: async ({ value }) => {
const available = await checkUsername(value);
return available ? undefined : 'Username taken';
},
onBlurAsync: async ({ value }) => {
return validateWithServer(value);
},
}}
/>Override debounce per validator:
<form.Field
name="username"
asyncDebounceMs={500}
validators={{
onChangeAsyncDebounceMs: 1000,
onChangeAsync: async ({ value }) => {
return checkAvailability(value);
},
}}
/>Linked Fields
Re-validate a field when another field changes:
<form.Field
name="password"
children={(field) => (
<input
type="password"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
)}
/>
<form.Field
name="confirmPassword"
validators={{
onChangeListenTo: ['password'],
onChange: ({ value, fieldApi }) => {
if (value !== fieldApi.form.getFieldValue('password')) {
return 'Passwords do not match';
}
return undefined;
},
}}
children={(field) => (
<div>
<input
type="password"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.isTouched && field.state.meta.errors.length > 0 && (
<em>{field.state.meta.errors.join(', ')}</em>
)}
</div>
)}
/>Multiple dependencies:
<form.Field
name="endDate"
validators={{
onChangeListenTo: ['startDate', 'duration'],
onChange: ({ value, fieldApi }) => {
const startDate = fieldApi.form.getFieldValue('startDate');
const duration = fieldApi.form.getFieldValue('duration');
if (new Date(value) <= new Date(startDate)) {
return 'End date must be after start date';
}
const diffDays = Math.floor(
(new Date(value).getTime() - new Date(startDate).getTime()) /
(1000 * 60 * 60 * 24),
);
if (diffDays > duration) {
return `Duration cannot exceed ${duration} days`;
}
return undefined;
},
}}
/>Conditional Validation
Validate based on other field values:
<form.Field
name="companyName"
validators={{
onChangeListenTo: ['accountType'],
onChange: ({ value, fieldApi }) => {
const accountType = fieldApi.form.getFieldValue('accountType');
if (accountType === 'business' && !value) {
return 'Company name is required for business accounts';
}
return undefined;
},
}}
/>Server-Side Validation
Async validation with server:
const checkUsername = async (username: string) => {
const response = await fetch('/api/check-username', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username }),
});
return response.json();
};
<form.Field
name="username"
validators={{
onChange: ({ value }) => {
if (value.length < 3) return 'Too short';
if (!/^[a-z0-9_]+$/.test(value)) return 'Invalid characters';
return undefined;
},
onChangeAsyncDebounceMs: 500,
onChangeAsync: async ({ value }) => {
const result = await checkUsername(value);
return result.available ? undefined : 'Username already taken';
},
}}
/>;Form-level async validation:
const form = useForm({
defaultValues: { email: '', username: '' },
validators: {
onSubmitAsync: async ({ value }) => {
const result = await fetch('/api/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(value),
}).then((r) => r.json());
if (!result.valid) {
return {
form: 'Invalid data',
fields: {
email: result.errors.email,
username: result.errors.username,
},
};
}
return null;
},
},
});Error Display Patterns
Show errors after touched:
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
{
isInvalid && (
<span className="text-red-600">{field.state.meta.errors.join(', ')}</span>
);
}Show errors immediately:
{
field.state.meta.errors.length > 0 && (
<span className="text-red-600">{field.state.meta.errors.join(', ')}</span>
);
}Show first error only:
{
field.state.meta.isTouched && field.state.meta.errors[0] && (
<span className="text-red-600">{field.state.meta.errors[0]}</span>
);
}Validation Notes
- Always validate on both client and server
- Return
undefinedfor valid state, error string for invalid - Async validators return
Promise<string | undefined> - Use debouncing for expensive async validation
onChangeListenTotriggers re-validation when dependencies change- Form-level validators can return
{ form: string, fields: Record<string, string> }