
TanStack Form ships with excellent support for schema validation libraries like Zod. For most simple forms, passing a schema to the appropriate validation handler is sufficient. But more complex forms often have validation requirements that live outside the schema entirely -- captcha verification, server-side uniqueness checks, rate limiting, or multi-step flows where each step depends on the previous one succeeding.
Consider a waitlist form with two validation concerns:
These two concerns have a natural ordering -- there's no point running the expensive async API call if the email is invalid. However, making the API request falls outside the scope of standard schema validation, meaning we must implement a more complex solution.
The solution is to handle both in a custom-defined validators.onSubmitAsync function, running them sequentially and returning early on failure.
It helps to understand what TanStack Form expects from form validator functions. Both sync and async form-level validators return undefined when valid or a TStandardSchemaValidatorIssue<"form"> object when invalid, which looks like the following:
ts{ form: Record<string, StandardSchemaV1Issue[]>; // form-level errors fields: Record<string, StandardSchemaV1Issue[]>; // field-level errors }
Note that field validators simply return StandardSchemaV1Issue[] or undefined. This article focuses on form validators.
parseValuesWithSchemaUsually, we can provide a Zod schema (or any other schema that supports the standard schema specification) directly to our useAppForm hook, and TanStack Form will handle everything behind the scenes:
tsconst waitListSchema = z.object({ email: z.string().email({ message: "Email address is required" }), }); const form = useAppForm({ validators: { onSubmit: waitListSchema, }, // ... });
But we can also supply a more complex function, which accepts an object parameter with the following properties:
value - the current form state, representing all key/value pairs.formApi - the TanStack Form form API object for interacting further with the form. (For field-level validators, this parameter is named fieldApi instead.)signal - an abort signal that can be provided to any cancellable requests or asynchronous calls.The formApi object provides both a parseValuesWithSchema and a parseValuesWithSchemaAsync function, which essentially mirrors what happens behind the scenes when we pass a Zod schema directly. (Note that parseValuesWithSchema should be used with validators.onSubmit while parseValuesWithSchemaAsync should be used with validators.onSubmitAsync.)
tsconst form = useAppForm({ validators: { onSubmit: ({ formApi }) => { const errors = formApi.parseValuesWithSchema(waitListSchema); console.log(errors); // TStandardSchemaValidatorIssue<"form"> | undefined return errors; }, }, // ... });
Alternatively, TanStack Form exports a standardSchemaValidators object that provides the same functionality. This is useful for older versions of the library that predate formApi.parseValuesWithSchema. (Like parseValuesWithSchema, use standardSchemaValidators.validate with validators.onSubmit and standardSchemaValidators.validateAsync with validators.onSubmitAsync.)
tsimport { standardSchemaValidators } from "@tanstack/react-form"; const form = useAppForm({ validators: { onSubmit: ({ value }) => { const errors = standardSchemaValidators.validate( { value, validationSource: "form" }, waitListSchema, ); console.log(errors); // TStandardSchemaValidatorIssue<"form"> | undefined return errors; }, }, // ... });
onSubmitAsync PatternHere's the complete handler from the waitlist form:
tsconst form = useAppForm({ defaultValues: { email: "", website: "" }, validators: { onSubmitAsync: async ({ value }) => { // Step 1: Run Zod schema validation const validationResult = standardSchemaValidators.validate( { value, validationSource: "form" }, waitListSchema, ); // Short-circuit if schema validation fails - no need to hit the API if (validationResult) { return validationResult; } // Step 2: Get reCAPTCHA token (async, requires user interaction state) const recaptchaToken: string | null = await executeRecaptcha(); // Step 3: Submit to API, which validates the captcha server-side const response: Response = await waitlistSubscribe({ email: value.email, recaptchaToken, }); // Valid response - no errors to return if (response.ok) { return undefined; } // Step 4: Map API error back to a field-level error on the email field const message: string = parseResponse(response); return { form: {}, fields: { email: [{ message, path: ["email"] }] }, } satisfies TStandardSchemaValidatorIssue<"form">; }, }, onSubmit: () => { // Only called on full success - safe to fire analytics, redirect, etc. }, });
A few points of note:
validationResult directly when it's truthy, you skip all subsequent async steps. TanStack Form will surface the schema errors and not call your post-validation onSubmit handler.onSubmit only fires on full success. This is a key TanStack Form guarantee: the onSubmit callback (where you'd do analytics, redirect, or update state) only executes when all validators return undefined. You don't need to guard against partial success inside it.Error messages won't appear automatically -- you need to explicitly extract them from field state and render them. Let's assume you're rendering the input in the following manner:
tsx<form.AppField name="email"> {(field) => ( <field.InputField type="email" placeholder="user@example.com" /> )} </form.AppField>
The InputField component is not provided by TanStack Form; it must be provided by you. Along with the input element, we can provide a label and render any error messages in a consistent manner:
tsxexport function InputField({ className, label, ...props }: InputFieldProps, ref) { const field = useFieldContext<string>(); const error = getFieldErrorMessage(field.state.meta); return ( <FieldWrapper id={field.name} label={label} error={error} required={props.required} className={className} > <input ref={ref} {...props} id={field.name} name={field.name} value={field.state.value ?? ""} onBlur={field.handleBlur} onChange={(event) => field.handleChange(event.target.value)} /> </FieldWrapper> ); }
As long as the error is returned with fields.email[n].message, TanStack Form routes it to the right field, no matter which validation step produced it. I recommend taking a look at the Bonus: Typed Error Messages section of my previous article to understand how we can maintain type safety while extracting the error message.
validators.onSubmit or validators.onSubmitAsync using early returns.formApi.parseValuesWithSchema() so you can call it inline and branch on the result.TStandardSchemaValidatorIssue<"form"> return shape. This ensures errors are presented to the user, prompting them to respond appropriately.TanStack Form's validator system is flexible enough to handle complex validation flows without needing middleware, custom hooks, or external state. When the built-in shorthand isn't expressive enough, expanding into a single validator function gives you all the control you need.