You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Currently, tools like Zod are very popular for server-side validation in JavaScript applications. Because the tool is not restricted to the backend, many people are also using it on the frontend to validate their form data. It is already possible to use Zod with the FormValidityObserver. But we'd like to make the developer experience a little more streamlined for developers working on full-stack JavaScript applications.
The Problem
Consider a scenario where a developer is using a simple Zod Schema to validate a user signup form in a Remix application. (Remix is a "full-stack React" framework superior to Next.js.) They might have a schema that looks something like this:
import{z}from"zod";/** Marks empty strings from `FormData` values as `undefined` */functionnonEmptyString<Textendsz.ZodTypeAny>(schema: T){returnz.preprocess((v)=>(v==="" ? undefined : v),schema);}constschema=z.object({username: nonEmptyString(z.string({required_error: "Username is required"}).min(5,"Minimum length is 5 characters")),email: nonEmptyString(z.string({required_error: "Email is required"}).email("Email is not valid")),// Other fields ...});
A simple Remix Action that validates a form's data might look like this:
import{json}from"@remix-run/node";importtype{ActionFunction}from"@remix-run/node";import{z}from"zod";// Zod Schema setup omitted for brevity ...// Note: We've excluded a success response for brevityexportconstaction=(async({ request })=>{constformData=Object.fromEntries(awaitrequest.formData());constresult=schema.safeParse(formData);if(result.error){returnjson(result.error.flatten());}})satisfiesActionFunction;
That's just about everything we need on the backend. On the frontend, a simple usage of the FormValidityObserver in Remix might look something like this:
import{json}from"@remix-run/node";importtype{ActionFunction}from"@remix-run/node";import{Form,useActionData}from"@remix-run/react";import{useState,useEffect,useMemo}from"react";import{createFormValidityObserver}from"@form-observer/react";import{z}from"zod";// Setup for the backend omitted for brevity ...typeFieldNames=keyof(typeofschema)["shape"];// Note: We're omitting the definition for a `handleSubmit` function here to make the problem more obvious.exportdefaultfunctionSignupForm(){constserverErrors=useActionData<typeofaction>();const[errors,setErrors]=useState(serverErrors);useEffect(()=>setErrors(serverErrors),[serverErrors]);const{ autoObserve }=useMemo(()=>{returncreateFormValidityObserver("input",{renderByDefault: true,renderer(errorContainer,errorMessage){constname=errorContainer.id.replace(/-error$/,"")asFieldNames;setErrors((e)=>e
? { ...e,fieldErrors: { ...e.fieldErrors,[name]: errorMessage}}
: {formErrors: [],fieldErrors: {[name]: errorMessage}});},});},[]);return(<Formref={useMemo(autoObserve,[autoObserve])}method="post"><labelhtmlFor="username">Username</label><inputid="username"name="username"type="text"minLength={5}requiredaria-describedby="username-error"/><divid="username-error"role="alert">{errors?.fieldErrors.username}</div><labelhtmlFor="email">Email</label><inputid="email"name="email"type="email"requiredaria-describedby="email-error"/><divid="email-error"role="alert">{errors?.fieldErrors.email}</div>{/* Other Fields ... */}<buttontype="submit">Submit</button></Form>);}
Although this code is functional, it results in an inconsistent user experience. When a user submits the form, they'll get Zod's error messages for the various form fields. But when a user interacts with the form's fields, then they'll get the browser's error messages instead. This isn't the end of the world, but it's certainly odd and undesirable. (We don't have a submit handler that enforces client-side validation yet. This is intentional to show the issue that we're describing.)
So, to bring in some consistency, we can try to add Zod to the frontend...
But you'll notice that this change actually doesn't do anything. Why? Because the browser's validation is always run before custom validation functions. So the browser's error messages are still displayed instead of Zod's on the client side. This means that we still have inconsistency between the displayed client errors and the displayed server errors.
Workarounds
There are 2 [undesirable] workarounds for this problem.
1) Omit Validation Attributes from the Form Fields
One option is to remove any attributes that would cause the browser to attempt form field validation. This would cause Zod to be responsible for all error messages displayed to the client.
exportdefaultfunctionSignupForm(){// Setup ...return(<Formref={useMemo(autoObserve,[autoObserve])}method="post"><labelhtmlFor="username">Username</label><inputid="username"name="username"type="text"aria-describedby="username-error"/><divid="username-error"role="alert">{errors?.fieldErrors.username}</div><labelhtmlFor="email">Email</label><inputid="email"name="email"type="text"aria-describedby="email-error"/><divid="email-error"role="alert">{errors?.fieldErrors.email}</div>{/* Other Fields ... */}<buttontype="submit">Submit</button></Form>);}
You'll notice that here, all validation attributes have been removed from our form controls. Additionally, the [type="email"] form control has been changed to [type="text"] to prevent the browser from trying to validate it as an email field. Now the error messages that get displayed on the client side are the exact same as the messages that get returned from the server side.
However, with this new implementation, there is no longer any client-side validation when JavaScript is unavailable. Since users without JavaScript can still submit their forms to our server, our server can still render a new page for them that has the error messages. That's good! However, this increases the chattiness between the client and the server. This also implies that the overall amount of time that the user will spend to submit a successful form will be larger (assuming that they don't perfectly fill it out the first time).
2) Duplicate Error Message Information on the Client Side
Another option is to configure the error messages on the client side to match the error messages on the server side. In addition to synchronizing our client-side errors with our server-side errors, this approach will allow us to keep our validation attributes in our form. This means that users without JavaScript will still be able to see some error messages without having to submit anything to server.
exportdefaultfunctionSignupForm(){// Other Setup ...const{ autoObserve, configure }=useMemo(()=>{returncreateFormValidityObserver("input",{/* Configuration Options without Zod */});},[]);return(<Formref={useMemo(autoObserve,[autoObserve])}method="post"><labelhtmlFor="username">Username</label><inputid="username"type="text"aria-describedby="username-error"{...configure("username",{required: "Username is required",minlength: {value: 5,message: "Minimum length is 5 characters"},})}/><divid="username-error"role="alert">{errors?.fieldErrors.username}</div><labelhtmlFor="email">Email</label><inputid="email"aria-describedby="email-error"{...configure("email",{required: "Email is required",type: {value: "email",message: "Email is not valid"},})}/><divid="email-error"role="alert">{errors?.fieldErrors.email}</div>{/* Other Fields ... */}<buttontype="submit">Submit</button></Form>);}
Great! We have everything that we need now! Now there are no inconsistencies between the client/server (when JS is available), and users without JS can still get form errors without making too many requests (if any) to our server.
However, this approach is more verbose. You'll notice that now we have to use the configure function to tell the browser which error messages to use when field validation fails. More importantly, we have to duplicate the error messages between our client an our server. For example, the string "Email is required" is written once for our schema and another time for our configure("email", /* ... */) call. To deduplicate our error messages, we could create a local errorMessages object that both the schema and the configure() calls could use. But this causes the boilerplate in our file to get a little larger.
The Solution
The ideal solution to this problem is to provide a way for users to skip the browser's validation without having to remove the validation attributes from their form controls.
This option would delegate all validation logic to the custom validation function. (More accurately, it makes the custom validation function the only "agent" that can update the error messages displayed in the UI.) But it would not require developers to remove the validation attributes from their form fields (meaning that users without JavaScript still get some client-side validation). So developers would get to keep their markup small -- just like it was in the beginning of our example:
<Formref={useMemo(autoObserve,[autoObserve])}method="post"><labelhtmlFor="username">Username</label><inputid="username"name="username"type="text"minLength={5}requiredaria-describedby="username-error"/><divid="username-error"role="alert">{errors?.fieldErrors.username}</div><labelhtmlFor="email">Email</label><inputid="email"name="email"type="email"requiredaria-describedby="email-error"/><divid="email-error"role="alert">{errors?.fieldErrors.email}</div>{/* Other Fields ... */}<buttontype="submit">Submit</button></Form>
Counterarguments
There are three counter arguments to the solution provided above.
1) The error messages will still be inconsistent...
If the desire is to provide client-side validation for users who lack JavaScript without requiring them to interact with the server, then the concern of "inconsistent error messages" inevitably appears again. As of today, browsers do not provide a way to override their native error messages without JavaScript. Consequently, with this solution, there will still be a set of users who see "Browser Error Messages" and a different set of users who see "JavaScript/Zod/Server Error Messages".
2) The concern of inconsistent error messages largely goes away if we add a submission handler.
In our example, we didn't have a submission handler. But client-side validation really only makes sense if we're going to block invalid form submissions. In that case, the client should rarely (if ever) encounter situations where they see inconsistent messages between the client and the server -- at least during a given user session. For example, if the server has the error message, "Field is required" and the client has the error message, "Username is required", then once the field is given a value, the server will never respond with a "Field is required" error. Therefore, the user won't see 2 different messages for the same error.
In this case, does inconsistency really matter that much? (It might. But this is still worth asking.) For the solution that we're suggesting, there are already going to be inconsistencies between users who have access to JS and users who do not (because the browser's error messages are not configurable). So again, the inconsistency concern is never fully resolved.
3) It's possible to argue that accessibility is improved if users without JavaScript get error messages from the server instead of getting them from the browser.
A browser can only display an error message for 1 form field at a time. When a user submits an invalid form, the first invalid field displays an error message in a "bubble". After all of the field's errors are resolved, the bubble goes away. But in order for the user to figure out what else is wrong with the form, they have to submit it again. Yes, this can be as easy as pressing the "Enter" key, but it can still be annoying. Additionally, the error bubbles that browsers provide typically won't look as appealing as a custom UI.
By contrast, the server has the ability to return all of the [current] error messages for the form's fields simultaneously. This means that users will know everything else they should fix before resubmitting the form. This user experience is arguably better, and typically prettier (if the error messages have good UI designs). In that case, the first workaround that we showed earlier might be preferable.
Mind you, the server will only respond with current error messages. If an empty email is submitted when the field is required, then the server will first respond with, "Email is required". If an invalid email is supplied afterwards, then the server will respond with, "Email is not valid". On a per-field basis, this is less efficient than what the browser provides out of the box. (Sometimes Zod can help circumvent this limitation, but not always.)
Basically, it's not always clear whether users without JavaScript should be given the browser's validation/messaging experience or the server's validation/messaging experience for forms. Currently, we're stuck with a set of tradeoffs. (Maybe browsers can provide a standard that would resolve this in the future?) And those tradeoffs could pose a reason not to rush to implementing this feature.
Difficulty of Implementation
Trivial
Other Notes
An Overwhelming Number of Options Is Burdensome
Ideally, our FormValidityObserver utility won't have 50 million potential options for its options object. Adding a skipBrowserValidation: boolean option probably isn't the end of the world. But I am starting to get hesitant when it comes to adding additional options. A revalidateOn option is also being considered... (Edit: This revalidateOn option has just recently been added.)
The Client's Validation Is Always a Subset of the Server's Validation
The server ultimately decides all of the validation logic that will be necessary for a given form. The client simply replicates (or reuses) that logic for the sake of user experience. The only way that the client should differ from the server is if it contains only a subset of the server's validation logic. (For instance, the client cannot determine on its own whether a user+password combination is valid. Help from the server will always be needed for this.) Consequently, if developers want to do so, it is appropriate/safe for them to skip the browser's validation and delegate all logic to the Zod schema used on the server.
Sidenote: Constraints Can Be Extracted from Zod Schemas
This is likely something that we won't explore. But if someone was interested in leveraging this information, it's possible:
This technically relates to our concerns in this Issue since we're interested in minimizing duplication of values, but it's more related to constraints than it is to error messages. And error messages are the greater focus here.
The text was updated successfully, but these errors were encountered:
Motivation
Currently, tools like
Zod
are very popular for server-side validation in JavaScript applications. Because the tool is not restricted to the backend, many people are also using it on the frontend to validate their form data. It is already possible to useZod
with theFormValidityObserver
. But we'd like to make the developer experience a little more streamlined for developers working on full-stack JavaScript applications.The Problem
Consider a scenario where a developer is using a simple Zod Schema to validate a user signup form in a Remix application. (Remix is a "full-stack React" framework superior to Next.js.) They might have a schema that looks something like this:
A simple Remix Action that validates a form's data might look like this:
That's just about everything we need on the backend. On the frontend, a simple usage of the
FormValidityObserver
inRemix
might look something like this:Although this code is functional, it results in an inconsistent user experience. When a user submits the form, they'll get Zod's error messages for the various form fields. But when a user interacts with the form's fields, then they'll get the browser's error messages instead. This isn't the end of the world, but it's certainly odd and undesirable. (We don't have a
submit
handler that enforces client-side validation yet. This is intentional to show the issue that we're describing.)So, to bring in some consistency, we can try to add Zod to the frontend...
But you'll notice that this change actually doesn't do anything. Why? Because the browser's validation is always run before custom validation functions. So the browser's error messages are still displayed instead of Zod's on the client side. This means that we still have inconsistency between the displayed client errors and the displayed server errors.
Workarounds
There are 2 [undesirable] workarounds for this problem.
1) Omit Validation Attributes from the Form Fields
One option is to remove any attributes that would cause the browser to attempt form field validation. This would cause Zod to be responsible for all error messages displayed to the client.
You'll notice that here, all validation attributes have been removed from our form controls. Additionally, the
[type="email"]
form control has been changed to[type="text"]
to prevent the browser from trying to validate it as an email field. Now the error messages that get displayed on the client side are the exact same as the messages that get returned from the server side.However, with this new implementation, there is no longer any client-side validation when JavaScript is unavailable. Since users without JavaScript can still submit their forms to our server, our server can still render a new page for them that has the error messages. That's good! However, this increases the chattiness between the client and the server. This also implies that the overall amount of time that the user will spend to submit a successful form will be larger (assuming that they don't perfectly fill it out the first time).
2) Duplicate Error Message Information on the Client Side
Another option is to
configure
the error messages on the client side to match the error messages on the server side. In addition to synchronizing our client-side errors with our server-side errors, this approach will allow us to keep our validation attributes in our form. This means that users without JavaScript will still be able to see some error messages without having to submit anything to server.Great! We have everything that we need now! Now there are no inconsistencies between the client/server (when JS is available), and users without JS can still get form errors without making too many requests (if any) to our server.
However, this approach is more verbose. You'll notice that now we have to use the
configure
function to tell the browser which error messages to use when field validation fails. More importantly, we have to duplicate the error messages between our client an our server. For example, the string"Email is required"
is written once for ourschema
and another time for ourconfigure("email", /* ... */)
call. To deduplicate our error messages, we could create a localerrorMessages
object that both theschema
and theconfigure()
calls could use. But this causes the boilerplate in our file to get a little larger.The Solution
The ideal solution to this problem is to provide a way for users to skip the browser's validation without having to remove the validation attributes from their form controls.
This option would delegate all validation logic to the custom validation function. (More accurately, it makes the custom validation function the only "agent" that can update the error messages displayed in the UI.) But it would not require developers to remove the validation attributes from their form fields (meaning that users without JavaScript still get some client-side validation). So developers would get to keep their markup small -- just like it was in the beginning of our example:
Counterarguments
There are three counter arguments to the solution provided above.
1) The error messages will still be inconsistent...
If the desire is to provide client-side validation for users who lack JavaScript without requiring them to interact with the server, then the concern of "inconsistent error messages" inevitably appears again. As of today, browsers do not provide a way to override their native error messages without JavaScript. Consequently, with this solution, there will still be a set of users who see "Browser Error Messages" and a different set of users who see "JavaScript/Zod/Server Error Messages".
2) The concern of inconsistent error messages largely goes away if we add a submission handler.
In our example, we didn't have a submission handler. But client-side validation really only makes sense if we're going to block invalid form submissions. In that case, the client should rarely (if ever) encounter situations where they see inconsistent messages between the client and the server -- at least during a given user session. For example, if the server has the error message, "Field is required" and the client has the error message, "Username is required", then once the field is given a value, the server will never respond with a "Field is required" error. Therefore, the user won't see 2 different messages for the same error.
In this case, does inconsistency really matter that much? (It might. But this is still worth asking.) For the solution that we're suggesting, there are already going to be inconsistencies between users who have access to JS and users who do not (because the browser's error messages are not configurable). So again, the inconsistency concern is never fully resolved.
3) It's possible to argue that accessibility is improved if users without JavaScript get error messages from the server instead of getting them from the browser.
A browser can only display an error message for 1 form field at a time. When a user submits an invalid form, the first invalid field displays an error message in a "bubble". After all of the field's errors are resolved, the bubble goes away. But in order for the user to figure out what else is wrong with the form, they have to submit it again. Yes, this can be as easy as pressing the "Enter" key, but it can still be annoying. Additionally, the error bubbles that browsers provide typically won't look as appealing as a custom UI.
By contrast, the server has the ability to return all of the [current] error messages for the form's fields simultaneously. This means that users will know everything else they should fix before resubmitting the form. This user experience is arguably better, and typically prettier (if the error messages have good UI designs). In that case, the first workaround that we showed earlier might be preferable.
Mind you, the server will only respond with current error messages. If an empty
email
is submitted when the field is required, then the server will first respond with, "Email is required". If an invalid email is supplied afterwards, then the server will respond with, "Email is not valid". On a per-field basis, this is less efficient than what the browser provides out of the box. (Sometimes Zod can help circumvent this limitation, but not always.)Basically, it's not always clear whether users without JavaScript should be given the browser's validation/messaging experience or the server's validation/messaging experience for forms. Currently, we're stuck with a set of tradeoffs. (Maybe browsers can provide a standard that would resolve this in the future?) And those tradeoffs could pose a reason not to rush to implementing this feature.
Difficulty of Implementation
Trivial
Other Notes
An Overwhelming Number of Options Is Burdensome
Ideally, our
FormValidityObserver
utility won't have 50 million potential options for itsoption
s object. Adding askipBrowserValidation: boolean
option probably isn't the end of the world. But I am starting to get hesitant when it comes to adding additional options. ArevalidateOn
option is also being considered... (Edit: ThisrevalidateOn
option has just recently been added.)The Client's Validation Is Always a Subset of the Server's Validation
The server ultimately decides all of the validation logic that will be necessary for a given form. The client simply replicates (or reuses) that logic for the sake of user experience. The only way that the client should differ from the server is if it contains only a subset of the server's validation logic. (For instance, the client cannot determine on its own whether a user+password combination is valid. Help from the server will always be needed for this.) Consequently, if developers want to do so, it is appropriate/safe for them to skip the browser's validation and delegate all logic to the Zod
schema
used on the server.Sidenote: Constraints Can Be Extracted from Zod Schemas
This is likely something that we won't explore. But if someone was interested in leveraging this information, it's possible:
This technically relates to our concerns in this Issue since we're interested in minimizing duplication of values, but it's more related to constraints than it is to error messages. And error messages are the greater focus here.
The text was updated successfully, but these errors were encountered: