Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Potential Feature: Add a Way to Skip Browser Validation with the FormValidityObserver #7

Open
ITenthusiasm opened this issue Apr 26, 2024 · 1 comment

Comments

@ITenthusiasm
Copy link
Member

ITenthusiasm commented Apr 26, 2024

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 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` */
function nonEmptyString<T extends z.ZodTypeAny>(schema: T) {
  return z.preprocess((v) => (v === "" ? undefined : v), schema);
}

const schema = 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";
import type { ActionFunction } from "@remix-run/node";
import { z } from "zod";

// Zod Schema setup omitted for brevity ...

// Note: We've excluded a success response for brevity
export const action = (async ({ request }) => {
  const formData = Object.fromEntries(await request.formData());
  const result = schema.safeParse(formData);

  if (result.error) {
    return json(result.error.flatten());
  }
}) satisfies ActionFunction;

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";
import type { 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 ...

type FieldNames = keyof (typeof schema)["shape"];


// Note: We're omitting the definition for a `handleSubmit` function here to make the problem more obvious.
export default function SignupForm() {
  const serverErrors = useActionData<typeof action>();
  const [errors, setErrors] = useState(serverErrors);
  useEffect(() => setErrors(serverErrors), [serverErrors]);

  const { autoObserve } = useMemo(() => {
    return createFormValidityObserver("input", {
      renderByDefault: true,
      renderer(errorContainer, errorMessage) {
        const name = errorContainer.id.replace(/-error$/, "") as FieldNames;

        setErrors((e) =>
          e
            ? { ...e, fieldErrors: { ...e.fieldErrors, [name]: errorMessage } }
            : { formErrors: [], fieldErrors: { [name]: errorMessage } }
        );
      },
    });
  }, []);

  return (
    <Form ref={useMemo(autoObserve, [autoObserve])} method="post">
      <label htmlFor="username">Username</label>
      <input id="username" name="username" type="text" minLength={5} required aria-describedby="username-error" />
      <div id="username-error" role="alert">
        {errors?.fieldErrors.username}
      </div>

      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" required aria-describedby="email-error" />
      <div id="email-error" role="alert">
        {errors?.fieldErrors.email}
      </div>

      {/* Other Fields ... */}
      <button type="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...

// Add Zod Validation to `createFormValidityObserver` configuration
const { autoObserve } = useMemo(() => {
  return createFormValidityObserver("input", {
    renderByDefault: true,
    renderer(errorContainer, errorMessage) {
      const name = errorContainer.id.replace(/-error$/, "") as FieldNames;

      setErrors((e) =>
        e
          ? { ...e, fieldErrors: { ...e.fieldErrors, [name]: errorMessage } }
          : { formErrors: [], fieldErrors: { [name]: errorMessage } }
      );
    },
    defaultErrors: {
      validate(field: HTMLInputElement) {
        const result = schema.shape[field.name as FieldNames].safeParse(field.value);
        if (result.success) return;
        return result.error.issues[0].message;
      },
    },
  });
}, []);

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.

export default function SignupForm() {
  // Setup ...

  return (
    <Form ref={useMemo(autoObserve, [autoObserve])} method="post">
      <label htmlFor="username">Username</label>
      <input id="username" name="username" type="text" aria-describedby="username-error" />
      <div id="username-error" role="alert">
        {errors?.fieldErrors.username}
      </div>

      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="text" aria-describedby="email-error" />
      <div id="email-error" role="alert">
        {errors?.fieldErrors.email}
      </div>

      {/* Other Fields ... */}
      <button type="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.

export default function SignupForm() {
  // Other Setup ...

  const { autoObserve, configure } = useMemo(() => {
    return createFormValidityObserver("input", {
      /* Configuration Options without Zod */
    });
  }, []);

  return (
    <Form ref={useMemo(autoObserve, [autoObserve])} method="post">
      <label htmlFor="username">Username</label>
      <input
        id="username"
        type="text"
        aria-describedby="username-error"
        {...configure("username", {
          required: "Username is required",
          minlength: { value: 5, message: "Minimum length is 5 characters" },
        })}
      />
      <div id="username-error" role="alert">
        {errors?.fieldErrors.username}
      </div>

      <label htmlFor="email">Email</label>
      <input
        id="email"
        aria-describedby="email-error"
        {...configure("email", {
          required: "Email is required",
          type: { value: "email", message: "Email is not valid" },
        })}
      />
      <div id="email-error" role="alert">
        {errors?.fieldErrors.email}
      </div>

      {/* Other Fields ... */}
      <button type="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.

const { autoObserve, configure } = useMemo(() => {
  return createFormValidityObserver("input", {
    skipBrowserValidation: true,
    renderByDefault: true,
    renderer(errorContainer, errorMessage) {
      const name = errorContainer.id.replace(/-error$/, "") as FieldNames;

      setErrors((e) =>
        e
          ? { ...e, fieldErrors: { ...e.fieldErrors, [name]: errorMessage } }
          : { formErrors: [], fieldErrors: { [name]: errorMessage } }
      );
    },
    defaultErrors: {
      validate(field: HTMLInputElement) {
        const result = schema.shape[field.name as FieldNames].safeParse(field.value);
        if (result.success) return;
        return result.error.issues[0].message;
      },
    },
  });
}, []);

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:

<Form ref={useMemo(autoObserve, [autoObserve])} method="post">
  <label htmlFor="username">Username</label>
  <input id="username" name="username" type="text" minLength={5} required aria-describedby="username-error" />
  <div id="username-error" role="alert">
    {errors?.fieldErrors.username}
  </div>

  <label htmlFor="email">Email</label>
  <input id="email" name="email" type="email" required aria-describedby="email-error" />
  <div id="email-error" role="alert">
    {errors?.fieldErrors.email}
  </div>

  {/* Other Fields ... */}
  <button type="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:

<input 
  id="username"
  name="username"
  type="text"
  minLength={schema.shape.username._def.schema.minLength}
  required={!schema.shape.username._def.schema.isOptional()}
  aria-describedby="username-error"
/>

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.

@ITenthusiasm
Copy link
Member Author

If the community shows interest in this feature, I will more strongly consider supporting it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant