tanstack-form

Installation
SKILL.md

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

npm install @tanstack/react-form
# Optional schema adapters:
npm install @tanstack/zod-form-adapter zod
npm install @tanstack/valibot-form-adapter valibot

Core: useForm

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)

<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

Cause When
onChange After every value change
onBlur When field loses focus
onSubmit During submission
onMount When field mounts

Synchronous Validation

<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

<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)

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

const form = useForm({
  defaultValues: { password: "", confirmPassword: "" },
  validators: {
    onChange: ({ value }) => {
      if (value.password !== value.confirmPassword) {
        return "Passwords do not match";
      }
      return undefined;
    },
  },
});

Linked/Dependent Fields

<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

<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

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)

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

Reactivity (form.Subscribe & useStore)

// 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 subscription
function FormStatus() {
  const isValid = form.useStore((s) => s.isValid);
  return isValid ? null : <p>Fix errors</p>;
}

Form State

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

interface FieldState<TData> {
  value: TData;
  meta: {
    isTouched: boolean;
    isDirty: boolean;
    isPristine: boolean;
    isValidating: boolean;
    errors: ValidationError[];
    errorMap: Record<ValidationCause, ValidationError>;
  };
}

FormApi Methods

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)

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

// 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

// 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'
<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)
Related skills
Installs
1
Repository
frostfoe7/rdz
First Seen
Mar 30, 2026