💤 Minimalistic React 19 Forms with Zod validation2024-12-21
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.