Powerful, type-safe forms for React.
Comprehensive validation system supporting synchronous and asynchronous validators, Standard Schema integration for third-party validation libraries, custom validation logic, and detailed error handling with multiple validation triggers.
TanStack React Form implements the Standard Schema specification for validation library integration.
/**
* Standard Schema validator interface (v1)
* Implemented by validation libraries like Zod, Yup, Valibot, Joi, etc.
*/
interface StandardSchemaV1<Input = unknown, Output = Input> {
readonly '~standard': StandardSchemaV1.Props<Input, Output>;
}
namespace StandardSchemaV1 {
interface Props<Input, Output> {
/** Standard Schema version */
readonly version: 1;
/** Library vendor identifier */
readonly vendor: string;
/**
* Validation function
* @param value - Value to validate
* @returns Validation result with output value or issues
*/
readonly validate: (
value: unknown,
) => Result<Output> | Promise<Result<Output>>;
}
interface Result<Output> {
/** Validated and transformed output value (present if valid) */
readonly value?: Output;
/** Validation issues/errors (present if invalid) */
readonly issues?: ReadonlyArray<StandardSchemaV1Issue>;
}
}
/**
* Standard Schema issue/error interface
*/
interface StandardSchemaV1Issue {
/** Human-readable error message */
readonly message: string;
/** Path to the field with error (e.g., 'user.email') */
readonly path?: ReadonlyArray<string | number | symbol>;
}
/**
* Type guard to check if a validator is a Standard Schema validator
* @param validator - Validator to check
* @returns True if validator implements Standard Schema
*/
function isStandardSchemaValidator(
validator: unknown,
): validator is StandardSchemaV1;Helper object for working with Standard Schema validators.
/**
* Standard Schema validation helpers
*/
const standardSchemaValidators: {
/**
* Synchronously validates a value using a Standard Schema validator
* @param value - Value to validate
* @param schema - Standard Schema validator
* @returns Validation error or undefined if valid
*/
validate<TInput, TOutput>(
value: TInput,
schema: StandardSchemaV1<TInput, TOutput>,
): ValidationError | undefined;
/**
* Asynchronously validates a value using a Standard Schema validator
* @param value - Value to validate
* @param schema - Standard Schema validator
* @returns Promise resolving to validation error or undefined if valid
*/
validateAsync<TInput, TOutput>(
value: TInput,
schema: StandardSchemaV1<TInput, TOutput>,
): Promise<ValidationError | undefined>;
};Control when and how validation runs with custom logic functions.
/**
* Default validation logic that runs validators based on event type
* Runs onMount, onChange, onBlur, onSubmit validators at their respective triggers
*/
const defaultValidationLogic: ValidationLogicFn;
/**
* Validation logic similar to React Hook Form
* Only validates dynamically after first submission attempt
*
* @param props.mode - Validation mode before submission ('change' | 'blur' | 'submit')
* @param props.modeAfterSubmission - Validation mode after submission ('change' | 'blur' | 'submit')
* @returns Validation logic function
*/
function revalidateLogic(props?: {
mode?: 'change' | 'blur' | 'submit';
modeAfterSubmission?: 'change' | 'blur' | 'submit';
}): ValidationLogicFn;
/**
* Validation logic function type
* Determines validation behavior based on form state and trigger cause
* @param props.cause - Reason for validation
* @param props.validator - Validator type to check
* @param props.value - Current value
* @param props.formApi - Form API instance
*/
type ValidationLogicFn = (props: ValidationLogicProps) => void;
interface ValidationLogicProps {
/** Reason for validation trigger */
cause: ValidationCause;
/** Type of validator being checked */
validator: ValidationErrorMapKeys;
/** Current form or field value */
value: unknown;
/** Form API instance */
formApi: AnyFormApi;
}Core types for validation errors and triggers.
/** Validation error - can be any type (string, object, etc.) */
type ValidationError = unknown;
/** Validation trigger cause */
type ValidationCause = 'change' | 'blur' | 'submit' | 'mount' | 'server' | 'dynamic';
/** Listener trigger cause (subset of ValidationCause) */
type ListenerCause = 'change' | 'blur' | 'submit' | 'mount';
/** Validation source (form-level or field-level) */
type ValidationSource = 'form' | 'field';
/** Keys for validation error maps (e.g., 'onMount', 'onChange', 'onBlur') */
type ValidationErrorMapKeys = `on${Capitalize<ValidationCause>}`;
/**
* Map of validation errors by trigger type
*/
type ValidationErrorMap = Partial<Record<ValidationErrorMapKeys, ValidationError>>;
/**
* Map of validation error sources by trigger type
*/
type ValidationErrorMapSource = Partial<Record<ValidationErrorMapKeys, ValidationSource>>;
/**
* Form-level validation error map
* Extends ValidationErrorMap to support global form errors
*/
type FormValidationErrorMap<
TFormData,
TOnMount extends undefined | FormValidateOrFn<TFormData>,
TOnChange extends undefined | FormValidateOrFn<TFormData>,
TOnChangeAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
TOnBlur extends undefined | FormValidateOrFn<TFormData>,
TOnBlurAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
TOnSubmit extends undefined | FormValidateOrFn<TFormData>,
TOnSubmitAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
TOnDynamic extends undefined | FormValidateOrFn<TFormData>,
TOnDynamicAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
TOnServer extends undefined | FormAsyncValidateOrFn<TFormData>,
> = {
onMount?: UnwrapFormValidateOrFn<TOnMount>;
onChange?: UnwrapFormValidateOrFn<TOnChange>;
onChangeAsync?: UnwrapFormAsyncValidateOrFn<TOnChangeAsync>;
onBlur?: UnwrapFormValidateOrFn<TOnBlur>;
onBlurAsync?: UnwrapFormAsyncValidateOrFn<TOnBlurAsync>;
onSubmit?: UnwrapFormValidateOrFn<TOnSubmit>;
onSubmitAsync?: UnwrapFormAsyncValidateOrFn<TOnSubmitAsync>;
onDynamic?: UnwrapFormValidateOrFn<TOnDynamic>;
onDynamicAsync?: UnwrapFormAsyncValidateOrFn<TOnDynamicAsync>;
onServer?: UnwrapFormAsyncValidateOrFn<TOnServer>;
};Support for validation errors that affect both form-level and field-level state.
/**
* Global form validation error
* Can specify both form-level error and field-specific errors
*/
interface GlobalFormValidationError<TFormData> {
/** Form-level error message */
form?: ValidationError;
/** Map of field-specific errors by field path */
fields: Partial<Record<DeepKeys<TFormData>, ValidationError>>;
}
/**
* Form validation error type
* Can be either a simple error or a global error with field mapping
*/
type FormValidationError<TFormData> =
| ValidationError
| GlobalFormValidationError<TFormData>;
/**
* Type guard to check if error is a global form validation error
* @param error - Error to check
* @returns True if error is GlobalFormValidationError
*/
function isGlobalFormValidationError(
error: unknown,
): error is GlobalFormValidationError<any>;
/** Extracts the form-level error from a GlobalFormValidationError */
type ExtractGlobalFormError<T> = T extends GlobalFormValidationError<any>
? T['form']
: T;Types for unwrapping validator return values.
/** Unwraps return type from form validator */
type UnwrapFormValidateOrFn<T> = T extends FormValidateFn<any>
? ReturnType<T> extends Promise<infer U>
? U
: ReturnType<T>
: T extends StandardSchemaV1<any, any>
? ValidationError
: undefined;
/** Unwraps return type from async form validator */
type UnwrapFormAsyncValidateOrFn<T> = T extends FormValidateAsyncFn<any>
? Awaited<ReturnType<T>>
: T extends StandardSchemaV1<any, any>
? ValidationError
: undefined;
/** Unwraps return type from field validator */
type UnwrapFieldValidateOrFn<T> = T extends FieldValidateFn<any, any, any>
? ReturnType<T> extends Promise<infer U>
? U
: ReturnType<T>
: T extends StandardSchemaV1<any, any>
? ValidationError
: undefined;
/** Unwraps return type from async field validator */
type UnwrapFieldAsyncValidateOrFn<T> = T extends FieldValidateAsyncFn<any, any, any>
? Awaited<ReturnType<T>>
: T extends StandardSchemaV1<any, any>
? ValidationError
: undefined;/** Asynchronous validator interface */
interface AsyncValidator<TInput> {
(value: TInput, signal: AbortSignal): Promise<ValidationError | undefined>;
}
/** Synchronous validator interface */
interface SyncValidator<TInput> {
(value: TInput): ValidationError | undefined;
}import { useForm } from '@tanstack/react-form';
import { z } from 'zod';
const userSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
age: z.number().min(18, 'Must be at least 18 years old'),
});
function UserForm() {
const form = useForm({
defaultValues: {
name: '',
email: '',
age: 0,
},
validators: {
onChange: userSchema,
},
});
return (
<form onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}>
<form.Field name="email" validators={{ onChange: z.string().email() }}>
{(field) => (
<div>
<input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors[0] && (
<span>{field.state.meta.errors[0]}</span>
)}
</div>
)}
</form.Field>
</form>
);
}import { useForm } from '@tanstack/react-form';
function PasswordForm() {
const form = useForm({
defaultValues: {
password: '',
confirmPassword: '',
},
validators: {
onChange: ({ value }) => {
if (value.password !== value.confirmPassword) {
return {
form: 'Passwords do not match',
fields: {
confirmPassword: 'Must match password',
},
};
}
return undefined;
},
},
});
return (
<form.Field
name="password"
validators={{
onChange: ({ value }) => {
if (value.length < 8) {
return 'Password must be at least 8 characters';
}
if (!/[A-Z]/.test(value)) {
return 'Password must contain an uppercase letter';
}
if (!/[0-9]/.test(value)) {
return 'Password must contain a number';
}
return undefined;
},
}}
>
{(field) => (
<div>
<input
type="password"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors[0]}
</div>
)}
</form.Field>
);
}function UsernameField() {
const form = useForm({
defaultValues: {
username: '',
},
asyncDebounceMs: 500,
});
return (
<form.Field
name="username"
validators={{
onChange: ({ value }) => {
if (value.length < 3) {
return 'Username must be at least 3 characters';
}
return undefined;
},
onChangeAsync: async ({ value, signal }) => {
try {
const response = await fetch(
`/api/check-username?username=${value}`,
{ signal }
);
const data = await response.json();
return data.available ? undefined : 'Username already taken';
} catch (error) {
if (error.name === 'AbortError') {
return undefined; // Validation was cancelled
}
throw error;
}
},
}}
>
{(field) => (
<div>
<input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.isValidating && <span>Checking...</span>}
{field.state.meta.errors[0] && (
<span>{field.state.meta.errors[0]}</span>
)}
</div>
)}
</form.Field>
);
}import { useForm, revalidateLogic } from '@tanstack/react-form';
function ReactHookFormStyleValidation() {
const form = useForm({
defaultValues: {
email: '',
},
// Only validate on blur before submission, on change after submission
validationLogic: revalidateLogic({
mode: 'blur',
modeAfterSubmission: 'change',
}),
validators: {
onBlur: ({ value }) => {
if (!value.email.includes('@')) {
return { form: 'Invalid email' };
}
return undefined;
},
onChange: ({ value }) => {
if (!value.email.includes('@')) {
return { form: 'Invalid email' };
}
return undefined;
},
},
});
return <form>{/* fields */}</form>;
}function MultiValidatorField() {
const form = useForm({
defaultValues: {
email: '',
},
});
return (
<form.Field
name="email"
validators={{
onChange: [
// Array of validators - all must pass
({ value }) => (!value ? 'Email is required' : undefined),
({ value }) => (!value.includes('@') ? 'Must include @' : undefined),
({ value }) => (!value.includes('.') ? 'Must include domain' : undefined),
],
}}
>
{(field) => (
<div>
<input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors.map((error, i) => (
<div key={i}>{String(error)}</div>
))}
</div>
)}
</form.Field>
);
}function ConditionalValidation() {
const form = useForm({
defaultValues: {
shippingMethod: 'standard',
trackingNumber: '',
},
});
return (
<>
<form.Field name="shippingMethod">
{(field) => (
<select
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
>
<option value="standard">Standard</option>
<option value="express">Express</option>
</select>
)}
</form.Field>
<form.Field
name="trackingNumber"
validators={{
onChangeListenTo: ['shippingMethod'],
onChange: ({ value, fieldApi }) => {
const shippingMethod = fieldApi.form.getFieldValue('shippingMethod');
if (shippingMethod === 'express' && !value) {
return 'Tracking number required for express shipping';
}
return undefined;
},
}}
>
{(field) => (
<div>
<input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors[0]}
</div>
)}
</form.Field>
</>
);
}function OrderForm() {
const form = useForm({
defaultValues: {
items: [],
total: 0,
},
validators: {
onSubmit: ({ value }) => {
if (value.items.length === 0) {
return {
form: 'Order must have at least one item',
fields: {
items: 'Add at least one item to the order',
},
};
}
const calculatedTotal = value.items.reduce(
(sum, item) => sum + item.price,
0
);
if (calculatedTotal !== value.total) {
return {
form: 'Total does not match items',
fields: {
total: `Expected ${calculatedTotal}, got ${value.total}`,
},
};
}
return undefined;
},
},
});
return (
<form onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}>
{form.state.errors.length > 0 && (
<div className="form-error">
{form.state.errors.map((error, i) => (
<div key={i}>{String(error)}</div>
))}
</div>
)}
{/* fields */}
</form>
);
}Install with Tessl CLI
npx tessl i tessl/npm-tanstack--react-form