diff --git a/TODO.md b/TODO.md index 05341ee..1351357 100644 --- a/TODO.md +++ b/TODO.md @@ -2,8 +2,7 @@ ## Priority -- [ ] Add docs on how to reconcile error messages between server and client. - - [ ] Consider doing this AFTER releasing `0.9.0` if you need to. +- [ ] Update `SuperTokens + Remix` and `SuperTokens + SvelteKit` examples with the stateful `errors` object approach (if it would be easier to understand/read). ## Documentation diff --git a/docs/form-validity-observer/guides.md b/docs/form-validity-observer/guides.md index 70d78a7..c835d6b 100644 --- a/docs/form-validity-observer/guides.md +++ b/docs/form-validity-observer/guides.md @@ -6,6 +6,7 @@ Here you'll find helpful tips on how to use the `FormValidityObserver` effective - [Keeping Track of Visited/Dirty Fields](#keeping-track-of-visiteddirty-fields) - [Getting the Most out of the `defaultErrors` option](#getting-the-most-out-of-the-defaulterrors-option) - [Managing Form Errors with State](#managing-form-errors-with-state) +- [Reconciling Server Errors with Client Errors in Forms](#reconciling-server-errors-with-client-errors-in-forms) - [Keeping Track of Form Data](#keeping-track-of-form-data) - [Recommendations for Conditionally Rendered Fields](#recommendations-for-conditionally-rendered-fields) - [Recommendations for Styling Form Fields and Their Error Messages](#recommendations-for-styling-form-fields-and-their-error-messages) @@ -14,8 +15,7 @@ Here you'll find helpful tips on how to use the `FormValidityObserver` effective ## Enabling Accessible Error Messages during Form Submissions @@ -357,6 +357,205 @@ Notice that we also supplied the [`renderByDefault: true`](./README.md#form-vali You can find more detailed examples of using stateful error objects on [StackBlitz](https://stackblitz.com/@ITenthusiasm/collections/form-observer-examples). +## Reconciling Server Errors with Client Errors in Forms + +It's common for server-rendered applications to have both server-side validation logic and client-side validation logic for forms. Usually, the validation logic is the same between the client and the server. But in some situations, you may have validation logic that you _only_ want to run on the server. For example, the logic for verifying that a user + password combination is correct should only be run on the server. In cases like these, you'll need a way to combine/reconcile your server-side errors with your client-side errors so that your users will know what they need to fix. + +There are multiple ways to go about this. We'll be showing 2 approaches that use [`Remix`](https://remix.run/) and [`Zod`](https://zod.dev/), and we'll be managing our error messages [with state](#managing-form-errors-with-state) (instead of manipulating the DOM directly). The examples that you see below should be easily transferrable to other frameworks (such as `SvelteKit`) and other validators (such as `yup`, or even your own server logic). If you're interested in manipulating the DOM directly (instead of using state), you're free to do that as well. + +### 1) Using `Zod` for Server-side Validation and the Browser for Client-side Validation + +In this first approach, we _will not_ use `Zod` on the frontend. Oftentimes, the browser is sufficient for running client-side validation. One of the biggest benefits of using the browser's validation logic is that it works even when your users have [no access to JavaScript](https://www.kryogenix.org/code/browser/everyonehasjs.html). In such cases, the browser will be able to tell your users how to correct their forms _without_ making roundtrips to your server. Additionally, by keeping `Zod` out of your client bundle, you save a significant amount of space (roughly 13.8 kb). + +```tsx +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"; + +/* -------------------- Browser -------------------- */ +// Note: We are omitting the definition of a `handleSubmit` function to make it +// easier to test error reconciliation between the client and the server. +export default function SignupForm() { + const serverErrors = useActionData(); + const [errors, setErrors] = useState(serverErrors); + useEffect(() => setErrors(serverErrors), [serverErrors]); + + const { autoObserve, configure } = 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 ( +
+ + + + + + + + + {/* Other Fields ... */} + +
+ ); +} + +/* -------------------- Server -------------------- */ +/** Replaces empty strings in the `FormData` with `undefined` values */ +function nonEmptyString(schema: T) { + return z.preprocess((v) => (v === "" ? undefined : v), schema); +} + +type FieldNames = keyof (typeof schema)["shape"]; +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 ... +}); + +// 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; +``` + +Although this approach does allow us to keep our client bundle smaller, you'll notice that it also results in us having to duplicate our error messages on the server and the client. One way around this problem is to create an `errorMessages` object that both the server and the client can share, like so: + +```ts +const errorMessages = { + username: { required: "Username is required", minlength: "Minimum length is 5 characters" }, + email: { required: "Email is required", format: "Email is not valid" }, +} as const; +``` + +Then, our frontend could use this object to define its error messages: + +```tsx + +``` + +And our Zod schema definition could do the same: + +```ts +const schema = z.object({ + username: nonEmptyString( + z.string({ required_error: errorMessages.username.required }).min(5, errorMessages.username.minlength), + ), + email: nonEmptyString(z.string({ required_error: errorMessages.email.required }).email(errorMessages.email.format)), +}); +``` + +This approach allows us to reduce code duplication between the client and the server with ease _while also keeping our client bundle smaller_. So it's worth considering! + +### 2) Using `Zod` Exclusively for Both Server _and_ Client-side Validation + +If you're really bothered by the idea of having to create an `errorMessages` object that both the server and the client can share, then another alternative is to just use Zod on _both_ the server _and_ the client. Be warned: _This will noticeably increase your client's JavaScript bundle size_, and it might have more impacts on performance/maintainability than you expect (especially for complex forms). Additionally, you will no longer be able to take advantage of the browser's validation logic. This means that when users of your application lack access to JavaScript, they will keep having to make roundtrips to your server to know how to fix their forms (instead of having the browser tell them immediately without making any network requests). + +Nonetheless, this approach removes the need for an `errorMessages` object. So, if that is your preferred approach, please see below. (We will only show the code for the frontend here because that is the only code that needs to change.) + +```tsx +/* -------------------- Browser -------------------- */ +// Note: We are omitting the definition of a `handleSubmit` function to make it +// easier to test error reconciliation between the client and the server. +export default function SignupForm() { + const serverErrors = useActionData(); + const [errors, setErrors] = useState(serverErrors); + useEffect(() => setErrors(serverErrors), [serverErrors]); + + const { autoObserve, configure } = 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; + }, + }, + }); + }, []); + + return ( +
+ + + + + + + + + {/* Other Fields ... */} + +
+ ); +} +``` + +In the end, it's up to you to decide how you want to handle these trade-offs. There is no "perfect" solution. + +There is potential for a third option that would allow you to pull benefits from both of the approaches shown above. However, that third option would also pull _drawbacks_ from both of those approaches. (We can never avoid the difficulties of making real trade-offs.) If you're interested in that third option being supported, feel free to comment on [this issue](https://github.com/enthusiastic-js/form-observer/issues/7). + ## Keeping Track of Form Data Many form libraries offer stateful solutions for managing the data in your forms as JSON. But there are a few disadvantages to this approach: