--- name: tanstack-form description: Headless, performant, and type-safe form state management for TS/JS, React, Vue, Angular, Solid, Lit, and Svelte. --- ## Overview TanStack Form is a headless form library with deep TypeScript integration. It provides field-level and form-level validation (sync/async), array fields, linked/dependent fields, fine-grained reactivity, and schema validation adapter support (Zod, Valibot, Yup). **Package:** `@tanstack/react-form` **Adapters:** `@tanstack/zod-form-adapter`, `@tanstack/valibot-form-adapter` **Status:** Stable (v1) ## Installation ```bash npm install @tanstack/react-form # Optional schema adapters: npm install @tanstack/zod-form-adapter zod npm install @tanstack/valibot-form-adapter valibot ``` ## Core: useForm ```tsx import { useForm } from '@tanstack/react-form'; function MyForm() { const form = useForm({ defaultValues: { firstName: '', lastName: '', email: '', age: 0 }, onSubmit: async ({ value }) => { // value is fully typed await submitToServer(value); }, onSubmitInvalid: ({ value, formApi }) => { console.log('Validation failed:', formApi.state.errors); } }); return (
{ e.preventDefault(); e.stopPropagation(); form.handleSubmit(); }} > {/* Fields */} ({ canSubmit: state.canSubmit, isSubmitting: state.isSubmitting })} children={({ canSubmit, isSubmitting }) => ( )} /> ); } ``` ## Fields (form.Field) ```tsx value.length < 3 ? 'Must be at least 3 characters' : undefined, }} children={(field) => (
field.handleChange(e.target.value)} /> {field.state.meta.isTouched && field.state.meta.errors.length > 0 && ( {field.state.meta.errors.join(', ')} )}
)} /> {(field) => ( field.handleChange(e.target.value)} onBlur={field.handleBlur} /> )} ``` ## Validation ### Validation Timing | Cause | When | | ---------- | ------------------------ | | `onChange` | After every value change | | `onBlur` | When field loses focus | | `onSubmit` | During submission | | `onMount` | When field mounts | ### Synchronous Validation ```tsx { if (value < 18) return 'Must be 18 or older'; return undefined; // undefined = valid }, onBlur: ({ value }) => { if (!value) return 'Required'; return undefined; } }} /> ``` ### Asynchronous Validation ```tsx { const res = await fetch(`/api/check-username?q=${value}`); const { available } = await res.json(); if (!available) return 'Username taken'; return undefined; } }} > {(field) => ( <> field.handleChange(e.target.value)} /> {field.state.meta.isValidating && Checking...} )} ``` ### Schema Validation (Zod) ```tsx import { zodValidator } from '@tanstack/zod-form-adapter' import { z } from 'zod' const form = useForm({ defaultValues: { email: '', age: 0 }, validatorAdapter: zodValidator(), onSubmit: async ({ value }) => { /* ... */ }, }) ``` ### Form-Level Validation ```tsx const form = useForm({ defaultValues: { password: '', confirmPassword: '' }, validators: { onChange: ({ value }) => { if (value.password !== value.confirmPassword) { return 'Passwords do not match'; } return undefined; } } }); ``` ### Linked/Dependent Fields ```tsx { const password = fieldApi.form.getFieldValue('password'); if (value !== password) return 'Passwords do not match'; return undefined; } }} /> ``` ## Array Fields ```tsx {(field) => (
{field.state.value.map((_, index) => (
{(subField) => ( subField.handleChange(e.target.value)} /> )}
))}
)}
``` ### Array Methods ```typescript field.pushValue(item); // Add to end field.insertValue(index, item); // Insert at index field.replaceValue(index, item); // Replace at index field.removeValue(index); // Remove at index field.swapValues(indexA, indexB); // Swap positions field.moveValue(from, to); // Move position ``` ## Listeners (Side Effects) ```tsx { // Side effect: reset dependent fields form.setFieldValue('state', ''); form.setFieldValue('postalCode', ''); } }} /> ``` ## Reactivity (form.Subscribe & useStore) ```tsx // Render-prop subscription (fine-grained) ({ canSubmit: state.canSubmit, isDirty: state.isDirty })} children={({ canSubmit, isDirty }) => (
{isDirty && Unsaved changes}
)} />; // Hook-based subscription function FormStatus() { const isValid = form.useStore((s) => s.isValid); return isValid ? null :

Fix errors

; } ``` ## Form State ```typescript interface FormState { values: TFormData; errors: ValidationError[]; errorMap: Record; isFormValid: boolean; isFieldsValid: boolean; isValid: boolean; // isFormValid && isFieldsValid isTouched: boolean; isPristine: boolean; isDirty: boolean; isSubmitting: boolean; isSubmitted: boolean; isSubmitSuccessful: boolean; submissionAttempts: number; canSubmit: boolean; // isValid && !isSubmitting } ``` ## Field State ```typescript interface FieldState { value: TData; meta: { isTouched: boolean; isDirty: boolean; isPristine: boolean; isValidating: boolean; errors: ValidationError[]; errorMap: Record; }; } ``` ## FormApi Methods ```typescript form.handleSubmit(); form.reset(); form.getFieldValue(field); form.setFieldValue(field, value); form.getFieldMeta(field); form.setFieldMeta(field, updater); form.validateAllFields(cause); form.validateField(field, cause); form.deleteField(field); ``` ## Shared Form Options (formOptions) ```tsx import { formOptions } from '@tanstack/react-form'; const sharedOpts = formOptions({ defaultValues: { firstName: '', lastName: '' } }); // Reuse across components const form = useForm({ ...sharedOpts, onSubmit: async ({ value }) => { /* ... */ } }); ``` ## Server-Side Validation ```tsx // TanStack Start / Next.js server action import { ServerValidateError } from '@tanstack/react-form/nextjs'; export async function validateForm(data: FormData) { const email = data.get('email') as string; if (await checkEmailExists(email)) { throw new ServerValidateError({ form: 'Submission failed', fields: { email: 'Email already registered' } }); } } ``` ## TypeScript Integration ```tsx // Type-safe field paths with DeepKeys interface UserForm { name: string address: { street: string; city: string } tags: string[] contacts: Array<{ name: string; phone: string }> } // TypeScript auto-completes all valid paths: // 'name', 'address', 'address.street', 'address.city', 'tags', 'contacts' // OK // Type Error! ``` ## Best Practices 1. **Always call `e.preventDefault()` and `e.stopPropagation()`** on form submit 2. **Always attach `onBlur={field.handleBlur}`** for blur validation and isTouched tracking 3. **Use `mode="array"`** for array fields to get array methods 4. **Return `undefined`** (not null/false) for valid validators 5. **Use `asyncDebounceMs`** for async validators to prevent API spam 6. **Check `isTouched` before showing errors** for better UX 7. **Use `form.Subscribe` with selectors** to minimize re-renders 8. **Use `formOptions`** for shared configuration across components 9. **Use schema validators** (Zod/Valibot) for complex validation rules 10. **Use `onChangeListenTo`** for cross-field validation dependencies ## Common Pitfalls - Forgetting `e.preventDefault()` on form submit (causes page reload) - Not attaching `onBlur` to inputs (breaks blur validation and isTouched) - Returning `null` or `false` instead of `undefined` for valid fields - Using `mode="array"` incorrectly (only needed on the array field itself, not sub-fields) - Subscribing to entire form state instead of using selectors (unnecessary re-renders) - Not using `asyncDebounceMs` with async validators (fires on every keystroke)