Skip to main content

Form Validation

Assemble Web uses the zod library to validate forms. Zod is a TypeScript-first schema declaration and validation library. It is designed to be as developer-friendly as possible. The goal is to eliminate duplicate type declarations.

Benefits:

  • Zero dependencies
  • Works in Node.js and all modern browsers
  • Tiny: 8kb minified + zipped
  • Immutable: methods (e.g. .optional()) return a new instance
  • Concise, chainable interface
  • Functional approach: parse, don't validate

Usage

Create an object schema to represent your form's fields and apply desired validations.

export const loginFormSchema = ({ emailError, passwordError }) =>
z.object({
email: z.string().email({ message: emailError }).trim(),
password: z.string().min(3, { message: passwordError }).trim(),
});

Send the form values to the schema for validation. Using Zod's safeParse method ensures validation results are returned without throwing exceptions. If validation fails, you can extract and return error messages:

const onSubmitForm = (state: FormState, formData: FormData) => {
const validatedFields = loginFormSchema.safeParse({
email: formData.get('email'),
password: formData.get('password'),
});

if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
};
}

// Call the provider
};

Back in the form, you can use react-hook-form useForm hook with the zodResolver to manage the form state and handle submission logic:

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';

// […]

const {
register,
handleSubmit,
reset,
formState: { errors, isValid, touchedFields, isSubmitting },
} = useForm({
mode: 'onBlur',
resolver: zodResolver(validatedFields),
});

const emailInputStatus = useMemo(() => {
if (errors.email) {
return 'error';
}
return touchedFields.email ? 'success' : undefined;
}, [errors.email, touchedFields.email]);

// […]

const submitForm = useMemo(
async ({ email }) => {
await sendRequest(email);
// […]
// Resets the form if the process succeeds
reset();
// […]
},
[reset],
);

// […]

<form
onSubmit={(e) => {
e.preventDefault();
void handleSubmit(submitForm)(e);
}}
>
<RegularInput
inputProps={{
id: 'email',
...register('email'),
}}
// […]
status={emailInputStatus}
hint={{
displayText: errors.email?.message,
}}
/>
</form>;

useFormValidation hook

to reduce the need for some repeated code to handle all fields, useFormValidation hook can be used to simplify handling of default input field values. baseInputProps will contain the validation values for status property and displayText for hint property for default input fields.

import useFormValidation from '@/hooks/useFormValidation';
import { zodResolver } from '@hookform/resolvers/zod';

// […]

const { register, baseInputProps } = useFormValidation({
mode: 'onBlur',
resolver: zodResolver(validatedFields),
});

// […]

<RegularInput
inputProps={{
id: 'email',
...register('email'),
}}
// […]
{...baseInputProps.email}
/>;

handleSubmit from useForm is still returned and can be used, or pass an onSubmit function to useFormValidation and use submitHandler instead to remove some complexity from consuming code -

import useFormValidation, { OnSubmitType } from '@/hooks/useFormValidation';
import { zodResolver } from '@hookform/resolvers/zod';

// […]

const {
register,
submitHandler,
// handleSubmit,
baseInputProps
} = useFormValidation({
resolver: zodResolver(validatedFields),
/**
* type inference will work here when passing
* a function directly, so email will be a string
* as expected.
* or use useCallback<OnSubmitType<typeof
* validatedFields>>(({ email }) => {}, []);
* to define onSubmit alone before calling the
* hook and infer the typing.
*/
onSubmit: ({ email }) => {
// […]
}
});

<form onSubmit={submitHandler}>

mode

useForm will allow setting mode to control when validation is triggered.

useFormValidation will still allow setting mode value, but if no value is set then useFormValidation will do some additional handling to provide sane ux -

  • base behavior is same as onTouched, so field is only validated on blur first time, then onChange for additional edits so validation happens on every change. this will avoid validation error displaying while typing value first time.
  • main difference with useFormValidation is that once all fields have been touched or changed, then all fields will be validated any time one field changes.
    • this will make validation of fields that depends on values of other fields work as expected. common example is that "password" and "confirm password" field values must match, so this will be updated as expected while typing.
    • this also means that once all fields have been entered and user is typing in the last field and the last field is valid, then the whole form is being validated on each change instead of after removing focus from the last field, so the isValid flag from the hook will be updated while typing the last field so the ui can be updated as expected, commonly setting submit button to enabled based on flag.
    • errors will still contain all errors whenever validation is triggered, but baseInputProps will only set error values to touched fields, to retain the behavior that error is only displayed after the field has been unfocused once, but the isValid flag is still allowed to update to reflect the validation of the whole form while typing.