Tanstack Form

by tanstack-skills6f5521ecbdfbNo license35 starsListed Oct 8, 2026Updated Oct 8, 2026Repository updated 8 months ago

Headless, performant, and type-safe form state management for TS/JS, React, Vue, Angular, Solid, Lit, and Svelte.

Instructions onlySoftware Development
AI-generated overview

Reference guide for building type-safe forms with the TanStack Form library in TypeScript and JavaScript.

What it does
Provides usage documentation for TanStack Form, a headless form state library for TS/JS and frameworks such as React, Vue, Angular, Solid, Lit, and Svelte. It covers installation, the useForm hook, field components, sync and async validation, schema adapters, array fields, listeners, reactivity subscriptions, form and field state, FormApi methods, shared options, server-side validation, and TypeScript typing. It also lists best practices and common pitfalls.
When to use it
Use it when implementing or reviewing forms with TanStack Form and needing guidance on validation, array fields, dependent fields, or type-safe field paths. It suits developers working in React or other supported frameworks who want API and pattern reference rather than a runnable tool.
Requirements
No scripts are included; it is documentation only. Following the examples assumes a JavaScript or TypeScript project with the relevant TanStack Form package installed, and optionally Zod or Valibot adapters.

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 zodnpm 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 (    <form      onSubmit={(e) => {        e.preventDefault()        e.stopPropagation()        form.handleSubmit()      }}    >      {/* Fields */}      <form.Subscribe        selector={(state) => ({ canSubmit: state.canSubmit, isSubmitting: state.isSubmitting })}        children={({ canSubmit, isSubmitting }) => (          <button type="submit" disabled={!canSubmit}>            {isSubmitting ? 'Submitting...' : 'Submit'}          </button>        )}      />    </form>  )}

Fields (form.Field)

tsx
<form.Field  name="firstName"  validators={{    onChange: ({ value }) =>      value.length < 3 ? 'Must be at least 3 characters' : undefined,  }}  children={(field) => (    <div>      <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)}      />      {field.state.meta.isTouched && field.state.meta.errors.length > 0 && (        <em>{field.state.meta.errors.join(', ')}</em>      )}    </div>  )}/>
<!-- Nested fields use dot notation --><form.Field name="address.city">  {(field) => (    <input      value={field.state.value}      onChange={(e) => field.handleChange(e.target.value)}      onBlur={field.handleBlur}    />  )}</form.Field>

Validation

Validation Timing

CauseWhen
onChangeAfter every value change
onBlurWhen field loses focus
onSubmitDuring submission
onMountWhen field mounts

Synchronous Validation

tsx
<form.Field  name="age"  validators={{    onChange: ({ value }) => {      if (value < 18) return 'Must be 18 or older'      return undefined // undefined = valid    },    onBlur: ({ value }) => {      if (!value) return 'Required'      return undefined    },  }}/>

Asynchronous Validation

tsx
<form.Field  name="username"  asyncDebounceMs={500}  validators={{    onChangeAsync: async ({ value }) => {      const res = await fetch(`/api/check-username?q=${value}`)      const { available } = await res.json()      if (!available) return 'Username taken'      return undefined    },  }}>  {(field) => (    <>      <input value={field.state.value} onChange={(e) => field.handleChange(e.target.value)} />      {field.state.meta.isValidating && <span>Checking...</span>}    </>  )}</form.Field>

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.Field  name="email"  validators={{    onChange: z.string().email('Invalid email'),    onBlur: z.string().min(1, 'Required'),  }}/>
<form.Field  name="age"  validators={{    onChange: z.number().min(18, 'Must be 18+'),  }}/>

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
<form.Field  name="confirmPassword"  validators={{    onChangeListenTo: ['password'], // Re-validate when password changes    onChange: ({ value, fieldApi }) => {      const password = fieldApi.form.getFieldValue('password')      if (value !== password) return 'Passwords do not match'      return undefined    },  }}/>

Array Fields

tsx
<form.Field name="people" mode="array">  {(field) => (    <div>      {field.state.value.map((_, index) => (        <div key={index}>          <form.Field name={`people[${index}].name`}>            {(subField) => (              <input                value={subField.state.value}                onChange={(e) => subField.handleChange(e.target.value)}              />            )}          </form.Field>          <button type="button" onClick={() => field.removeValue(index)}>            Remove          </button>        </div>      ))}      <button type="button" onClick={() => field.pushValue({ name: '', age: 0 })}>        Add Person      </button>    </div>  )}</form.Field>

Array Methods

typescript
field.pushValue(item)              // Add to endfield.insertValue(index, item)     // Insert at indexfield.replaceValue(index, item)    // Replace at indexfield.removeValue(index)           // Remove at indexfield.swapValues(indexA, indexB)    // Swap positionsfield.moveValue(from, to)          // Move position

Listeners (Side Effects)

tsx
<form.Field  name="country"  listeners={{    onChange: ({ value }) => {      // Side effect: reset dependent fields      form.setFieldValue('state', '')      form.setFieldValue('postalCode', '')    },  }}/>

Reactivity (form.Subscribe & useStore)

tsx
// Render-prop subscription (fine-grained)<form.Subscribe  selector={(state) => ({ canSubmit: state.canSubmit, isDirty: state.isDirty })}  children={({ canSubmit, isDirty }) => (    <div>      {isDirty && <span>Unsaved changes</span>}      <button disabled={!canSubmit}>Save</button>    </div>  )}/>
// Hook-based subscriptionfunction FormStatus() {  const isValid = form.useStore((s) => s.isValid)  return isValid ? null : <p>Fix errors</p>}

Form State

typescript
interface FormState {  values: TFormData  errors: ValidationError[]  errorMap: Record<string, ValidationError>  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<TData> {  value: TData  meta: {    isTouched: boolean    isDirty: boolean    isPristine: boolean    isValidating: boolean    errors: ValidationError[]    errorMap: Record<ValidationCause, ValidationError>  }}

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 componentsconst form = useForm({  ...sharedOpts,  onSubmit: async ({ value }) => { /* ... */ },})

Server-Side Validation

tsx
// TanStack Start / Next.js server actionimport { 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 DeepKeysinterface 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'<form.Field name="address.city" />     // OK<form.Field name="nonexistent" />       // 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)

Source and attribution

Source:tanstack-skills/tanstack-skillsinplugins/tanstack-form/skills/tanstack-format commit6f5521e

License: No license

Content belongs to its original authors. SourceWeft indexes it from a public repository.

Report or request removal