T
TanStack5w ago
extended-salmon

formIsValid stays false despite all fields being valid?

I have a large form with field-level validation configured to run on blur. I've run into a tricky issue where formIsValid remains false even when all individual fields show isValid: true. The problem flow: 1. User fills out form and selects a radio button (the final field) 2. User clicks Save → nothing happens because formIsValid is false (even though there are no field errors) 3. User clicks back into any text field and tabs out (triggering blur) 4. User clicks Save → now it works because formIsValid finally becomes true What I've tried: I expected await form.handleSubmit() to re-run all validation and update formIsValid, but it seems to run the validation without updating the formIsValid property? Root cause I discovered: The issue is browser-specific behavior with radio buttons. When a user clicks Save after selecting a radio button: Chrome: Focuses the radio button, so blur events work as expected Safari: Doesn't focus the radio button, so no blur event fires, meaning validation never runs for that field This means in Safari, the radio button validation never triggers, keeping formIsValid false even though the field value is valid. Question: Is there a way to manually trigger the validation needed to update formIsValid? Or force all field validations to run and update the form's validity state before submit? Happy to share code snippets if that would help troubleshoot! Note: I'm using Zod for form validation, and TanStack form composition with useAppForm, withForm, etc.
const form = useAppForm({
defaultValues: initialValues,
validators: {
onBlur: lobbyingActivitySchema,
},

listeners: {
/**
* Clear any field errors immediately when user starts typing to fix the field.
* This provides instant feedback and prevents stale errors from confusing
* screen readers or making the form feel unresponsive.
*/
onChange: ({ fieldApi }) => {
if (fieldApi.state.meta.errors.length > 0) {
fieldApi.setMeta((prev) => ({
...prev,
errorMap: {},
}));
}
},
},

onSubmit: async ({ value }) => {
await onSubmit(value);
},
});
const form = useAppForm({
defaultValues: initialValues,
validators: {
onBlur: lobbyingActivitySchema,
},

listeners: {
/**
* Clear any field errors immediately when user starts typing to fix the field.
* This provides instant feedback and prevents stale errors from confusing
* screen readers or making the form feel unresponsive.
*/
onChange: ({ fieldApi }) => {
if (fieldApi.state.meta.errors.length > 0) {
fieldApi.setMeta((prev) => ({
...prev,
errorMap: {},
}));
}
},
},

onSubmit: async ({ value }) => {
await onSubmit(value);
},
});
const handleFormSubmit = async (e: React.FormEvent) => {
e.preventDefault();
e.stopPropagation();

await form.handleSubmit();

/**
* Check for errors before submitting form. If there are errors, focus on
* the first error field and return.
*/
const formErrors = form.state.fieldMeta;
const hasFormErrors = Object.values(formErrors).some(
(meta) => meta.errors && meta.errors.length > 0,
);

if (hasFormErrors) {
focusFirstErrorInput();
return;
}
};
const handleFormSubmit = async (e: React.FormEvent) => {
e.preventDefault();
e.stopPropagation();

await form.handleSubmit();

/**
* Check for errors before submitting form. If there are errors, focus on
* the first error field and return.
*/
const formErrors = form.state.fieldMeta;
const hasFormErrors = Object.values(formErrors).some(
(meta) => meta.errors && meta.errors.length > 0,
);

if (hasFormErrors) {
focusFirstErrorInput();
return;
}
};
No description
35 Replies
extended-salmon
extended-salmonOP5w ago
In Safari, when clicking a radio button, and then clicking "Save" - no blur happens (but it does happen in Chrome). This blur seems to need to happen for isFormValid to be true.
correct-apricot
correct-apricot5w ago
could you share the radio button snippet? I didn't know about this parity between safari and chrome in general, handleSubmit will return early if canSubmit is false. If canSubmitWhenInvalid is true, it will rerun all validation regardless of canSubmit. Note that it won't trigger onSubmit unless those validators passed. Determining the cause for the onBlur behaviour is still important though, so I'd appreciate a snippet to test against.
extended-salmon
extended-salmonOP5w ago
Yeah I can try to get you a snippet tomorrow. Though, this is a custom component/radio button. I haven't tried it with a vanilla HTML radio button that doesn't have custom styling. That being said, let's say I have 10 form inputs, and isValid is true on all of them when the user clicks "Save", and handleSubmit is run, why would formIsValid and canSubmit be false if the validation is re-run? It's weird that I can make both formIsValid and canSubmit by just going to another field, and then bluring--and then the Save button and handleSubmit work
correct-apricot
correct-apricot5w ago
I see. Which UI library do you use? There are some libraries that like to overcorrect some stuff. One that comes to mind is Bootstrap and their number inputs, so I can look into the library too. It wouldn't explain why it works on Chrome though ... It implies that the field.handleBlur wasn't called for safari
extended-salmon
extended-salmonOP5w ago
It's an internal one at my company. But I think the idea is the same as something like Bootstrap though. That being said, if isValid is true on the field - and it's being validated, is there any reason that canSubmit and formIsValid would still be false? Here's something I threw together to troubleshoot, and you can see that each and every field is valid. But canSubmit and formIsValid are both false (that is, until I go a text/input field, and blur). Is there a way to programmatically say "run the blur validation on every field"?
correct-apricot
correct-apricot5w ago
Is there a way to programmatically say "run the blur validation on every field"?
there is! form.validateAllFields
That being said, if isValid is true on the field - and it's being validated, is there any reason that canSubmit and formIsValid would still be false?
Not that I can think of - This will need some testing. Thanks for the report!
extended-salmon
extended-salmonOP5w ago
I did try form.validateAllFields, and it didn't make any difference. Even tried manually validating the fields before calling handleSubmit:
await form.validateAllFields("blur");
await form.handleSubmit();
await form.validateAllFields("blur");
await form.handleSubmit();
It only seems to flip canSubmit and formIsValid when I actually blur a field
correct-apricot
correct-apricot5w ago
perhaps there's a lingering field? do you have any dynamic sections in the form? actually, that is likely what's going on. formIsValid means the form itself has errors, but you mentioned that no field actually has problems and they're all valid Zod schemas will create an error object and send a copy to the form, so you should be able to read all issues that were mapped to fields in formIsValid and form.state.errors However, if there's no field that actually matches the field name (zod's generated path might mismatch), then it's only present on the form and not on any field Compare that to isFieldsValid which represents all field errors currently present
extended-salmon
extended-salmonOP5w ago
Does this explain why everything works as expected if I fill in all of the fields, and then just make sure I blur out of any field before clicking "Save" - and then it works?
correct-apricot
correct-apricot5w ago
-# not even a little. It should help with making a reproducible example though! I'll give it a shot later
extended-salmon
extended-salmonOP5w ago
Cool! I'll try and send some examples/videos tomorrow (it's getting a bit late my time). Really appreciate your help!!
correct-apricot
correct-apricot5w ago
no rush :Prayge: hopefully the suggested workaround will do until it's resolved
extended-salmon
extended-salmonOP5w ago
I might have missed it. What was the suggested work around? And after doing some Googling, it definitely looks like Safari handles focusing on radio buttons differently. I’ll try to create a reproducible example tomorrow Oh you’re saying that the Zod schema might have an error—but might not map to any field? I can definitely quadruple check that. I just find it weird that it works perfectly fine in Chrome when the blurring happens on each field.
correct-apricot
correct-apricot5w ago
canSubmitWhenInvalid will allow you to submit with canSubmit: false and will rerun the validators, hopefully checking that your form is indeed valid at submission time. That's the suggested workaround yeah, that's the part that isn't explained by the zod error. It might explain why fields can be valid before zod realized it's fine now (because the blur validation hasn't run yet) because the errors that were generated weren't pointed at an existing field
extended-salmon
extended-salmonOP5w ago
Do you know of an easy way to output/compare the zod schema to my fields so that I can troubleshoot that?
correct-apricot
correct-apricot5w ago
add a form listener that outputs yourSchema.safeParse(value) to console lists all the issues alternatively, use a store to get the current form-level errors:
const errors = useStore(form.store, state => state.errors)

useEffect(() => console.log(errors), [errors])
const errors = useStore(form.store, state => state.errors)

useEffect(() => console.log(errors), [errors])
which should be a formatted version of the zod errors looking back ... you're setting field meta directly here. Maybe that's causing the desync between form and field state. That should be easy to test though, I'll make an example real quick to test
correct-apricot
correct-apricot5w ago
it was indeed because of setMeta. Since I haven't seen this type of custom validation logic before, I've gone ahead and made an example listing three versions: yours, mine, and a second / third suggestion. Feel free to ask for more info: https://stackblitz.com/edit/vitejs-vite-owpjfazk?file=src%2FApp.tsx @brandonleichty
LeCarbonator
StackBlitz
Vitejs - Vite (duplicated) - StackBlitz
Next generation frontend tooling. It's fast!
extended-salmon
extended-salmonOP4w ago
@Luca | LeCarbonator WOW... First of all, I can't believe you took the time to put all of those examples together for me. Truly amazing! Let me know if I can buy a "virtual" coffee or something! I'm so glad I shared that full code snippet, as I was totally looking in the wrong spot. I can't believe that's what it was. Out of your three options, is there one that you'd recommend or you feel like is the most "TanStack Form" way to handle this? Again, thnank you thank you!!
correct-apricot
correct-apricot4w ago
Well, I don't have a coffee thingy set up, so don't worry about that! As for what my suggestion is, it depends on what your validators really are. If it's really just a zod schema (especially zod v4), go for version 3. The schema is quick to run, you won't run into issues where canSubmitis false for too long, but the visual errors shouldn't show up for the user.
extended-salmon
extended-salmonOP4w ago
It is a Zod 4 schema... though it's pretty complex. This form has a lot of conditional logic, and I haven't found a way to get it to work outside of this pattern I'm using...
correct-apricot
correct-apricot4w ago
true, but unless you benchmarked it, I doubt it's actually the halting bit of your code. If that's the concern, then using React in the first place is a problem TanStack Form isn't particularly optimized either, it focuses on less rerenders at the expense of RAM at the moment
extended-salmon
extended-salmonOP4w ago
Yeah I'm not super worried about performance. As I'm sure it's more than fast enough! On a semi-unrelated note (by on the topic of Zod), would what you say I'm doing for conditional fields is a solid approach? I need runtime checks, and I couldn't get a Zod discriminated union to work in my usecase
correct-apricot
correct-apricot4w ago
I couldn't get Zod discriminated union to work in my usecase
As in the types broke? or runtime?
extended-salmon
extended-salmonOP4w ago
Seemed to be a runtime problem. Couldn't get any of the errors to appear
correct-apricot
correct-apricot4w ago
I mean, this is a free chance to give zod v4 a spin. Do you have an example object to compare against?
extended-salmon
extended-salmonOP4w ago
I think I am using Zod 4? The import I'm using (in the file above) is import * as z from "zod/v4";
correct-apricot
correct-apricot4w ago
it‘s the beta version of zod v4 yeah full release has been out for I think a month? oh, didn‘t answer your question the schema is solid, but make sure to unit test
extended-salmon
extended-salmonOP4w ago
Oh I think I just realized why I was removing the actual errors before... I want the errors to also appear if the user clicks "Save"/submit - even if no fields were blurred. Now the challenge I'm running into is that the errors don't go away when a user clicks "Save" - and goes back to address them. As the submission attempts is great than 0. This is the logic I'm using in my custom form components:
import { useStore, type AnyFieldApi } from "@tanstack/react-form";

export const useFieldError = (field: AnyFieldApi) => {
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 submissionAttempts = useStore(
field.form.store,
(state) => state.submissionAttempts,
);

/**
* Show field errors only after user interaction is complete (blur) or form submission is attempted.
* This improves UX by avoiding disruptive error messages while users are actively typing,
* while still providing timely feedback once they've finished interacting with the field.
*/
return (isBlurred || submissionAttempts > 0) && !isValid
? fieldError
: undefined;
};
import { useStore, type AnyFieldApi } from "@tanstack/react-form";

export const useFieldError = (field: AnyFieldApi) => {
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 submissionAttempts = useStore(
field.form.store,
(state) => state.submissionAttempts,
);

/**
* Show field errors only after user interaction is complete (blur) or form submission is attempted.
* This improves UX by avoiding disruptive error messages while users are actively typing,
* while still providing timely feedback once they've finished interacting with the field.
*/
return (isBlurred || submissionAttempts > 0) && !isValid
? fieldError
: undefined;
};
correct-apricot
correct-apricot4w ago
hmm, I see … this‘ll be tricky
extended-salmon
extended-salmonOP4w ago
What about something like this? This appears to work - but I feel like I might be missing something obvious...
import { useStore, type AnyFieldApi } from "@tanstack/react-form";

export const useFieldError = (field: AnyFieldApi) => {
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 submissionAttempts = useStore(
field.form.store,
(state) => state.submissionAttempts,
);

// Track if field was modified after the last submission attempt
const isDirtyAfterSubmission = useStore(field.store, (state) => {
// If no submission attempts yet, this doesn't matter
if (submissionAttempts === 0) return false;

// Check if the field has been modified since the last submission
return state.meta.isDirty;
});

/**
* Show field errors only after user interaction is complete (blur) or form submission is attempted.
* Hide errors while user is actively fixing the field after a submission attempt.
* This improves UX by avoiding disruptive error messages while users are actively typing,
* while still providing timely feedback once they've finished interacting with the field.
*/
return (isBlurred || (submissionAttempts > 0 && !isDirtyAfterSubmission)) &&
!isValid
? fieldError
: undefined;
};
import { useStore, type AnyFieldApi } from "@tanstack/react-form";

export const useFieldError = (field: AnyFieldApi) => {
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 submissionAttempts = useStore(
field.form.store,
(state) => state.submissionAttempts,
);

// Track if field was modified after the last submission attempt
const isDirtyAfterSubmission = useStore(field.store, (state) => {
// If no submission attempts yet, this doesn't matter
if (submissionAttempts === 0) return false;

// Check if the field has been modified since the last submission
return state.meta.isDirty;
});

/**
* Show field errors only after user interaction is complete (blur) or form submission is attempted.
* Hide errors while user is actively fixing the field after a submission attempt.
* This improves UX by avoiding disruptive error messages while users are actively typing,
* while still providing timely feedback once they've finished interacting with the field.
*/
return (isBlurred || (submissionAttempts > 0 && !isDirtyAfterSubmission)) &&
!isValid
? fieldError
: undefined;
};
correct-apricot
correct-apricot4w ago
does isDirty reset if submission attempts is bigger than 0? I don't think it does either way, I would rely on a useRef instead to keep track of it. You don't need it to be reactive since it's purely dependent on other reactive states I'll write something and see if it works 👍
function tryGetErrorMessage(errors: unknown[]): string | undefined {
return errors.map(err => {
// error is a message already
if (typeof err === 'string') return err;
// error is nullish (technically unintended/impossible, but whatever)
if (err === undefined || err === null) return null;
// if error contains a 'message' string property, return that.
if (typeof err === 'object' && 'message' in err && typeof err.message === 'string') {
return err.message
};
// try stringifying as fallback
return String(err);
}).filter(v => typeof v === 'string').at(0);
}
export const useFieldError = (field: AnyFieldApi) => {
const isValid = useStore(field.store, state => state.meta.isValid);
const isBlurred = useStore(field.store, state => state.meta.isBlurred);
const errors = useStore(field.store, state => state.meta.errors);
const submissionAttempts = useStore(field.form.store, state => state.submissionAttempts);

const forceShowError = useRef(false);

useEffect(() => {
if (!isBlurred) {
// blurring a field means the
// field has been interacted with again. It assumes that you add the
// `onChange` field listener as in v3
forceShowError.current = false;
}
}, [isBlurred])

useEffect(() => {
// if the user has at least one submission attempt
// force show errors for that attempt. Will reset per field
// if it has been changed.
if (submissionAttempts > 0) {
forceShowError.current = true;
}
}, [submissionAttempts])

if (isBlurred || forceShowError.current) {
return tryGetErrorMessage(errors);
}
return undefined;
}
function tryGetErrorMessage(errors: unknown[]): string | undefined {
return errors.map(err => {
// error is a message already
if (typeof err === 'string') return err;
// error is nullish (technically unintended/impossible, but whatever)
if (err === undefined || err === null) return null;
// if error contains a 'message' string property, return that.
if (typeof err === 'object' && 'message' in err && typeof err.message === 'string') {
return err.message
};
// try stringifying as fallback
return String(err);
}).filter(v => typeof v === 'string').at(0);
}
export const useFieldError = (field: AnyFieldApi) => {
const isValid = useStore(field.store, state => state.meta.isValid);
const isBlurred = useStore(field.store, state => state.meta.isBlurred);
const errors = useStore(field.store, state => state.meta.errors);
const submissionAttempts = useStore(field.form.store, state => state.submissionAttempts);

const forceShowError = useRef(false);

useEffect(() => {
if (!isBlurred) {
// blurring a field means the
// field has been interacted with again. It assumes that you add the
// `onChange` field listener as in v3
forceShowError.current = false;
}
}, [isBlurred])

useEffect(() => {
// if the user has at least one submission attempt
// force show errors for that attempt. Will reset per field
// if it has been changed.
if (submissionAttempts > 0) {
forceShowError.current = true;
}
}, [submissionAttempts])

if (isBlurred || forceShowError.current) {
return tryGetErrorMessage(errors);
}
return undefined;
}
How about something like this? @brandonleichty hmmm ... not quite ... if a user submits without having interacted with any field, then isBlurred will start out as false and therefore won't reset forceShowError on change the main issue here is that you want to clear the error as soon as any change happens, which I haven‘t seen before here. I‘ll think about it for a bit, maybe something comes to mind if not, then it‘s something to remember for the validation feature coming soon I guess instead of doing all that blur setting shenanigans, you could also just subscribe to the field value with useEffect set the meta of isBlurred in that hook too so you don‘t forget in the form listener
function tryGetErrorMessage(errors: unknown[]): string | undefined {
return errors
.map((err) => {
// error is a message already
if (typeof err === 'string') return err;
// error is nullish (technically unintended/impossible, but whatever)
if (err === undefined || err === null) return null;
// if error contains a 'message' string property, return that.
if (
typeof err === 'object' &&
'message' in err &&
typeof err.message === 'string'
) {
return err.message;
}
// try stringifying as fallback
return String(err);
})
.filter((v) => typeof v === 'string')
.at(0);
}
export const useFieldError = (field: AnyFieldApi) => {
const isValid = useStore(field.store, (state) => state.meta.isValid);
const isBlurred = useStore(field.store, (state) => state.meta.isBlurred);
const errors = useStore(field.store, (state) => state.meta.errors);
const submissionAttempts = useStore(
field.form.store,
(state) => state.submissionAttempts
);

const lastValidAttempt = useRef(submissionAttempts);
const showSubmitError = lastValidAttempt.current !== submissionAttempts;

useEffect(() => {
if (isValid && submissionAttempts > 0) {
// the user has corrected the field since the last submission
// requested changes.
lastValidAttempt.current = submissionAttempts;
}
if (isValid && isBlurred) {
// if the user changed the value so that it became valid,
// you can remove the blurred state.
field.setMeta((prev) => ({ ...prev, isBlurred: false }));
}
}, [field, isValid, isBlurred, submissionAttempts]);

if (!isValid && (isBlurred || showSubmitError)) {
return tryGetErrorMessage(errors);
}
return undefined;
};
function tryGetErrorMessage(errors: unknown[]): string | undefined {
return errors
.map((err) => {
// error is a message already
if (typeof err === 'string') return err;
// error is nullish (technically unintended/impossible, but whatever)
if (err === undefined || err === null) return null;
// if error contains a 'message' string property, return that.
if (
typeof err === 'object' &&
'message' in err &&
typeof err.message === 'string'
) {
return err.message;
}
// try stringifying as fallback
return String(err);
})
.filter((v) => typeof v === 'string')
.at(0);
}
export const useFieldError = (field: AnyFieldApi) => {
const isValid = useStore(field.store, (state) => state.meta.isValid);
const isBlurred = useStore(field.store, (state) => state.meta.isBlurred);
const errors = useStore(field.store, (state) => state.meta.errors);
const submissionAttempts = useStore(
field.form.store,
(state) => state.submissionAttempts
);

const lastValidAttempt = useRef(submissionAttempts);
const showSubmitError = lastValidAttempt.current !== submissionAttempts;

useEffect(() => {
if (isValid && submissionAttempts > 0) {
// the user has corrected the field since the last submission
// requested changes.
lastValidAttempt.current = submissionAttempts;
}
if (isValid && isBlurred) {
// if the user changed the value so that it became valid,
// you can remove the blurred state.
field.setMeta((prev) => ({ ...prev, isBlurred: false }));
}
}, [field, isValid, isBlurred, submissionAttempts]);

if (!isValid && (isBlurred || showSubmitError)) {
return tryGetErrorMessage(errors);
}
return undefined;
};
I've tinkered with the solution last night, and it was quite difficult to get it right. The immediate removal of the field error would require keeping track of change, blur as well as submission which would need more logic worked into it. However, I have a proposal that you can try out which is based on Reward Early, Punish Late. Essentially this flow instead of your proposed one: * If a value is invalid, as soon as the user fixes the input it should be green again (reward early). * If a value is valid, it shouldn't become red until the user finishes its input -- on blur or submission. That reduced it to only three main states: submissionAttempts, blurred state and isValid. It also flows quite nicely from the UX perspective. As far as screen readers go, an early removal of the error might actually convey the wrong information, as it's not actually a valid field yet. You may end up in a loop of changing 1 character and blurring it, only to find out that the new value wasn't actually correct at all.
correct-apricot
correct-apricot4w ago
here's a screencast of what the behaviour would be with the proposed strategy. Let me know if you still want the eager removal of errors @brandonleichty
correct-apricot
correct-apricot4w ago
this implementation should also be safe if you have dynamic fields, such as a checkbox for e. g. has Start date, but not actually providing a start date. It would look to the user like it's valid, until they try to submit and see that it has errors
extended-salmon
extended-salmonOP4w ago
Hey @Luca | LeCarbonator- I somehow missed the notification for this message! Sorry I'm just responding. Wow, you really went above and beyond here! Can't thank you enough. I am curious... Is it not uncommon for people to want to show errors on blur, and then remove them when the user starts typing? That's been a common pattern that UX/AX teams that I've worked with have recommended. In terms of my original issue, I ended up going this route, which fixed my weird Safari radio button focusing issue. And all works as expected now. Though, I probably need to do some research into why the canSubmit wasn't flipping to true in my case...
onChange: ({ fieldApi, formApi }) => {
if (fieldApi.state.meta.errors.length > 0) {
fieldApi.setMeta((prev) => ({
...prev,
errorMap: {},
}));
}

if (formApi.state.errorMap.onBlur) {
formApi.validate("blur");
}
},
onChange: ({ fieldApi, formApi }) => {
if (fieldApi.state.meta.errors.length > 0) {
fieldApi.setMeta((prev) => ({
...prev,
errorMap: {},
}));
}

if (formApi.state.errorMap.onBlur) {
formApi.validate("blur");
}
},
I was wondering: is it possible to have multiple global validators? I couldn't seem to get that to work. For example:
validators: {
onBlur: schemaName,
onChange: schemaName
},
validators: {
onBlur: schemaName,
onChange: schemaName
},
correct-apricot
correct-apricot4w ago
yeah, you can have multiple. Just keep in mind that they don‘t rerun the same either so an onBlur error would not be cleared from onChange

Did you find this page helpful?