TanStack
Getting Started

Migrating from v1

TanStack Form v2 keeps the same core idea as v1: create a form with useForm, render fields from the returned form API, and submit with form.handleSubmit(). Most migrations are not a full rewrite, but v2 does change several React-facing APIs that show up in examples and integration tests.

This page is a starting checklist for migrating React apps from v1 to v2.

Quick checklist

  • Keep useForm, defaultValues, onSubmit, form.Field, and form.Subscribe, but update render props from field.state.value and field.state.meta to field.value and field.meta.
  • Always provide defaultValues to useForm. v2 does not support omitting them.
  • Move field-level defaultValue props into form-level defaultValues.
  • Remove field-level defaultMeta props. Model initial validation and error visibility through validators, runOnMount, and errorVisibility.
  • Change validation from keyed event objects like { onChange, onBlur } to arrays of validator objects with run and triggers.
  • Change async validator debounce options such as onChangeAsyncDebounceMs to triggerDebounceMs on the validator object.
  • Change field cross-validation from onChangeListenTo / onBlurListenTo to watchFields on validators or listeners.
  • Replace mode="array" fields with form.ArrayField.
  • Keep array mutations on the ArrayField render prop, such as array.pushValue(...), or use path-based form methods such as form.pushFieldValue(...) when the control lives outside the render prop.
  • Read errors from field.errors, group.state.errors, and form.state.errors as validation issue objects. Use error.message when rendering text.
  • In form- and group-level validators, build routed errors with createErrorMap(...) and return the map directly.
  • Return createValidationError(...) from onSubmit when a submit handler needs to route server errors back to form or field state.
  • Use useSelector, which replaces v1's deprecated useStore usage.
  • Subscribe to form.atom and field.atom instead of form.store and field.store.
  • Use formOptions(...) for shared base form options.
  • Replace withForm components with plain components that accept a form prop typed as ReactFormType<typeof formOpts>.
  • Replace useFormGroup with form.FormGroup for scoped sections of the same form.
  • Replace withFieldGroup with defineFieldGroup(...).bindComponent(...) for reusable field bundles that map virtual field names to different concrete form paths.
  • Upgrade Vue to version 3.6 or newer when using the Vue adapter. Form Composition relies on behavior introduced in Vue 3.6.
  • Update CommonJS integrations to consume ESM. The v2 packages do not publish a CommonJS build and require Node.js 18 or newer.
  • Upgrade React to version 18 or newer. The v2 React adapter no longer supports React 17.
  • Update CommonJS integrations to consume ESM. The v2 packages do not publish a CommonJS build and require Node.js 18 or newer.

React version

The v2 React adapter supports React 18 and 19. React 17 is no longer supported, so upgrade react, react-dom, and their corresponding type packages before migrating. If you imported CrossVersionReactNode from @tanstack/react-form, import ReactNode from react instead.

Vue version

The v2 Vue adapter requires Vue 3.6 or newer. At the time of writing, Vue 3.6 has not yet been released as stable and is available as a release candidate. We hope Vue 3.6 will be stable by the time TanStack Form v2 becomes stable.

Form Composition relies on Vue 3.6 behavior to work correctly. Requiring it from the start lets the Vue adapter support Form Composition without adding a breaking minimum-version change later in the v2 release line.

Package format

The v2 core, framework, devtools, and server-adapter packages publish ESM exports only and declare Node.js 18 as their minimum version. Prefer import / export syntax, use dynamic import() when consuming them from CommonJS, and make sure any Node-based test, SSR, or build tooling can load ESM dependencies. There is no separate CommonJS build or require export to fall back to.

Basic fields

The v1 simple example and v2 basic example are intentionally similar. The main render-prop difference is that v2 exposes the subscribed field surface directly.

tsx
// v1
<form.Field name="firstName">
  {(field) => (
    <input
      name={field.name}
      value={field.state.value}
      onBlur={field.handleBlur}
      onChange={(event) => field.handleChange(event.target.value)}
    />
  )}
</form.Field>
tsx
// v2
<form.Field name="firstName">
  {(field) => (
    <input
      name={field.name}
      value={field.value}
      onBlur={field.handleBlur}
      onChange={(event) => field.handleChange(event.target.value)}
      aria-invalid={field.meta.isInvalid}
    />
  )}
</form.Field>

The same applies to common meta and error reads:

tsx
// v1
field.state.value
field.state.meta.isTouched
field.state.meta.isValid
field.state.meta.errors.join(',')
tsx
// v2
field.value
field.meta.isTouched
field.meta.isValid
field.errors.map((error) => error.message).join(',')

v2 also exposes field.meta.isInvalid, which is the inverse of isValid and is convenient for attributes such as aria-invalid.

The v2 React integration tests also assert that fields rerender independently when sibling fields change. Prefer reading only the subscribed field values and meta you need in each render prop.

Defaults and initial meta

In v1, a form could rely on field-level defaultValue props:

tsx
// v1
const form = useForm({
  defaultValues: {} as Person,
})

<form.Field name="firstName" defaultValue="">
  {(field) => <input value={field.state.value} />}
</form.Field>

In v2, defaultValues are required on the form and are the only place to define initial values:

tsx
// v2
const form = useForm({
  defaultValues: {
    firstName: '',
    lastName: '',
  },
})

<form.Field name="firstName">
  {(field) => <input value={field.value} />}
</form.Field>

<form.Field defaultMeta={...}> was also removed. If v1 code used defaultMeta={{ isTouched: true }} to show errors immediately, replace the implicit meta state with mount validation and an explicit visibility policy:

tsx
// v2
const form = useForm({
  defaultValues: {
    firstName: '',
  },
})

<form.Field
  name="firstName"
  validators={[
    {
      runOnMount: true,
      triggers: ['change', 'blur'],
      run: ({ value }) => (value ? undefined : 'First name is required'),
    },
  ]}
>
  {(field) => (
    <>
      <input
        value={field.value}
        onBlur={field.handleBlur}
        onChange={(event) => field.handleChange(event.target.value)}
      />
      {field.errors.map((error) => (
        <p key={error.message}>{error.message}</p>
      ))}
    </>
  )}
</form.Field>

If errors should validate on mount but remain hidden until the user blurs the field or tries to submit, use a form-level policy such as errorVisibility: ({ fieldState, state }) => fieldState.meta.isBlurred || state.submissionAttempts > 0 instead.

Validators

v1 validators are keyed by event names:

tsx
// v1
<form.Field
  name="firstName"
  validators={{
    onChange: ({ value }) =>
      !value
        ? 'A first name is required'
        : value.length < 3
          ? 'First name must be at least 3 characters'
          : undefined,
    onChangeAsyncDebounceMs: 500,
    onChangeAsync: async ({ value }) => {
      await checkName(value)
      return value.includes('error')
        ? 'No "error" allowed in first name'
        : undefined
    },
  }}
/>

v2 validators are ordered arrays. Each validator has a run function or standard schema, a triggers list, and optional behavior like debouncing or bailing:

tsx
// v2
<form.Field
  name="firstName"
  validators={[
    {
      run: ({ value }) => {
        if (value.length === 0) return 'A first name is required'
        if (value.length < 3) return 'First name is too short'
      },
      triggers: ['change', 'blur'],
      triggerDebounceMs: 300,
    },
    {
      run: async ({ value }) => {
        await checkName(value)
        return value.toLowerCase().includes('error')
          ? 'No "error" allowed in first name'
          : undefined
      },
      triggers: ['change'],
      bailIfInvalid: true,
    },
  ]}
/>

Useful translations:

v1v2
validators.onMountvalidator with runOnMount: true
validators.onChangevalidator with triggers: ['change']
validators.onBlurvalidator with triggers: ['blur']
validators.onSubmitvalidator with triggers: [], or rely on submit running validators by default
onChangeAsyncasync run with triggers: ['change']
onChangeAsyncDebounceMstriggerDebounceMs
onChangeListenTo / onBlurListenTowatchFields
validationLogic: revalidateLogic()migrate to explicit triggers, when, runOnSubmit, and bailIfInvalid rules

Submission is separate from the triggers list: every validator runs during submission by default. An empty triggers: [] therefore means "no change or blur validation, but still validate on submit." Set runOnSubmit: false when a validator must not run during client submission; do not add 'submit' to triggers.

triggers can contain strings or trigger config objects. Use config objects when a validator should only run for some changes:

tsx
validators={[
  {
    triggers: [
      {
        trigger: 'change',
        when: ({ value }) => Boolean(value),
      },
    ],
    watchFields: ['startDate'],
    run: ({ value, formApi }) => {
      const startDate = formApi.getFieldValue('startDate')
      if (value < startDate) return 'End date must be after the start date'
    },
  },
]}

Predicate callbacks are scope-aware. A when callback, a function-valued runOnSubmit, or a function-valued triggerDebounceMs receives scope and formApi; field predicates receive the field being validated as fieldApi, while group predicates also receive groupApi. For form and group predicates, an optional fieldApi is the field that triggered validation.

Use createValidator(...) when the scheduling policy should be reused with different validator functions or schemas while preserving their inferred types:

tsx
import { createValidator } from '@tanstack/react-form'

const validateAfterSubmit = createValidator({
  triggers: [
    {
      trigger: 'change',
      when: ({ groupApi, formApi }) =>
        (groupApi?.state.submissionAttempts ??
          formApi.state.submissionAttempts) > 0,
    },
  ],
})

const validators = [validateAfterSubmit(schema)]

Standard schemas

v1 accepted standard schemas in event-keyed validators:

tsx
// v1
useForm({
  defaultValues,
  validators: {
    onChange: schema,
  },
})

In v2, pass schemas as run values inside the validators array:

tsx
// v2
useForm({
  defaultValues,
  validators: [
    {
      run: schema,
      triggers: ['change'],
    },
  ],
})

The form's value remains its editable input state. When a Standard Schema validator runs successfully during submission, its parsed output is available in onSubmit at the corresponding schemaOutputs index. Non-schema entries are undefined. Validators with literal runOnSubmit: false are skipped during submission, and their slots are typed as undefined. Use the parsed entry when an endpoint expects the schema's output type.

For schema-led option inference, formOptions.strictSchema(...) uses the schema input as the form shape, while formOptions.looseSchema(...) permits editable nullish defaults and combines them with the schema shape. These option helpers only affect types; the validators still perform the runtime parsing.

When manually calling a schema, return parseIssues(...) with the failed issue array. In a field validator it produces field issues; in a form or group validator it routes issue paths into a form/field map; and in onSubmit it produces a branded submit validation result. The v2 field-group example uses this helper after calling schema.safeParse(...).

Errors and submit results

v1 examples commonly render errors from field.state.meta.errors. In v2, render from field.errors for fields, group.state.errors for form groups, and form.state.errors for the form. Each of the group and form collections is an array. The items are validation issue objects, so render error.message.

tsx
function FieldError({ field }: { field: AnyFieldApi }) {
  return (
    <small role={field.meta.isInvalid ? 'alert' : undefined} aria-live="polite">
      {field.errors.map((error) => error.message).join('\n')}
    </small>
  )
}

Form- and group-level validators can route errors to the form and individual fields. Build a map with the createErrorMap supplied to run, then return the map itself:

tsx
const form = useForm({
  defaultValues: {
    firstName: '',
  },
  validators: [
    {
      triggers: ['change'],
      run: ({ value, createErrorMap }) => {
        const errors = createErrorMap()

        if (!value.firstName.trim()) {
          errors.form = 'Please correct the highlighted fields'
          errors.fields.firstName = 'First name is required'
        }

        return errors
      },
    },
  ],
})

createErrorMap also accepts an initial { form, fields } object. An empty returned map is treated as a valid result. Field-level validators continue to return their own issue, string, issue array, or a valid result directly.

Submit handlers can now return a typed validation result. Use createValidationError to route endpoint errors back into form state:

tsx
const form = useForm({
  defaultValues: {
    firstName: '',
    lastName: '',
  },
  onSubmit: async ({ value, createValidationError }) => {
    const result = await saveUser(value)

    if (!result.ok) {
      return createValidationError({
        form: 'Could not save user',
        fields: {
          firstName: 'Name already exists',
          lastName: 'Name already exists',
        },
      })
    }

    return null
  },
})

form.handleSubmit() resolves to the validation-result array for that submission attempt, so framework examples can await it and continue to native or server submission when errors.length === 0. A thrown or rejected onSubmit marks submission unsuccessful but is not added to that array. Return createValidationError(...) or parseIssues(...) when a submit failure should become validation state.

Selectors and atoms

v1 re-exported useStore from @tanstack/react-store, but that usage was deprecated before v2. In v2, use useSelector and subscribe to atoms:

tsx
// v1
import { useStore } from '@tanstack/react-store'

const errors = useStore(form.store, (state) => state.errors)
const fieldErrors = useStore(field.store, (state) => state.meta.errors)
tsx
// v2
import { useSelector } from '@tanstack/react-form'

const errors = useSelector(form.atom, (state) => state.errors)
const fieldErrors = useSelector(field.atom, (state) => state.meta.errors)

The same rename applies when subscribing to field groups:

tsx
const values = useSelector(fields.atom, (values) => values)

Reading form.state or group.state gives an imperative snapshot; it does not subscribe a React component. Use field render props, form.Subscribe, group.Subscribe, or useSelector for reactive reads, and keep selectors focused on the state the component renders.

Arrays

v1 represented array fields with mode="array" and exposed array helpers from the field render prop:

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

v2 uses a dedicated form.ArrayField component for array subscriptions. Its render prop still exposes array methods, so the direct migration can keep the mutation next to the rendered array:

tsx
// v2
<form.ArrayField name="people">
  {(array) => (
    <>
      {array.value.map((_, index) => (
        <form.Field key={index} name={`people[${index}].name`}>
          {(field) => (
            <input
              name={field.name}
              value={field.value}
              onBlur={field.handleBlur}
              onChange={(event) => field.handleChange(event.target.value)}
            />
          )}
        </form.Field>
      ))}
      <button
        type="button"
        onClick={() => array.pushValue({ name: '', age: 0 })}
      >
        Add person
      </button>
    </>
  )}
</form.ArrayField>

When the mutation control is outside ArrayField, use the path-based form API instead:

tsx
form.pushFieldValue('people', { name: '', age: 0 })

The same choice is available for inserting, removing, moving, swapping, filtering, and clearing values: use the relative methods on array or the field-path methods on form.

The v2 array example calls out the performance reason for this change: ArrayField lets the array shell rerender when the array structure changes without forcing the whole list to rerender for every item value change.

Shared form options

For extracted components, v1 composition examples often use a custom useAppForm hook plus withForm from createFormHook. v2 still supports app form hooks, but withForm was removed. The smaller building block for shared defaults is formOptions(...):

tsx
import { formOptions } from '@tanstack/react-form'

export const sharedFormOptions = formOptions({
  defaultValues: {
    firstName: '',
    lastName: '',
    address: {
      street: '',
      country: '',
    },
  },
})

const form = useForm({
  ...sharedFormOptions,
  onSubmit: ({ value }) => {
    console.log(value)
  },
})

When typing extracted components, prefer the v2 public types:

tsx
import type { AnyReactFormApi, FieldWithValue } from '@tanstack/react-form'

function StringField({ field }: { field: FieldWithValue<string> }) {
  return (
    <input
      value={field.value}
      onChange={(event) => field.handleChange(event.target.value)}
    />
  )
}

function SubmitButton({ form }: { form: AnyReactFormApi }) {
  return (
    <form.Subscribe selector={(state) => [state.canSubmit, state.isSubmitting]}>
      {([canSubmit, isSubmitting]) => (
        <button type="submit" disabled={!canSubmit || isSubmitting}>
          {isSubmitting ? '...' : 'Submit'}
        </button>
      )}
    </form.Subscribe>
  )
}

AnyReactFormApi and FieldWithValue<T> intentionally erase unrelated type details for generic UI pieces. Use ReactFormType<typeof formOpts> when a component needs the option-derived data and field-path surface. Options created with appFormOptions also retain their app component map. Submit error types may intentionally widen when the shared options omit onSubmit so it can be supplied later. Prefer these aliases over spelling the full FormApi or FieldApi generic lists in application components.

For components that previously used withForm, pass the form API explicitly and type it from the same options object:

tsx
// v1
const AddressFields = withForm({
  defaultValues: {
    address: {
      street: '',
      country: '',
    },
  },
  render: ({ form }) => (
    <>
      <form.Field name="address.street">{/* ... */}</form.Field>
      <form.Field name="address.country">{/* ... */}</form.Field>
    </>
  ),
})
tsx
// v2
import { formOptions } from '@tanstack/react-form'
import type { ReactFormType } from '@tanstack/react-form'

export const profileFormOptions = formOptions({
  defaultValues: {
    address: {
      street: '',
      country: '',
    },
  },
})

interface AddressFieldsProps {
  form: ReactFormType<typeof profileFormOptions>
}

function AddressFields({ form }: AddressFieldsProps) {
  return (
    <>
      <form.Field name="address.street">{/* ... */}</form.Field>
      <form.Field name="address.country">{/* ... */}</form.Field>
    </>
  )
}

Form groups and field groups

Replace v1 form group usage with form.FormGroup. It is the v2 tool for multi-step forms and scoped validation when a section is part of one concrete form shape:

tsx
<form.FormGroup name="guestDetails" onSubmit={() => goToNextStep()}>
  {(group) => (
    <>
      <group.Field name="name">
        {(field) => (
          <input
            value={field.value}
            onChange={(event) => field.handleChange(event.target.value)}
          />
        )}
      </group.Field>
      <button type="button" onClick={() => group.handleSubmit()}>
        Continue
      </button>
    </>
  )}
</form.FormGroup>

Inside the group, field names are scoped. name="name" becomes guestDetails.name, group.ArrayField name="guests" becomes guestDetails.guests, and watched fields are scoped the same way.

Group submission is its own validation cycle. group.handleSubmit() validates the group and its fields and then calls the group's onSubmit or onSubmitInvalid; form.handleSubmit() does not also run the group's validators. Keep any rules required for final whole-form submission in the form-level validator pipeline as well. group.handleSubmit() resolves to an error array, and group.state.errors is an array.

For a field inside a mounted group, scalar state exposed to errorVisibility (such as submissionAttempts and isDirty) is scoped to the nearest group. The callback's values and errors remain form-wide.

v1's withFieldGroup HOC was removed. v2 replaces it with defineFieldGroup(...) for reusable field bundles. Use it when a component should not care where its fields live in the parent form:

tsx
import { defineFieldGroup } from '@tanstack/react-form'

const rangeFieldGroup = defineFieldGroup(({ strict }) => ({
  lower: strict<string>(),
  upper: strict<string>(),
}))

function RangeFieldsImpl({
  fields,
}: {
  fields: typeof rangeFieldGroup.fields
}) {
  return (
    <>
      <fields.Field name="lower">
        {(field) => (
          <input
            value={field.value}
            onChange={(event) => field.handleChange(event.target.value)}
          />
        )}
      </fields.Field>
      <fields.Field
        name="upper"
        validators={[
          {
            triggers: ['change'],
            watchFields: ['lower'],
            run: ({ value }) => {
              const lower = fields.getFieldValue('lower')
              if (Number(value) < Number(lower)) {
                return 'Upper bound must be greater than lower bound'
              }
            },
          },
        ]}
      >
        {(field) => (
          <input
            value={field.value}
            onChange={(event) => field.handleChange(event.target.value)}
          />
        )}
      </fields.Field>
    </>
  )
}

export const RangeFields = rangeFieldGroup.bindComponent(
  RangeFieldsImpl,
  'fields',
)

Then bind virtual names to concrete paths wherever the group is used:

tsx
<RangeFields
  form={form}
  fields={{
    lower: 'minPrice',
    upper: 'maxPrice',
  }}
/>

Use this v2 pattern to migrate v1 withFieldGroup components that were reused against different field paths. If you are using app form components from createFormHook, wrap components that expect an injected field API with getFormHookHelpers(), then define field groups with defineAppFieldGroup:

tsx
import { createFormHook, getFormHookHelpers } from '@tanstack/react-form'

const { fieldComponent } = getFormHookHelpers()
const AppTextField = fieldComponent.strict(StringField, 'field')

const { appFormOptions, useAppForm, defineAppFieldGroup } = createFormHook({
  fieldComponents: {
    TextField: AppTextField,
  },
  formComponents: {},
})

const contactFieldGroup = defineAppFieldGroup(({ strict }) => ({
  name: strict<string>(),
}))

Listeners

v1 listeners were event-keyed objects. v2 listeners use a validator-like array shape with run, triggers, and optional triggerDebounceMs. A trigger can be a string or { trigger, when }, and debounce can be a number or a callback. Form listeners support change, blur, submit, mount, and reset; field listeners additionally support unmount.

tsx
// v2
<form.Field
  name="amount"
  listeners={[
    {
      triggers: ['blur'],
      run: ({ value, fieldApi }) => {
        fieldApi.handleChange(Number(value).toFixed(2))
      },
    },
  ]}
/>

For cross-field field listeners, put the source fields in watchFields. In form.FormGroup and field-group-bound components, watchFields uses the scoped or virtual field names and v2 resolves them to the concrete form paths. Form-level listeners instead observe propagated events and receive an optional source field as triggerFieldApi. It is present for propagated field changes and blurs, but absent for form-originated events such as mount, reset, and submit.

Server and framework integrations

The v1 examples include Next server actions, Remix, and TanStack Start examples that use v1's keyed validators and store-level errors. The v2 examples show a new server-validation model in @tanstack/react-form-start and a Next.js example with shared isomorphic validation.

Key changes:

  • Import serverValidateHelper from @tanstack/react-form, then import the framework adapter from @tanstack/react-form-start, @tanstack/react-form-nextjs.
  • Share common form configuration with formOptions(...).
  • Return the createErrorMap() map directly from framework/server validators when routing form and field errors.
  • Combine triggers: ['server'] with runOnSubmit: false for a form validator that must run only on the server. Server-triggered validators otherwise still run during client submission because submission is enabled by default.
  • Next.js and Remix adapters, and the Start adapter when no callback override is supplied, resolve to { success: true, values, schemaOutputs } or { success: false, serverState }. Pass the failure's serverState back to useForm({ ...formOpts, serverState }) on the client.
  • @tanstack/react-form-remix was removed.

For example, a Start adapter is configured separately from the shared form options:

tsx
import { serverValidateHelper } from '@tanstack/react-form'
import { start } from '@tanstack/react-form-start'

const { createServerValidate } = serverValidateHelper({
  framework: start(),
})

const serverValidate = createServerValidate(formOpts)

In formOpts, a server-only validator returns its routed error map directly:

tsx
{
  triggers: ['server'],
  runOnSubmit: false,
  run: ({ value, createErrorMap }) => {
    const errors = createErrorMap()

    if (value.age < 13) {
      errors.fields.age = 'You must be at least 13'
    }

    return errors
  },
}

Client rendering still follows the same v2 field surface:

tsx
<form.Field name="age">
  {(field) => (
    <>
      <input
        name={field.name}
        value={field.value}
        onBlur={field.handleBlur}
        onChange={(event) => field.handleChange(Number(event.target.value))}
      />
      {field.errors.map((error) => (
        <div key={error.message} role="alert">
          {error.message}
        </div>
      ))}
    </>
  )}
</form.Field>

Migration order for an app

  1. Upgrade imports and keep the smallest possible form compiling with ESM import syntax, useForm, defaultValues, form.Field, and form.handleSubmit().
  2. Move every field-level defaultValue into form-level defaultValues and remove defaultMeta.
  3. Update field render props from field.state.* to the direct v2 surface.
  4. Replace useStore(...store...) with useSelector(...atom...).
  5. Convert validators and listeners to arrays, paying special attention to the default submit behavior.
  6. Convert arrays from mode="array" to form.ArrayField; keep relative mutators on its render prop or move them to path-based form methods.
  7. Return createErrorMap(...) directly from form, group, and server validators, and convert submitted endpoint errors to createValidationError(...).
  8. Revisit composition: use formOptions(...) for shared options, ReactFormType<typeof formOpts> for extracted form props, form.FormGroup for scoped sections, and defineFieldGroup(...).bindComponent(...) for reusable field bundles.
  9. Re-run React integration tests around validation timing, field rerenders, groups, server error hydration, and array mutations. These are the areas where v2 intentionally tightened behavior.