T
TanStack2mo ago
extended-salmon

Inconsistent validation timing - first field validates onBlur, subsequent fields validate onChange

Issue Title TanStack Form: Inconsistent validation timing - first field validates onBlur, subsequent fields validate onChange Problem: Form validation behavior is inconsistent across fields. The first text field correctly waits until blur to show validation errors, but subsequent text fields show validation errors immediately when the user starts typing. Current Behavior: - First field: ✅ Shows errors only after blur - Subsequent fields: ❌ Shows errors immediately on typing Configuration:
const form = useAppForm({
defaultValues,
validators: {
onBlur: muCustomZodSchema,
},
// ...
});
const form = useAppForm({
defaultValues,
validators: {
onBlur: muCustomZodSchema,
},
// ...
});
Composed Form Component
import TextField from "@internal-library/textfield";
import React from "react";
import { useFieldContext, useFormContext } from "../../../../hooks/form";

export const FormTextField = ({
error,
...textFieldProps
}: {
error?: string;
} & Omit<
React.ComponentProps<typeof TextField>,
"name" | "onChange" | "value" | "onBlur"
>) => {
const field = useFieldContext<string>();
const form = useFormContext();


const displayError =
field.state.meta.isTouched &&
!field.state.meta.isValid &&
!field.state.meta.isValidating
? error || field.state.meta.errors?.[0]?.message
: undefined;

return (
<form.Subscribe selector={(state) => state.isSubmitting}>
{(isSubmitting) => (
<TextField
{...textFieldProps}
name={field.name}
value={field.state.value || ""}
onChange={(event) => field.handleChange(event.target.value)}
onBlur={field.handleBlur}
disabled={isSubmitting || textFieldProps.disabled}
error={displayError}
/>
)}
</form.Subscribe>
);
};
import TextField from "@internal-library/textfield";
import React from "react";
import { useFieldContext, useFormContext } from "../../../../hooks/form";

export const FormTextField = ({
error,
...textFieldProps
}: {
error?: string;
} & Omit<
React.ComponentProps<typeof TextField>,
"name" | "onChange" | "value" | "onBlur"
>) => {
const field = useFieldContext<string>();
const form = useFormContext();


const displayError =
field.state.meta.isTouched &&
!field.state.meta.isValid &&
!field.state.meta.isValidating
? error || field.state.meta.errors?.[0]?.message
: undefined;

return (
<form.Subscribe selector={(state) => state.isSubmitting}>
{(isSubmitting) => (
<TextField
{...textFieldProps}
name={field.name}
value={field.state.value || ""}
onChange={(event) => field.handleChange(event.target.value)}
onBlur={field.handleBlur}
disabled={isSubmitting || textFieldProps.disabled}
error={displayError}
/>
)}
</form.Subscribe>
);
};
Question: Why would field.state.meta.isTouched and !field.state.meta.isValid both be true during typing for some fields but not others, when using the same component and validation configuration?
8 Replies
extended-salmon
extended-salmonOP2mo ago
One workaround I've found is to move all of the field validation inline into each form field. However, this feels like it adds a bunch of extra boilerplate - especially for a large form As an example, this worked (see below). But it required that I add the validation to each field individually.
import * as z from "zod/v4";

export const fieldValidators = {
activityType: z
.nullable(z.string())
.refine((val) => val !== null && val.length, {
message: "Please select an activity type",
}),

activityDescription: z.string().min(1, "Activity description is required"),

activityDate: z
.union([z.undefined(), z.date()])
.refine((val) => val instanceof Date, {
message: "Activity date is required",
}),

reasonForActivity: z.string().min(1, "Reason for activity is required"),

activityLengthHours: z
.string()
.min(1, "Activity length is required")
.refine(
(val) => {
const num = parseFloat(val);
return !isNaN(num) && num > 0;
},
{
message: "Activity length must be a valid positive number",
},
),
};

export const exampleFormSchema = z.object({
activityType: fieldValidators.activityType,
activityDescription: fieldValidators.activityDescription,
activityDate: fieldValidators.activityDate,
reasonForActivity: fieldValidators.reasonForActivity,
activityLengthHours: fieldValidators.activityLengthHours,
});

export type ExampleFormData = z.infer<typeof exampleFormSchema>;

export const defaultValues: ExampleFormData = {
activityType: null as string,
activityDescription: "",
activityDate: undefined as Date,
reasonForActivity: "",
activityLengthHours: "",
};
import * as z from "zod/v4";

export const fieldValidators = {
activityType: z
.nullable(z.string())
.refine((val) => val !== null && val.length, {
message: "Please select an activity type",
}),

activityDescription: z.string().min(1, "Activity description is required"),

activityDate: z
.union([z.undefined(), z.date()])
.refine((val) => val instanceof Date, {
message: "Activity date is required",
}),

reasonForActivity: z.string().min(1, "Reason for activity is required"),

activityLengthHours: z
.string()
.min(1, "Activity length is required")
.refine(
(val) => {
const num = parseFloat(val);
return !isNaN(num) && num > 0;
},
{
message: "Activity length must be a valid positive number",
},
),
};

export const exampleFormSchema = z.object({
activityType: fieldValidators.activityType,
activityDescription: fieldValidators.activityDescription,
activityDate: fieldValidators.activityDate,
reasonForActivity: fieldValidators.reasonForActivity,
activityLengthHours: fieldValidators.activityLengthHours,
});

export type ExampleFormData = z.infer<typeof exampleFormSchema>;

export const defaultValues: ExampleFormData = {
activityType: null as string,
activityDescription: "",
activityDate: undefined as Date,
reasonForActivity: "",
activityLengthHours: "",
};
<form.AppField
name="activityLengthHours"
validators={{
onBlur: fieldValidators.activityLengthHours,
}}
>
{(field) => (
<field.FormTextField label="Activity Length (In Hours)" />
)}
</form.AppField>
<form.AppField
name="activityLengthHours"
validators={{
onBlur: fieldValidators.activityLengthHours,
}}
>
{(field) => (
<field.FormTextField label="Activity Length (In Hours)" />
)}
</form.AppField>
equal-jade
equal-jade2mo ago
form validation isn't inconsistent across fields. It triggers on blur for any field and will generate the form errors which may include all fields. The reason you only see it in the currently blurred field is because it's touched and not valid, and therefore matches the condition you set for it. Other fields have errors, but are not touched. Once you changed the field value, it's touched. Are you perhaps looking for field.state.meta.isBlurred?
extended-salmon
extended-salmonOP2mo ago
Thanks @Luca | LeCarbonator! I see. So in my case, it might make more sense to just add the validation at a field level?
equal-jade
equal-jade2mo ago
not necessarily. You can modify your displayErrors to use isBlurred if you plan on using onBlur validation more often. Since it's a field component, an unrelated note: Meta properties should be accessed using useStore to ensure they're reactive.
const field = useFieldContext<string>()

field.state.value // okay
- field.state.meta.isTouched // risks not being reactive
+ const isTouched = useStore(field.store, state => state.meta.isTouched)
+ const isValid = useStore(field.store, state => state.meta.isValid)
...
const field = useFieldContext<string>()

field.state.value // okay
- field.state.meta.isTouched // risks not being reactive
+ const isTouched = useStore(field.store, state => state.meta.isTouched)
+ const isValid = useStore(field.store, state => state.meta.isValid)
...
The reason why is not in the documentation yet 😅 it's marked as issue #1207 though
extended-salmon
extended-salmonOP2mo ago
Good to know! I'll give that a try! @Luca | LeCarbonator—thanks again for your help! That ended up working out well. One other question for you... So the fields properly validate when blurred now. However, when a user (myself) clicks "Submit", the fields don't show their invalid state. And it seems to be because they haven't been blurred. Any thoughts on how to best approach this? This is what I currently have:
const field = useFieldContext<string>();
const form = useFormContext();

const isValid = useStore(field.store, (state) => state.meta.isValid);
const isBlurred = useStore(field.store, (state) => state.meta.isBlurred);
const fieldError = useStore(
field.store,
(state) => state.meta.errors?.[0]?.message,
);
const field = useFieldContext<string>();
const form = useFormContext();

const isValid = useStore(field.store, (state) => state.meta.isValid);
const isBlurred = useStore(field.store, (state) => state.meta.isBlurred);
const fieldError = useStore(
field.store,
(state) => state.meta.errors?.[0]?.message,
);
equal-jade
equal-jade2mo ago
right, I suggest an 'override' for submission attempts to ensure it's always showing errors for that:
const submissionAttempts = useStore(field.form.store, state => state.submissionAttempts)

// submissionAttempts > 0 => they have attempted to submit before, so definitely show the error if any
const submissionAttempts = useStore(field.form.store, state => state.submissionAttempts)

// submissionAttempts > 0 => they have attempted to submit before, so definitely show the error if any
extended-salmon
extended-salmonOP2mo ago
Would it also make sense to track the isSubmitted like this?
<form.Subscribe
selector={(state) => ({
isSubmitting: state.isSubmitting,
isSubmitted: state.isSubmitted,
})}
>
{({ isSubmitting, isSubmitted }) => {
const displayError =
!isValid && (isBlurred || isSubmitted) ? fieldError : undefined;

return (
<TextField
{...textFieldProps}
name={field.name}
value={field.state.value || ""}
onChange={(event) => field.handleChange(event.target.value)}
onBlur={field.handleBlur}
disabled={isSubmitting || textFieldProps.disabled}
error={displayError}
/>
);
}}
</form.Subscribe>
<form.Subscribe
selector={(state) => ({
isSubmitting: state.isSubmitting,
isSubmitted: state.isSubmitted,
})}
>
{({ isSubmitting, isSubmitted }) => {
const displayError =
!isValid && (isBlurred || isSubmitted) ? fieldError : undefined;

return (
<TextField
{...textFieldProps}
name={field.name}
value={field.state.value || ""}
onChange={(event) => field.handleChange(event.target.value)}
onBlur={field.handleBlur}
disabled={isSubmitting || textFieldProps.disabled}
error={displayError}
/>
);
}}
</form.Subscribe>
equal-jade
equal-jade2mo ago
not particularly. See the docs:
A boolean indicating if the onSubmit function has completed successfully. Goes back to false at each new submission attempt. Note: you can use isSubmitting to check if the form is currently submitting.

Did you find this page helpful?