At the core of TanStack Form's functionalities is the concept of validation. TanStack Form makes validation highly customizable:
It's up to you! The <Field /> component accepts some callbacks as props such as onChange or onBlur. Those callbacks are passed the current value of the field, as well as the fieldAPI object, so that you can perform the validation. If you find a validation error, simply return the error message as string and it will be available in field.state.meta.errors.
Here is an example:
<form.Field
name="age"
validators={{
onChange: ({value}) =>
value < 13 ? 'You must be 13 to make an account' : undefined,
}}
>
{(field) => (
<>
<label htmlFor={field.name}>Age:</label>
<input
id={field.name}
name={field.name}
value={field.state.value}
type="number"
onChange={(e) => field.handleChange(e.target.valueAsNumber)}
/>
{field.state.meta.errors ? (
<em role="alert">{field.state.meta.errors.join(', ')}</em>
) : null}
</>
)}
</form.Field>
<form.Field
name="age"
validators={{
onChange: ({value}) =>
value < 13 ? 'You must be 13 to make an account' : undefined,
}}
>
{(field) => (
<>
<label htmlFor={field.name}>Age:</label>
<input
id={field.name}
name={field.name}
value={field.state.value}
type="number"
onChange={(e) => field.handleChange(e.target.valueAsNumber)}
/>
{field.state.meta.errors ? (
<em role="alert">{field.state.meta.errors.join(', ')}</em>
) : null}
</>
)}
</form.Field>
In the example above, the validation is done at each keystroke (onChange). If, instead, we wanted the validation to be done when the field is blurred, we would change the code above like so:
<form.Field
name="age"
validators={{
onBlur: ({value}) =>
value < 13 ? 'You must be 13 to make an account' : undefined,
}}
>
{(field) => (
<>
<label htmlFor={field.name}>Age:</label>
<input
id={field.name}
name={field.name}
value={field.state.value}
type="number"
// Listen to the onBlur event on the field
onBlur={field.handleBlur}
// We always need to implement onChange, so that TanStack Form receives the changes
onChange={(e) => field.handleChange(e.target.valueAsNumber)}
/>
{field.state.meta.errors ? (
<em role="alert">{field.state.meta.errors.join(', ')}</em>
) : null}
</>
)}
</form.Field>
<form.Field
name="age"
validators={{
onBlur: ({value}) =>
value < 13 ? 'You must be 13 to make an account' : undefined,
}}
>
{(field) => (
<>
<label htmlFor={field.name}>Age:</label>
<input
id={field.name}
name={field.name}
value={field.state.value}
type="number"
// Listen to the onBlur event on the field
onBlur={field.handleBlur}
// We always need to implement onChange, so that TanStack Form receives the changes
onChange={(e) => field.handleChange(e.target.valueAsNumber)}
/>
{field.state.meta.errors ? (
<em role="alert">{field.state.meta.errors.join(', ')}</em>
) : null}
</>
)}
</form.Field>
So you can control when the validation is done by implementing the desired callback. You can even perform different pieces of validation at different times:
<form.Field
name="age"
validators={{
onChange: ({value}) =>
value < 13 ? 'You must be 13 to make an account' : undefined,
onBlur: ({value}) => (value < 0 ? 'Invalid value' : undefined),
}}
>
{(field) => (
<>
<label htmlFor={field.name}>Age:</label>
<input
id={field.name}
name={field.name}
value={field.state.value}
type="number"
// Listen to the onBlur event on the field
onBlur={field.handleBlur}
// We always need to implement onChange, so that TanStack Form receives the changes
onChange={(e) => field.handleChange(e.target.valueAsNumber)}
/>
{field.state.meta.errors ? (
<em role="alert">{field.state.meta.errors.join(', ')}</em>
) : null}
</>
)}
</form.Field>
<form.Field
name="age"
validators={{
onChange: ({value}) =>
value < 13 ? 'You must be 13 to make an account' : undefined,
onBlur: ({value}) => (value < 0 ? 'Invalid value' : undefined),
}}
>
{(field) => (
<>
<label htmlFor={field.name}>Age:</label>
<input
id={field.name}
name={field.name}
value={field.state.value}
type="number"
// Listen to the onBlur event on the field
onBlur={field.handleBlur}
// We always need to implement onChange, so that TanStack Form receives the changes
onChange={(e) => field.handleChange(e.target.valueAsNumber)}
/>
{field.state.meta.errors ? (
<em role="alert">{field.state.meta.errors.join(', ')}</em>
) : null}
</>
)}
</form.Field>
In the example above, we are validating different things on the same field at different times (at each keystroke and when blurring the field). Since field.state.meta.errors is an array, all the relevant errors at a given time are displayed. You can also use field.state.meta.errorMap to get errors based on when the validation was done (onChange, onBlur etc...). More info about displaying errors below.
Once you have your validation in place, you can map the errors from an array to be displayed in your UI:
<form.Field
name="age"
validators={{
onChange: ({value}) =>
value < 13 ? 'You must be 13 to make an account' : undefined,
}}
>
{(field) => {
return (
<>
{/* ... */}
{field.state.meta.errors.length ? <em>{field.state.meta.errors.join(",")}</em> : null}
</>
)
}}
</form.Field>
<form.Field
name="age"
validators={{
onChange: ({value}) =>
value < 13 ? 'You must be 13 to make an account' : undefined,
}}
>
{(field) => {
return (
<>
{/* ... */}
{field.state.meta.errors.length ? <em>{field.state.meta.errors.join(",")}</em> : null}
</>
)
}}
</form.Field>
Or use the errorMap property to access the specific error you're looking for:
<form.Field
name="age"
validators={{
onChange: ({value}) =>
value < 13 ? 'You must be 13 to make an account' : undefined,
}}
>
{(field) => (
<>
{/* ... */}
{field.state.meta.errorMap['onChange'] ? (
<em>{field.state.meta.errorMap['onChange']}</em>
) : null}
</>
)}
</form.Field>
<form.Field
name="age"
validators={{
onChange: ({value}) =>
value < 13 ? 'You must be 13 to make an account' : undefined,
}}
>
{(field) => (
<>
{/* ... */}
{field.state.meta.errorMap['onChange'] ? (
<em>{field.state.meta.errorMap['onChange']}</em>
) : null}
</>
)}
</form.Field>
As shown above, each <Field> accepts its own validation rules via the onChange, onBlur etc... callbacks. It is also possible to define validation rules at the form level (as opposed to field by field) by passing similar callbacks to the useForm() hook.
Example:
export default function App() {
const form = useForm({
defaultValues: {
age: 0,
},
onSubmit: async ({ value }) => {
console.log(value)
},
validators: {
// Add validators to the form the same way you would add them to a field
onChange({ value }) {
if (value.age < 13) {
return 'Must be 13 or older to sign'
}
return undefined
},
},
})
// Subscribe to the form's error map so that updates to it will render
// alternately, you can use `form.Subscribe`
const formErrorMap = useStore(form.store, (state) => state.errorMap)
return (
<div>
{/* ... */}
{formErrorMap.onChange ? (
<div>
<em>There was an error on the form: {formErrorMap.onChange}</em>
</div>
) : null}
{/* ... */}
</div>
)
}
export default function App() {
const form = useForm({
defaultValues: {
age: 0,
},
onSubmit: async ({ value }) => {
console.log(value)
},
validators: {
// Add validators to the form the same way you would add them to a field
onChange({ value }) {
if (value.age < 13) {
return 'Must be 13 or older to sign'
}
return undefined
},
},
})
// Subscribe to the form's error map so that updates to it will render
// alternately, you can use `form.Subscribe`
const formErrorMap = useStore(form.store, (state) => state.errorMap)
return (
<div>
{/* ... */}
{formErrorMap.onChange ? (
<div>
<em>There was an error on the form: {formErrorMap.onChange}</em>
</div>
) : null}
{/* ... */}
</div>
)
}
You can set errors on the fields from the form's validators. One common use case for this is validating all the fields on submit by calling a single API endpoint in the form's onSubmitAsync validator.
export default function App() {
const form = useForm({
defaultValues: {
age: 0,
},
validators: {
onSubmitAsync: async ({ value }) => {
// Verify the age on the server
const isOlderThan13 = await verifyAgeOnServer(value.age)
if (!isOlderThan13) {
return {
form: 'Invalid data', // The `form` key is optional
fields: {
age: 'Must be 13 or older to sign',
},
}
}
return null
},
},
})
return (
<div>
<form
onSubmit={(e) => {
e.preventDefault()
e.stopPropagation()
void form.handleSubmit()
}}
>
<form.Field name="age">
{(field) => (
<>
<label htmlFor={field.name}>Age:</label>
<input
id={field.name}
name={field.name}
value={field.state.value}
type="number"
onChange={(e) => field.handleChange(e.target.valueAsNumber)}
/>
{field.state.meta.errors ? (
<em role="alert">{field.state.meta.errors.join(', ')}</em>
) : null}
</>
)}
</form.Field>
<form.Subscribe
selector={(state) => [state.errorMap]}
children={([errorMap]) =>
errorMap.onSubmit ? (
<div>
<em>There was an error on the form: {errorMap.onSubmit}</em>
</div>
) : null
}
/>
{/*...*/}
</form>
</div>
)
}
export default function App() {
const form = useForm({
defaultValues: {
age: 0,
},
validators: {
onSubmitAsync: async ({ value }) => {
// Verify the age on the server
const isOlderThan13 = await verifyAgeOnServer(value.age)
if (!isOlderThan13) {
return {
form: 'Invalid data', // The `form` key is optional
fields: {
age: 'Must be 13 or older to sign',
},
}
}
return null
},
},
})
return (
<div>
<form
onSubmit={(e) => {
e.preventDefault()
e.stopPropagation()
void form.handleSubmit()
}}
>
<form.Field name="age">
{(field) => (
<>
<label htmlFor={field.name}>Age:</label>
<input
id={field.name}
name={field.name}
value={field.state.value}
type="number"
onChange={(e) => field.handleChange(e.target.valueAsNumber)}
/>
{field.state.meta.errors ? (
<em role="alert">{field.state.meta.errors.join(', ')}</em>
) : null}
</>
)}
</form.Field>
<form.Subscribe
selector={(state) => [state.errorMap]}
children={([errorMap]) =>
errorMap.onSubmit ? (
<div>
<em>There was an error on the form: {errorMap.onSubmit}</em>
</div>
) : null
}
/>
{/*...*/}
</form>
</div>
)
}
Something worth mentioning is that if you have a form validation function that returns an error, that error may be overwritten by the field-specific validation.
This means that:
jsxconst form = useForm({ defaultValues: { age: 0, }, validators: { onChange: ({ value }) => { return { fields: { age: value.age < 12 ? 'Too young!' : undefined, }, } }, }, }) // ... return <form.Field name="age" validators={{ onChange: ({ value }) => value % 2 === 0 ? 'Must be odd!' : undefined, }} />
const form = useForm({ defaultValues: { age: 0, }, validators: { onChange: ({ value }) => { return { fields: { age: value.age < 12 ? 'Too young!' : undefined, }, } }, }, }) // ... return <form.Field name="age" validators={{ onChange: ({ value }) => value % 2 === 0 ? 'Must be odd!' : undefined, }} />
Will only show 'Must be odd! even if the 'Too young!' error is returned by the form-level validation.
While we suspect most validations will be synchronous, there are many instances where a network call or some other async operation would be useful to validate against.
To do this, we have dedicated onChangeAsync, onBlurAsync, and other methods that can be used to validate against:
<form.Field
name="age"
validators={{
onChangeAsync: async ({value}) => {
await new Promise((resolve) => setTimeout(resolve, 1000))
return value < 13 ? 'You must be 13 to make an account' : undefined
},
}}
>
{(field) => (
<>
<label htmlFor={field.name}>Age:</label>
<input
id={field.name}
name={field.name}
value={field.state.value}
type="number"
onChange={(e) => field.handleChange(e.target.valueAsNumber)}
/>
{field.state.meta.errors ? (
<em role="alert">{field.state.meta.errors.join(', ')}</em>
) : null}
</>
)}
</form.Field>
<form.Field
name="age"
validators={{
onChangeAsync: async ({value}) => {
await new Promise((resolve) => setTimeout(resolve, 1000))
return value < 13 ? 'You must be 13 to make an account' : undefined
},
}}
>
{(field) => (
<>
<label htmlFor={field.name}>Age:</label>
<input
id={field.name}
name={field.name}
value={field.state.value}
type="number"
onChange={(e) => field.handleChange(e.target.valueAsNumber)}
/>
{field.state.meta.errors ? (
<em role="alert">{field.state.meta.errors.join(', ')}</em>
) : null}
</>
)}
</form.Field>
Synchronous and Asynchronous validations can coexist. For example, it is possible to define both onBlur and onBlurAsync on the same field:
<form.Field
name="age"
validators={{
onBlur: ({value}) => (value < 13 ? 'You must be at least 13' : undefined),
onBlurAsync: async ({value}) => {
const currentAge = await fetchCurrentAgeOnProfile()
return value < currentAge ? 'You can only increase the age' : undefined
},
}}
>
{(field) => (
<>
<label htmlFor={field.name}>Age:</label>
<input
id={field.name}
name={field.name}
value={field.state.value}
type="number"
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.valueAsNumber)}
/>
{field.state.meta.errors ? (
<em role="alert">{field.state.meta.errors.join(', ')}</em>
) : null}
</>
)}
</form.Field>
<form.Field
name="age"
validators={{
onBlur: ({value}) => (value < 13 ? 'You must be at least 13' : undefined),
onBlurAsync: async ({value}) => {
const currentAge = await fetchCurrentAgeOnProfile()
return value < currentAge ? 'You can only increase the age' : undefined
},
}}
>
{(field) => (
<>
<label htmlFor={field.name}>Age:</label>
<input
id={field.name}
name={field.name}
value={field.state.value}
type="number"
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.valueAsNumber)}
/>
{field.state.meta.errors ? (
<em role="alert">{field.state.meta.errors.join(', ')}</em>
) : null}
</>
)}
</form.Field>
The synchronous validation method (onBlur) is run first and the asynchronous method (onBlurAsync) is only run if the synchronous one (onBlur) succeeds. To change this behaviour, set the asyncAlways option to true, and the async method will be run regardless of the result of the sync method.
While async calls are the way to go when validating against the database, running a network request on every keystroke is a good way to DDOS your database.
Instead, we enable an easy method for debouncing your async calls by adding a single property:
<form.Field
name="age"
asyncDebounceMs={500}
validators={{
onChangeAsync: async ({value}) => {
// ...
}
}}
children={(field) => {
return (
<>
{/* ... */}
</>
);
}}
/>
<form.Field
name="age"
asyncDebounceMs={500}
validators={{
onChangeAsync: async ({value}) => {
// ...
}
}}
children={(field) => {
return (
<>
{/* ... */}
</>
);
}}
/>
This will debounce every async call with a 500ms delay. You can even override this property on a per-validation property:
<form.Field
name="age"
asyncDebounceMs={500}
validators={{
onChangeAsyncDebounceMs: 1500,
onChangeAsync: async ({value}) => {
// ...
},
onBlurAsync: async ({value}) => {
// ...
},
}}
children={(field) => {
return <>{/* ... */}</>
}}
/>
<form.Field
name="age"
asyncDebounceMs={500}
validators={{
onChangeAsyncDebounceMs: 1500,
onChangeAsync: async ({value}) => {
// ...
},
onBlurAsync: async ({value}) => {
// ...
},
}}
children={(field) => {
return <>{/* ... */}</>
}}
/>
This will run onChangeAsync every 1500ms while onBlurAsync will run every 500ms.
While functions provide more flexibility and customization over your validation, they can be a bit verbose. To help solve this, there are libraries like Valibot, Yup, and Zod that provide schema-based validation to make shorthand and type-strict validation substantially easier.
Luckily, we support all of these libraries through official adapters:
$ npm install @tanstack/zod-form-adapter zod
# or
$ npm install @tanstack/yup-form-adapter yup
# or
$ npm install @tanstack/valibot-form-adapter valibot
$ npm install @tanstack/zod-form-adapter zod
# or
$ npm install @tanstack/yup-form-adapter yup
# or
$ npm install @tanstack/valibot-form-adapter valibot
Once done, we can add the adapter to the validator property on the form or field:
import { zodValidator } from '@tanstack/zod-form-adapter'
import { z } from 'zod'
// ...
const form = useForm({
// Either add the validator here or on `Field`
validatorAdapter: zodValidator(),
// ...
})
<form.Field
name="age"
validatorAdapter={zodValidator()}
validators={{
onChange: z.number().gte(13, 'You must be 13 to make an account'),
}}
children={(field) => {
return <>{/* ... */}</>
}}
/>
import { zodValidator } from '@tanstack/zod-form-adapter'
import { z } from 'zod'
// ...
const form = useForm({
// Either add the validator here or on `Field`
validatorAdapter: zodValidator(),
// ...
})
<form.Field
name="age"
validatorAdapter={zodValidator()}
validators={{
onChange: z.number().gte(13, 'You must be 13 to make an account'),
}}
children={(field) => {
return <>{/* ... */}</>
}}
/>
These adapters also support async operations using the proper property names:
<form.Field
name="age"
validators={{
onChange: z.number().gte(13, 'You must be 13 to make an account'),
onChangeAsyncDebounceMs: 500,
onChangeAsync: z.number().refine(
async (value) => {
const currentAge = await fetchCurrentAgeOnProfile()
return value >= currentAge
},
{
message: 'You can only increase the age',
},
),
}}
children={(field) => {
return <>{/* ... */}</>
}}
/>
<form.Field
name="age"
validators={{
onChange: z.number().gte(13, 'You must be 13 to make an account'),
onChangeAsyncDebounceMs: 500,
onChangeAsync: z.number().refine(
async (value) => {
const currentAge = await fetchCurrentAgeOnProfile()
return value >= currentAge
},
{
message: 'You can only increase the age',
},
),
}}
children={(field) => {
return <>{/* ... */}</>
}}
/>
You can also use the adapter at the form level:
import { zodValidator } from '@tanstack/zod-form-adapter'
import { z } from 'zod'
// ...
const formSchema = z.object({
age: z.number().gte(13, 'You must be 13 to make an account'),
})
const form = useForm({
validatorAdapter: zodValidator(),
validators: {
onChange: formSchema
},
})
import { zodValidator } from '@tanstack/zod-form-adapter'
import { z } from 'zod'
// ...
const formSchema = z.object({
age: z.number().gte(13, 'You must be 13 to make an account'),
})
const form = useForm({
validatorAdapter: zodValidator(),
validators: {
onChange: formSchema
},
})
If you use the adapter at the form level, it will pass the validation to the fields of the same name.
This means that:
<form.Field
name="age"
children={(field) => {
return <>{/* ... */}</>
}}
/>
<form.Field
name="age"
children={(field) => {
return <>{/* ... */}</>
}}
/>
Will still display the error message from the form-level validation.
The onChange, onBlur etc... callbacks are also run when the form is submitted and the submission is blocked if the form is invalid.
The form state object has a canSubmit flag that is false when any field is invalid and the form has been touched (canSubmit is true until the form has been touched, even if some fields are "technically" invalid based on their onChange/onBlur props).
You can subscribe to it via form.Subscribe and use the value in order to, for example, disable the submit button when the form is invalid (in practice, disabled buttons are not accessible, use aria-disabled instead).
const form = useForm(/* ... */)
return (
/* ... */
// Dynamic submit button
<form.Subscribe
selector={(state) => [state.canSubmit, state.isSubmitting]}
children={([canSubmit, isSubmitting]) => (
<button type="submit" disabled={!canSubmit}>
{isSubmitting ? '...' : 'Submit'}
</button>
)}
/>
)
const form = useForm(/* ... */)
return (
/* ... */
// Dynamic submit button
<form.Subscribe
selector={(state) => [state.canSubmit, state.isSubmitting]}
children={([canSubmit, isSubmitting]) => (
<button type="submit" disabled={!canSubmit}>
{isSubmitting ? '...' : 'Submit'}
</button>
)}
/>
)
Your weekly dose of JavaScript news. Delivered every Monday to over 100,000 devs, for free.