Design System

Form Validation Patterns

TanStack Form validation patterns with FormShell. Every form uses FormShell to ensure browser-native validation never intercepts TanStack validators.

Why FormShell? Raw <form> with <input type="email"> triggers browser-native validation tooltips before TanStack Form validators fire. No aria-invalid gets set, no error messages render in the DOM, and E2E tests see nothing. FormShell applies noValidate automatically.

Required Field (onSubmit)

Validates on submit. The error appears in the DOM as a visible message with aria-invalid on the input.

const form = useForm({
  defaultValues: { name: '' },
  validators: {
    onSubmit: ({ value }) => {
      if (!value.name.trim())
        return { fields: { name: 'Name is required' } };
      return undefined;
    },
  },
});

<FormShell form={form}>
  <FormInput form={form} name="name" label="Full Name" required />
  <Button type="submit">Submit</Button>
</FormShell>

Email Format Validation

Validates email format on submit. With FormShell, the browser tooltip never appears — TanStack handles it.

validators: {
  onSubmit: ({ value }) => {
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.email)) {
      return { fields: { email: 'Enter a valid email address' } };
    }
    return undefined;
  },
}

Live Validation (onChange)

Validates as the user types. Use for character limits, format checks, or immediate feedback.

<FormTextarea
  form={form}
  name="bio"
  label="Bio"
  validators={{
    onChange: ({ value }) => {
      if (value.length > 0 && value.length < 10)
        return 'Bio must be at least 10 characters';
      return undefined;
    },
  }}
/>

Cross-Field Validation (Confirm Password)

Form-level onSubmit validator compares two fields. Errors target specific fields via the { fields: { ... } } return shape.

const form = useForm({
  defaultValues: { password: '', confirmPassword: '' },
  validators: {
    onSubmit: ({ value }) => {
      const errors: Record<string, string> = {};
      if (value.password.length < 8)
        errors.password = 'Password must be at least 8 characters';
      if (value.password !== value.confirmPassword)
        errors.confirmPassword = 'Passwords do not match';
      return Object.keys(errors).length > 0
        ? { fields: errors }
        : undefined;
    },
  },
});

<FormShell form={form}>
  <FormInput form={form} name="password" type="password" label="Password" />
  <FormInput form={form} name="confirmPassword" type="password" label="Confirm" />
  <Button type="submit">Submit</Button>
</FormShell>

Async Validation (Debounced)

Field-level onChangeAsync with onChangeAsyncDebounceMs. Fires after the user stops typing. Use for uniqueness checks against an API.

Validates after 500ms debounce. Try "admin" or "test".

<FormInput
  form={form}
  name="username"
  label="Username"
  validators={{
    onChangeAsyncDebounceMs: 500,
    onChangeAsync: async ({ value }) => {
      const res = await fetch(`/api/check-username?q=${value}`);
      const { available } = await res.json();
      return available ? undefined : `"${value}" is already taken`;
    },
  }}
/>
Note: The ui-forms molecules do not currently show a loading spinner during async validation. The error appears once the async validator resolves. A visual "checking..." indicator is a future enhancement opportunity.

Anti-Pattern: Raw <form> Without noValidate

Never do this:

// BAD — browser intercepts validation
<form action={() => form.handleSubmit()}>
  <input type="email" ... />
</form>

// GOOD — TanStack Form handles validation
<FormShell form={form}>
  <FormInput type="email" ... />
</FormShell>

Without noValidate, the browser shows its own tooltip for type="email" and blocks TanStack Form's onSubmit validators from firing. Playwright cannot see browser tooltips — the test sees no error.