T
TanStack22h ago
sensitive-blue

What to use when the Backend returns a form error? Validator vs Submit

Hi! So I have a form that has onSubmit validators. They are there so we do basic validations (through zod) like numbers, minimum characters, etc. I also have a regular onSubmit prop where I call the backend if the validator checks that everything is okay. Now, the Backend may return errors as well, such as Username already exists, but it does so when we actually try to create the user - there isn't a separate service where I check for usernames. Basically the POST to /api/users returns a 400 with the error message. I have checked this example -> https://tanstack.com/form/latest/docs/framework/react/examples/field-errors-from-form-validators but here there isn't an onSubmit handler, just a validator. Also, I think it assumes there is a service specifically for checking if the username already exists (which isn't my case) My question is: In my case I feel like I should still call the CreateUser service inside the onSubmit because the validator shouldn't be the one creating users when the form is all okay. How would you approach this? And also, how would you highlight the username field like on the example, when there's an error OUTSIDE a validator (inside a regular onSubmit), like this?:
if (!isUsernameAvailable) {
return {
// The `form` key is optional
form: 'Invalid data',
fields: {
...(!isUsernameAvailable
? { username: 'Username is taken' }
: {}),
},
}
}
if (!isUsernameAvailable) {
return {
// The `form` key is optional
form: 'Invalid data',
fields: {
...(!isUsernameAvailable
? { username: 'Username is taken' }
: {}),
},
}
}
Thanks in advance!
React TanStack Form Field Errors From Form Validators Example | Tan...
An example showing how to implement Field Errors From Form Validators in React using TanStack Form.
42 Replies
rising-crimson
rising-crimson22h ago
because the validator shouldn't be the one creating users when the form is all okay
That's the thing though - You have no separation of concerns here. If there are no validation errors, then the change has already happened, and if there were, then the change wasn't saved in the database. In the current implementation of onSubmit, only successful forms should go through. Only endpoints that are not validators should be called in it. If you have an endpoint that returns validation errors, you should use it in validators.onSubmitAsync. It is only called on submit, and only runs if validators.onSubmit has passed successfully. That being said - While we can talk all day about how this makes logical sense or not, it doesn't feel right. So we'll add a way to create validation errors in onSubmit sometime in the future. For now, you'll have to do with the code mentioned above.
sensitive-blue
sensitive-blueOP21h ago
Thank you so much Luca! So your suggestion for now would be for me to ditch the onSubmit and instead use validators.onSubmit along with validators.onSubmitAsync, so I can react to errors, right? Thanks again! 🙏
rising-crimson
rising-crimson21h ago
yeah. That basically sums it up How you approached it before: * Validators in validators * Validators should not mutate data * Validators return user errors * onSubmit should mutate data * onSubmit returns transient errors and user errors How TSF sees it (for now): * Validators in validators * Validators should be async, the result should determine if it had errors or not * Validators return user errors * onSubmit can mutate data * onSubmit is only for successful submissions * onSubmit returns transient errors
sensitive-blue
sensitive-blueOP21h ago
Gotcha, thanks a lot, I’ll keep an eye on the news ❤️
rising-crimson
rising-crimson21h ago
Sounds good! Let me know if you have other questions
sensitive-blue
sensitive-blueOP20h ago
I actually have one more question now @Luca | LeCarbonator: This approach now seems to break the form composition pattern, when we pass the form (type errors). I have created a small StackBlitz example so you can check it, maybe I'm missing something: https://stackblitz.com/edit/validator-onsubmitasync-composable?file=src%2Findex.tsx (check line 67 on index.tsx)
seab
StackBlitz
validator.onSubmitAsync with Composable sections - StackBlitz
Run official live example code for Form Field Errors From Form Validators, created by Tanstack on StackBlitz
sensitive-blue
sensitive-blueOP20h ago
For reference:
No description
rising-crimson
rising-crimson20h ago
the error happens because withForm expects a form with a specific shape. That way, you can still get the errors in a type safe way inside withForm. However, that also means that your endpoint now caused a difference between what withForm expects and what your state will end up as so withForm expects to know what validators are present and what types they have. The actual runtime value of those validators won't matter for withForm as they are unused
sensitive-blue
sensitive-blueOP20h ago
Hmm I see. So what would be the best approach here? Is it safe to ignore these errors? They always show up as soon as I use any validators.onSubmitAsync, regardless of their content
rising-crimson
rising-crimson20h ago
the best approach would be to ensure withForm knows about the validator. In the current setup, form.state.errorMap.onSubmit is not the same type between the form in withForm vs. the form you're actually using so in this case, add this to withForm:
validators: {
onSubmitAsync: () => '' as string | null,
},
validators: {
onSubmitAsync: () => '' as string | null,
},
sensitive-blue
sensitive-blueOP20h ago
That makes sense, but it doesn't seem to solve my issue :/ I'm not sure what to do next
No description
rising-crimson
rising-crimson19h ago
oh, I thought it resolved the issue in my fork. One sec no error is thrown if I open your stackblitz. Are you sure it's not fixed?
sensitive-blue
sensitive-blueOP19h ago
What browser are you using? It doesn't show any error on FF, but it does on Brave 🤔 On my local environment, on my real app, this error always occurs
rising-crimson
rising-crimson19h ago
FF wouldn't be the first time that stackblitz has inconsistencies like that. can you copy the last few lines of the error and share them here? The ones you get on Brave
sensitive-blue
sensitive-blueOP19h ago
So this is the type error I get, part 1:
Type 'AppFieldExtendedReactFormApi<{ username: string; age: number; }, FormValidateOrFn<{ username: string; age: number; }> | undefined, FormValidateOrFn<{ username: string; age: number; }> | undefined, ... 10 more ..., {}>' is not assignable to type 'AppFieldExtendedReactFormApi<{ username: string; age: number; }, any, any, FormAsyncValidateOrFn<{ username: string; age: number; }> | undefined, any, FormAsyncValidateOrFn<{ username: string; age: number; }> | undefined, ... 7 more ..., {}>'.
Type 'AppFieldExtendedReactFormApi<{ username: string; age: number; }, FormValidateOrFn<{ username: string; age: number; }> | undefined, FormValidateOrFn<{ username: string; age: number; }> | undefined, ... 10 more ..., {}>' is not assignable to type 'FormApi<{ username: string; age: number; }, any, any, FormAsyncValidateOrFn<{ username: string; age: number; }> | undefined, any, FormAsyncValidateOrFn<{ username: string; age: number; }> | undefined, ... 5 more ..., any>'.
The types of 'options.validators' are incompatible between these types.
Type 'FormValidators<{ username: string; age: number; }, FormValidateOrFn<{ username: string; age: number; }> | undefined, FormValidateOrFn<{ username: string; age: number; }> | undefined, ... 6 more ..., FormAsyncValidateOrFn<...> | undefined> | undefined' is not assignable to type 'FormValidators<{ username: string; age: number; }, any, any, FormAsyncValidateOrFn<{ username: string; age: number; }> | undefined, any, FormAsyncValidateOrFn<{ username: string; age: number; }> | undefined, any, () => string | null, any, FormAsyncValidateOrFn<...> | undefined> | undefined'.
Type 'AppFieldExtendedReactFormApi<{ username: string; age: number; }, FormValidateOrFn<{ username: string; age: number; }> | undefined, FormValidateOrFn<{ username: string; age: number; }> | undefined, ... 10 more ..., {}>' is not assignable to type 'AppFieldExtendedReactFormApi<{ username: string; age: number; }, any, any, FormAsyncValidateOrFn<{ username: string; age: number; }> | undefined, any, FormAsyncValidateOrFn<{ username: string; age: number; }> | undefined, ... 7 more ..., {}>'.
Type 'AppFieldExtendedReactFormApi<{ username: string; age: number; }, FormValidateOrFn<{ username: string; age: number; }> | undefined, FormValidateOrFn<{ username: string; age: number; }> | undefined, ... 10 more ..., {}>' is not assignable to type 'FormApi<{ username: string; age: number; }, any, any, FormAsyncValidateOrFn<{ username: string; age: number; }> | undefined, any, FormAsyncValidateOrFn<{ username: string; age: number; }> | undefined, ... 5 more ..., any>'.
The types of 'options.validators' are incompatible between these types.
Type 'FormValidators<{ username: string; age: number; }, FormValidateOrFn<{ username: string; age: number; }> | undefined, FormValidateOrFn<{ username: string; age: number; }> | undefined, ... 6 more ..., FormAsyncValidateOrFn<...> | undefined> | undefined' is not assignable to type 'FormValidators<{ username: string; age: number; }, any, any, FormAsyncValidateOrFn<{ username: string; age: number; }> | undefined, any, FormAsyncValidateOrFn<{ username: string; age: number; }> | undefined, any, () => string | null, any, FormAsyncValidateOrFn<...> | undefined> | undefined'.
And part 2:
Type 'FormValidators<{ username: string; age: number; }, FormValidateOrFn<{ username: string; age: number; }> | undefined, FormValidateOrFn<{ username: string; age: number; }> | undefined, ... 6 more ..., FormAsyncValidateOrFn<...> | undefined>' is not assignable to type 'FormValidators<{ username: string; age: number; }, any, any, FormAsyncValidateOrFn<{ username: string; age: number; }> | undefined, any, FormAsyncValidateOrFn<{ username: string; age: number; }> | undefined, any, () => string | null, any, FormAsyncValidateOrFn<...> | undefined>'.
Type '({ value }: { value: { username: string; age: number; }; formApi: FormApi<{ username: string; age: number; }, any, any, any, any, any, any, any, any, any, any, any>; signal: AbortSignal; }) => Promise<...>' is not assignable to type '() => string | null'.(2322)
createFormHook.d.ts(60, 9): The expected type comes from property 'form' which is declared here on type 'IntrinsicAttributes & { form: AppFieldExtendedReactFormApi<{ username: string; age: number; }, any, any, FormAsyncValidateOrFn<{ username: string; age: number; }> | undefined, ... 9 more ..., {}>; } & { ...; }'
(property) form: AppFieldExtendedReactFormApi<{
username: string;
age: number;
}, any, any, FormAsyncValidateOrFn<{
username: string;
age: number;
}> | undefined, any, FormAsyncValidateOrFn<{
username: string;
age: number;
}> | undefined, ... 7 more ..., {}>
Type 'FormValidators<{ username: string; age: number; }, FormValidateOrFn<{ username: string; age: number; }> | undefined, FormValidateOrFn<{ username: string; age: number; }> | undefined, ... 6 more ..., FormAsyncValidateOrFn<...> | undefined>' is not assignable to type 'FormValidators<{ username: string; age: number; }, any, any, FormAsyncValidateOrFn<{ username: string; age: number; }> | undefined, any, FormAsyncValidateOrFn<{ username: string; age: number; }> | undefined, any, () => string | null, any, FormAsyncValidateOrFn<...> | undefined>'.
Type '({ value }: { value: { username: string; age: number; }; formApi: FormApi<{ username: string; age: number; }, any, any, any, any, any, any, any, any, any, any, any>; signal: AbortSignal; }) => Promise<...>' is not assignable to type '() => string | null'.(2322)
createFormHook.d.ts(60, 9): The expected type comes from property 'form' which is declared here on type 'IntrinsicAttributes & { form: AppFieldExtendedReactFormApi<{ username: string; age: number; }, any, any, FormAsyncValidateOrFn<{ username: string; age: number; }> | undefined, ... 9 more ..., {}>; } & { ...; }'
(property) form: AppFieldExtendedReactFormApi<{
username: string;
age: number;
}, any, any, FormAsyncValidateOrFn<{
username: string;
age: number;
}> | undefined, any, FormAsyncValidateOrFn<{
username: string;
age: number;
}> | undefined, ... 7 more ..., {}>
rising-crimson
rising-crimson19h ago
hm, looks like it's because one's async and the other one isn't
sensitive-blue
sensitive-blueOP19h ago
I've also tried that, without success 🙁
rising-crimson
rising-crimson19h ago
if only this error showed up for me :Madge: let me try chrome looks like it doesn't show up on chrome either. I'll copy the code and check locally
sensitive-blue
sensitive-blueOP19h ago
I'm sorry for making you open that crap 😹 thank you so much in advance 🙏
rising-crimson
rising-crimson19h ago
free chance to get more feedback in this case, reasons to add createError to onSubmit what the ... it's because of different argument size?
sensitive-blue
sensitive-blueOP19h ago
whaaat?
rising-crimson
rising-crimson19h ago
Okay, it looks like it's really really really really strict. More than I thought it initially was. I'll write something up in a moment, just need to check some things
rising-crimson
rising-crimson19h ago
in the meantime, I can recommend this VSCode extension if you use it https://marketplace.visualstudio.com/items?itemName=yoavbls.pretty-ts-errors
Pretty TypeScript Errors - Visual Studio Marketplace
Extension for Visual Studio Code - Make TypeScript errors prettier and more human-readable in VSCode
rising-crimson
rising-crimson19h ago
not for this error, just in general. I really like the extension
sensitive-blue
sensitive-blueOP19h ago
Oh yeah, I love it! Unfortunately StackBlitz doesn't have it 😭
rising-crimson
rising-crimson19h ago
Yeah, unfortunately. Anyways, I was able to recreate the error. It was so specific that it even knew which fields you assigned errors to. Not that I expected it, but annoying for cases like this. If you use the latest version, there's something I can recommend instead. If you want to split up your form into multiple components (with withForm), you can extract the common fields and their types using formOptions:
const commonOptions = formOptions({
defaultValues: {
username: '',
age: 0
},
validators: {
onSubmitAsync: async ({ value }) => {
const [isRightAge, isUsernameAvailable] = await Promise.all([
// Verify the age on the server
verifyAgeOnServer(value.age),
// Verify the availability of the username on the server
checkIfUsernameIsTaken(value.username),
]);

if (!isRightAge || !isUsernameAvailable) {
return {
// The `form` key is optional
form: 'Invalid data',
fields: {
...(!isRightAge ? { age: 'Must be 13 or older to sign' } : {}),
...(!isUsernameAvailable
? { username: 'Username is taken' }
: {}),
},
};
}

return null;
}
}
})
const commonOptions = formOptions({
defaultValues: {
username: '',
age: 0
},
validators: {
onSubmitAsync: async ({ value }) => {
const [isRightAge, isUsernameAvailable] = await Promise.all([
// Verify the age on the server
verifyAgeOnServer(value.age),
// Verify the availability of the username on the server
checkIfUsernameIsTaken(value.username),
]);

if (!isRightAge || !isUsernameAvailable) {
return {
// The `form` key is optional
form: 'Invalid data',
fields: {
...(!isRightAge ? { age: 'Must be 13 or older to sign' } : {}),
...(!isUsernameAvailable
? { username: 'Username is taken' }
: {}),
},
};
}

return null;
}
}
})
What this will do is make sharing between form sections way easier.
const Fields = withForm({
...commonOptions,
// and you're set!
render: () => null
});

// main.tsx
const form = useAppForm({
...commonOptions,
// you can overwrite too if needed
defaultValues: myAsyncData ?? commonOptions.defaultValues
})
const Fields = withForm({
...commonOptions,
// and you're set!
render: () => null
});

// main.tsx
const form = useAppForm({
...commonOptions,
// you can overwrite too if needed
defaultValues: myAsyncData ?? commonOptions.defaultValues
})
This would be very convenient if you used schemas or the like, but in your case, that includes the endpoint ... The main things that withForm and useAppForm must have in common is defaultValues and validators. listeners can still be placed in useAppForm without problem -# and also maybe because they're broken in formOptions at the moment A smaller feature I've been thinking of is giving a handy function to create field-level errors from a form. Since it's not in the library (but possible), here's the snippet. Maybe it helps with type safety and broadening the type more so you encounter less of these hyper-specific errors:
function createFieldLevelError<TData, TError extends { form?: unknown; fields: Record<DeepKeys<TData>, unknown>>(value: TData, error: TError): TError {
return error;
};

// usage
createFieldLevelError(value, {
form: 'Anything',
// type-safe record
fields: { username: 'Error' }
)
function createFieldLevelError<TData, TError extends { form?: unknown; fields: Record<DeepKeys<TData>, unknown>>(value: TData, error: TError): TError {
return error;
};

// usage
createFieldLevelError(value, {
form: 'Anything',
// type-safe record
fields: { username: 'Error' }
)
sensitive-blue
sensitive-blueOP19h ago
Wooow, thanks for all the information! I'd loooove this feature! How would you manually clear up these custom errors, when using that snippet?
rising-crimson
rising-crimson19h ago
manually clear up? could you elaborate'
sensitive-blue
sensitive-blueOP19h ago
Let's say I called createFieldLevelError with an error on an input field. Then the user changes the input. Does the error persist? Or would I need to call something like clearFieldLevelError on it?
rising-crimson
rising-crimson19h ago
depends on the validator you used everything apart from onSubmit is considered persistent, so you need to have another call that clears the error in question. onSubmit errors get cleared if the field changes its value
sensitive-blue
sensitive-blueOP19h ago
oh so it would work together with the onChange, onSubmit, etc handlers? that's incredible
rising-crimson
rising-crimson19h ago
yeah, the function above works for any form-level validator and the form already knows TData, which is why it should be exported (or at least passed to the validators callback)
sensitive-blue
sensitive-blueOP19h ago
Peeerfect! Thank you so much for your help ❤️
rising-crimson
rising-crimson19h ago
no worries 👍
sensitive-blue
sensitive-blueOP19h ago
Btw I had used the formOptions functions, but I thought only the defaultValues would make sense to share between form composition components. I was like: "why the hell would the <Fields /> component be aware of the onSubmit handler, or validators for other areas of the form? But from what I understood, it's not harmful to include everything, right? Even if my validators.onSubmitAsync is also present inside <Fields />
rising-crimson
rising-crimson19h ago
well, that feature was introduced right as I started working with the library, so I don't really know why it was added I personally don't mind structured errors being returned, but to each their own yeah, in withForm, it's only for typing help no runtime stuff in withFieldGroup, the only thing that ends up being used is the Object.keys(defaultValues). It's needed so you can map form fields and the like the rest is also unused
sensitive-blue
sensitive-blueOP19h ago
I see I see, I had no idea at all. It honestly seems kinda weird, but I'm glad I understood it now
rising-crimson
rising-crimson19h ago
it's common for higher order components. But you don't see them around that much anymore
sensitive-blue
sensitive-blueOP19h ago
Yup, I never seem them now. Are there any plans to change withForm from HOC to something else?
rising-crimson
rising-crimson19h ago
hard to say. I doubt form composition will receive much of an overhaul in a v2. There's a bunch of things to address when it comes to validation though, so maybe that will indirectly affect it either way that's a long way away
sensitive-blue
sensitive-blueOP19h ago
Gotcha. Again, thanks a lot and amazing work with the library, have a nice day 😄
rising-crimson
rising-crimson19h ago
you too ❤️

Did you find this page helpful?