T
TanStack4mo ago
extended-salmon

validate on submit then on change

Hello! Great library, thoroughly enjoying it over react hook form. Only one bit that I am stuck on, I would like the following behaviour - User enter values into form - User pressed submit - Any invalid fields show their errors - First invalid field gets focused - Validation is re checked on every change This is how react hook form works, but I can't seem to emulate it in tanstack form and I can't see anything I am doing wrong from docs. I have tried using onSubmit validator, but that removes the error as soon as a key is pressed. If I keep an invalid value and press submit, it focuses the field again but doesn't show the error. Also, if I then enter a valid value and have another field no valid, then pressing submit again doesn't focus the next field. I have tried using onChange but that shows validation errors immediately. What I am really looking for is an onSubmitThenChange option. If they have tried to submit the form and are struggling, we should help them out as much as possible. I tried using onChange and then ignoring errors until submissionAttemps > 0 but that still had weird effects. Attached is code snippets in thread.
7 Replies
extended-salmon
extended-salmonOP4mo ago
// app code
const signInSchema = z.object({
email: z.string().email(),
password: z.string().min(1)
});

export type SignInFormData = z.infer<typeof signInSchema>;

const ExampleRepo = () => {
const form = usePdsForm({
defaultValues: {
email: '',
password: ''
},
validators: {
onSubmit: signInSchema
// onChange: signInSchema
},
onSubmit: ({ value }) => {
console.log(value);
}
});

return (
<form.AppForm>
<form.Form>
<form.AppField
name="email"
children={field => <field.TextField label="Email address" type="email" isRequired />}
/>
<form.AppField
name="password"
children={field => (
<field.TextField label="Password" type="password" isRequired isRevealable />
)}
/>
<form.Submit className="w-full">Sign in</form.Submit>
</form.Form>
</form.AppForm>
);
};
// app code
const signInSchema = z.object({
email: z.string().email(),
password: z.string().min(1)
});

export type SignInFormData = z.infer<typeof signInSchema>;

const ExampleRepo = () => {
const form = usePdsForm({
defaultValues: {
email: '',
password: ''
},
validators: {
onSubmit: signInSchema
// onChange: signInSchema
},
onSubmit: ({ value }) => {
console.log(value);
}
});

return (
<form.AppForm>
<form.Form>
<form.AppField
name="email"
children={field => <field.TextField label="Email address" type="email" isRequired />}
/>
<form.AppField
name="password"
children={field => (
<field.TextField label="Password" type="password" isRequired isRevealable />
)}
/>
<form.Submit className="w-full">Sign in</form.Submit>
</form.Form>
</form.AppForm>
);
};
Component library
const { fieldContext, useFieldContext, formContext, useFormContext } = createFormHookContexts();

const BoundTextField = (props: TextFieldProps) => {
const field = useFieldContext<TextFieldProps['value']>();
// todo: make better
const error = extracMessageFromError(field.state.meta.errors.at(0));

// const hasSubmitted = field.form.state.submissionAttempts > 0;
// const isInvalid = !field.state.meta.isValid && hasSubmitted;
// const errorMessage = hasSubmitted ? error : undefined;

return (
<TextField
name={field.name}
value={field.state.value}
onChange={field.handleChange}
onBlur={field.handleBlur}
// Default to showing the first error message
isInvalid={!field.state.meta.isValid}
errorMessage={error}
// Let the field handle the error state
validationBehavior="aria"
{...props}
/>
);
};

const BoundSubmit = (props: ButtonProps) => {
const form = useFormContext();
return (
<form.Subscribe selector={state => state.isSubmitting}>
{isSubmitting => <Button type="submit" isPending={isSubmitting} {...props} />}
</form.Subscribe>
);
};

const BoundForm = (props: InheritableElementProps<'form', object>) => {
const form = useFormContext();
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
event.stopPropagation();
form.handleSubmit();
};

return <form onSubmit={handleSubmit} {...props} />;
};

const { useAppForm: usePdsForm, withForm: withPdsForm } = createFormHook({
fieldComponents: {
TextField: BoundTextField
},
formComponents: {
Submit: BoundSubmit,
Form: BoundForm
},
fieldContext,
formContext
});

export { usePdsForm, withPdsForm };
const { fieldContext, useFieldContext, formContext, useFormContext } = createFormHookContexts();

const BoundTextField = (props: TextFieldProps) => {
const field = useFieldContext<TextFieldProps['value']>();
// todo: make better
const error = extracMessageFromError(field.state.meta.errors.at(0));

// const hasSubmitted = field.form.state.submissionAttempts > 0;
// const isInvalid = !field.state.meta.isValid && hasSubmitted;
// const errorMessage = hasSubmitted ? error : undefined;

return (
<TextField
name={field.name}
value={field.state.value}
onChange={field.handleChange}
onBlur={field.handleBlur}
// Default to showing the first error message
isInvalid={!field.state.meta.isValid}
errorMessage={error}
// Let the field handle the error state
validationBehavior="aria"
{...props}
/>
);
};

const BoundSubmit = (props: ButtonProps) => {
const form = useFormContext();
return (
<form.Subscribe selector={state => state.isSubmitting}>
{isSubmitting => <Button type="submit" isPending={isSubmitting} {...props} />}
</form.Subscribe>
);
};

const BoundForm = (props: InheritableElementProps<'form', object>) => {
const form = useFormContext();
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
event.stopPropagation();
form.handleSubmit();
};

return <form onSubmit={handleSubmit} {...props} />;
};

const { useAppForm: usePdsForm, withForm: withPdsForm } = createFormHook({
fieldComponents: {
TextField: BoundTextField
},
formComponents: {
Submit: BoundSubmit,
Form: BoundForm
},
fieldContext,
formContext
});

export { usePdsForm, withPdsForm };
Any help is greatly appreciated!
like-gold
like-gold4mo ago
User enter values into form User pressed submit Any invalid fields show their errors Validation is re checked on every change
Combine submissionAttemps > 0 with isTouched, isBlurred, or others (see https://tanstack.com/form/latest/docs/framework/react/guides/basic-concepts#field-state )? And get the state from the store, otherwise it might be stale.
const errors = useStore(field.store, (state) => state.meta.errors);
const errors = useStore(field.store, (state) => state.meta.errors);
First invalid field gets focused
This is not yet supported out of the box FYI, as this caused issues for others previously as well:
export type SignInFormData = z.infer<typeof signInSchema>;
export type SignInFormData = z.infer<typeof signInSchema>;
infer is the output of the Schema (it's the same as z.output) while z.input gets you the Input Type of your Schema.
extended-salmon
extended-salmonOP4mo ago
Thanks for your help! I came back to this today and made some progress, but still think I am doing something wrong. I changed my BoundTextField to this:
const BoundTextField = (props: TextFieldProps) => {
const field = useFieldContext<TextFieldProps['value']>();

const [errors, isValid] = useStore(field.store, state => [state.meta.errors, state.meta.isValid]);
const submissionAttempts = useStore(field.form.store, state => state.submissionAttempts);

const error = extracMessageFromError(errors.at(0));
const isInvalid = !isValid && submissionAttempts > 0;
const errorMessage = isInvalid ? error : undefined;

return (
<TextField
name={field.name}
value={field.state.value}
onChange={field.handleChange}
onBlur={field.handleBlur}
// Default to showing the first error message
isInvalid={isInvalid}
errorMessage={errorMessage}
// Let the field handle the error state
validationBehavior="aria"
{...props}
/>
);
};
const BoundTextField = (props: TextFieldProps) => {
const field = useFieldContext<TextFieldProps['value']>();

const [errors, isValid] = useStore(field.store, state => [state.meta.errors, state.meta.isValid]);
const submissionAttempts = useStore(field.form.store, state => state.submissionAttempts);

const error = extracMessageFromError(errors.at(0));
const isInvalid = !isValid && submissionAttempts > 0;
const errorMessage = isInvalid ? error : undefined;

return (
<TextField
name={field.name}
value={field.state.value}
onChange={field.handleChange}
onBlur={field.handleBlur}
// Default to showing the first error message
isInvalid={isInvalid}
errorMessage={errorMessage}
// Let the field handle the error state
validationBehavior="aria"
{...props}
/>
);
};
And modifed the validation to be onChange, however the submissionAttemps behaviour seems weird. If I press submit without touching anything, then it increments to 1. If i enter an invalid email then press submit, it doesn't increment (it just refocuses the text field). Due to the lack of increment, it doesn't show the errors. Any ideas? Looked for additional settings (including adding onSubmit validation too)
like-gold
like-gold4mo ago
If i enter an invalid email then press submit, it doesn't increment (it just refocuses the text field). Due to the lack of increment, it doesn't show the errors.
That should not be the case… Can you create a reproduction of this on Stackblitz or CodeSandbox?
conscious-sapphire
conscious-sapphire3mo ago
I couldn't get this to work either. I like tanstack form and tanstack in general but this library has felt a bit lackluster and a lot of things we have to hack around. The method they suggested was
validators: {
onChange: ({ formApi }) =>
formApi.state.submissionAttempts > 0
? formApi.parseValuesWithSchema(formSchema)
: undefined,
onSubmit: ({ formApi }) =>
formApi.state.submissionAttempts === 0
? formApi.parseValuesWithSchema(formSchema)
: undefined,
},
validators: {
onChange: ({ formApi }) =>
formApi.state.submissionAttempts > 0
? formApi.parseValuesWithSchema(formSchema)
: undefined,
onSubmit: ({ formApi }) =>
formApi.state.submissionAttempts === 0
? formApi.parseValuesWithSchema(formSchema)
: undefined,
},
Never mind, after some more digging I found a solution Nonetheless, it does still feel "hacky"
rival-black
rival-black3mo ago
We may or may not have started working on a solution to this problem that will only require a single LOC change on useForm 😇 Not too much to share right now since we're still prototyping, but we're aware of this issue This is the interim solution for now tho, yes
conscious-sapphire
conscious-sapphire3mo ago
huge W when you guys pull it off 🙏🙏

Did you find this page helpful?