Skip to content

Instantly share code, notes, and snippets.

@pom421
Last active December 22, 2023 16:28
Show Gist options
  • Save pom421/ea34eeb778b0d94fe85352dc27aada96 to your computer and use it in GitHub Desktop.
Save pom421/ea34eeb778b0d94fe85352dc27aada96 to your computer and use it in GitHub Desktop.
Form with React Hook form and zod rules (Next.js page example)
// try it : https://codesandbox.io/s/sample-next-ts-rhf-zod-9ieev
import React from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import type { FieldError } from "react-hook-form";
// JSON.stringify(error) will not work, because of circulare structure. So we need this helper.
const formatErrors = (errors: Record<string, FieldError>) =>
Object.keys(errors).map((key) => ({
key,
message: errors[key].message
}));
/* ---------- Some UI components ----------*/
type AlertType = "error" | "warning" | "success";
// Global Alert div.
const Alert = ({ children, type }: { children: string; type: AlertType }) => {
const backgroundColor =
type === "error" ? "tomato" : type === "warning" ? "orange" : "powderBlue";
return <div style={{ padding: "0 10", backgroundColor }}>{children}</div>;
};
// Use role="alert" to announce the error message.
const AlertInput = ({ children }: { children: React.ReactNode }) =>
Boolean(children) ? (
<span role="alert" style={{ color: "tomato" }}>
{children}
</span>
) : null;
/* ---------- Zod schema et TS type ----------*/
const titles = ["Mr", "Mrs", "Miss", "Dr"] as const; // as const is mandatory to get litteral types in UserType.
// Describe the correctness of data's form.
const userSchema = z
.object({
firstName: z.string().max(36),
lastName: z
.string()
.min(1, { message: "The lastName is required." })
.max(36),
mobileNumber: z.string().min(10).max(13).optional(),
email: z
.string()
.min(1, "The email is required.")
.email({ message: "The email is invalid." }),
confirmEmail: z.string().min(1, "The email is required."),
// At first, no option radio are checked so this is null. So the error is "Expected string, received null".
// So we need to accept first string or null, in order to apply refine to set a custom message.
isDeveloper: z.union([z.string(), z.null()]).refine((val) => val !== null, {
message: "Please, make a choice!"
}),
title: z.enum(titles),
age: z
.number({ invalid_type_error: "Un nombre est attendu" })
.int()
.refine((val) => val >= 18, { message: "Vous devez être majeur" })
})
// The refine method is used to add custom rules or rules over multiple fields.
.refine((data) => data.email === data.confirmEmail, {
message: "Emails don't match.",
path: ["confirmEmail"] // Set the path of this error on the confirmEmail field.
});
// Infer the TS type according to the zod schema.
type UserType = z.infer<typeof userSchema>;
/* ---------- React component ----------*/
export default function App() {
const {
register,
handleSubmit,
watch,
formState: { errors, isSubmitting, isSubmitted, isDirty, isValid }
} = useForm<UserType>({
mode: "onChange",
resolver: zodResolver(userSchema), // Configuration the validation with the zod schema.
defaultValues: {
isDeveloper: undefined,
mobileNumber: "666-666666",
firstName: "toto",
lastName: "titi",
email: "",
confirmEmail: "",
title: "Miss"
}
});
// The onSubmit function is invoked by RHF only if the validation is OK.
const onSubmit = (user: UserType) => {
console.log("dans onSubmit", user);
};
return (
<>
<h1>Ajout d'un utilisateur</h1>
<p style={{ fontStyle: "italic", maxWidth: 600 }}>
This example is a demo to show the use of a form, driven with React Hook
Form and validated by zod. The example is in full Typescript.
</p>
{Boolean(Object.keys(errors)?.length) && (
<Alert type="error">There are errors in the form.</Alert>
)}
<form
onSubmit={handleSubmit(onSubmit)}
style={{ display: "flex", flexDirection: "column", maxWidth: 600 }}
noValidate
>
{/* use aria-invalid to indicate field contain error for accessiblity reasons. */}
<input
type="text"
placeholder="First name is not mandatory"
{...register("firstName")}
aria-invalid={Boolean(errors.firstName)}
/>
<AlertInput>{errors?.firstName?.message}</AlertInput>
<input
type="text"
placeholder="Last name (mandatory)"
{...register("lastName")}
aria-invalid={Boolean(errors.lastName)}
/>
<AlertInput>{errors?.lastName?.message}</AlertInput>
<input
type="text"
placeholder="Email (mandatory)"
{...register("email")}
aria-invalid={Boolean(errors.email)}
/>
<AlertInput>{errors?.email?.message}</AlertInput>
<input
type="text"
placeholder="The same email as above"
{...register("confirmEmail")}
aria-invalid={Boolean(errors.confirmEmail)}
/>
<AlertInput>{errors?.confirmEmail?.message}</AlertInput>
<input
type="tel"
placeholder="Mobile number (mandatory)"
{...register("mobileNumber")}
aria-invalid={Boolean(errors.mobileNumber)}
/>
<AlertInput>{errors?.mobileNumber?.message}</AlertInput>
<select {...register("title")} aria-invalid={Boolean(errors.title)}>
{titles.map((elt) => (
<option key={elt} value={elt}>
{elt}
</option>
))}
</select>
<label>
Âge
<input
type="number"
placeholder="Age"
{...register("age", { valueAsNumber: true })}
aria-invalid={Boolean(errors.age)}
/>
</label>
<AlertInput>{errors?.age?.message}</AlertInput>
<div>
<p>Are you a developer? (mandatory)</p>
<label>
Yes
<input {...register("isDeveloper")} type="radio" value="Yes" />
</label>
</div>
<div>
<label>
No
<input {...register("isDeveloper")} type="radio" value="No" />
</label>
</div>
<AlertInput>{errors?.isDeveloper?.message}</AlertInput>
<input type="submit" disabled={isSubmitting || !isValid} />
<pre>{JSON.stringify(formatErrors, null, 2)}</pre>
<pre>{JSON.stringify(watch(), null, 2)}</pre>
<pre>
formState ={" "}
{JSON.stringify(
{ isSubmitting, isSubmitted, isDirty, isValid },
null,
2
)}
</pre>
</form>
</>
);
}
@iltonbarbosa
Copy link

If the input is a component, how make it?

@pom421
Copy link
Author

pom421 commented Nov 30, 2022

@iltonbarbosa

With well written libraries, you just need to inject register. See this example in Chakra UI : https://chakra-ui.com/getting-started/with-hook-form.

Otherwise, you need to make a forwardRef wrapper of your component or use control API of React Hook Form. See https://react-hook-form.com/get-started/#Integratinganexistingform.

@llermaly
Copy link

llermaly commented Dec 7, 2022

nice example @pom421 , kinda offtopic, do you know how to make this work with trpc?

I'm getting this error:

Error: You're trying to use @trpc/server in a non-server environment. This is not supported by default.
    at initTRPCInner (webpack-internal:///./node_modules/@trpc/server/dist/index.mjs:823:23)
    at TRPCBuilder.create (webpack-internal:///./node_modules/@trpc/server/dist/index.mjs:797:33)
    at eval (webpack-internal:///./src/server/trpc/trpc.ts:11:72)
    at ./src/server/trpc/trpc.ts (http://localhost:3000/_next/static/webpack/pages/clients/new.c94d29ea0a4cdf91.hot-update.js:38:1)

This is my code:

import { useForm } from "react-hook-form";
import { trpc } from "../../utils/trpc";
import { zodResolver } from "@hookform/resolvers/zod";

import { clientZod } from "../../server/trpc/router/client";
import type { ClientType } from "../../server/trpc/router/client";

const CreateClientPage = () => {
  const { handleSubmit, register } = useForm<ClientType>({
    resolver: zodResolver(clientZod),
  });

  const { mutate } = trpc.client.create.useMutation();
  const onSubmit = (values: ClientType) => mutate(values);

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input type="text" placeholder="Client name" {...register("name")} />
      <input type="text" placeholder="Client email" {...register("email")} />
      <input
        type="text"
        placeholder="Client website"
        {...register("website")}
      />
      <input type="submit" value={"Create"} />
    </form>
  );
};

export default CreateClientPage;

@KrallXZ
Copy link

KrallXZ commented Dec 8, 2022

@llermaly you have to define your validation schema (clientZod) in file other than your router and the error should gone. I just came into same.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment