💤 Minimalistic React 19 Forms with Zod validation2024-12-21

💤 Minimalistic React 19 Forms with Zod validation

Here’s a very quick way to achieve client and server validation using React 19 Forms using Zod and Next.js.

Honestly this renders react-hook-form not very useful as everything can be achieved without it.

Let’s start with the Zod schema:

import { z } from "zod";

export const formSchema = z
  .object({
    email: z.string().min(1, "Field cannot be empty").max(100).email(),
  })
  .required();

export type FormSchema = z.infer<typeof formSchema>;

Now, let’s create a few important helpers.

First one is a function to convert FormData to FormSchema object:

function formDataToObj(formData: FormData): FormSchema {
  const obj: any = {};
  for (const [key, value] of formData.entries()) {
    obj[key] = value;
  }
  return obj;
}

Next would be the validation:

export type Validation = Omit<
  // just a hint on what it resembles, fields will be different
  SafeParseReturnType<FormSchema, FormSchema>,
  "error" | "success" | "data"
> & {
  data?: FormSchema,
  success?: boolean,
  // Next.js will butcher error object, so we provide something more primitive
  errors?: typeToFlattenedError<FormSchema>["fieldErrors"],
};

export function validate(formData: FormData): Validation {
    const rawData = Object.fromEntries(formData) as FormSchema;
    const { error, data } = formSchema.safeParse(rawData);

    if (error) {
        return {
            success: false,
            data: rawData, // data is undefined if there are errors
            errors: error.flatten().fieldErrors, 
        };
    }

    return { success: true, data };
}

Now, let’s create the form:

import { Validation, validate, formSchema } from './schema';

async function sendContactForm(body: FormData): Promise<Validation> {
    'use server';
    
    const validation = validate(body);

    if (!validation.success) return validation;

    await doSomethingWith(validation.data);

    return validation;
}

export function Form() {
    const [{ errors, success, data }, action, isPending] = useActionState<Validation, FormData>(async (state, data) => {
        const clientRes = validate(data);

        if (!clientRes.success) return clientRes; // immediately return

        return sendContactForm(data);
    }, {});

    if (success) {
        return <div>Thank you!</div>;
    }

    return (
        <form action={action} noValidate={true}>
            {errors && <div>Please fix the errors</div>}

						<fieldset>
							<legend>
							  <label htmlFor="email">Email:</label>
	   				  </legend>
							<input 
							  type="email" 
							  name="email" 
							  id="email" 
							  required={formSchema.shape.email.minLength > 0}
							  maxLength={formSchema.shape.email.maxLength}
							  defaultValue={data.email}
							/>
							{errors?.email && <div>{errors?.email.join(', ')}</div>}
						</fieldset>

            <div>
                <button type="submit" disabled={isPending} />
            </div>
        </form>
    );
}

And this is it!

💡 During my research I already wrote the draft of this condensed article, and then decided to do one more round of googling for similar ones. And found this: https://rdrn.me/react-forms/#client-validation. To my surprise, the approach was almost identical to what I had came up with, except lack of client validation. I made a suggestion and author accepted, so please consider reading the full article for more details and explanations.