webdev.complete
🪣 Data Fetching & Forms
⚛️React
Lesson 87 of 117
25 min

Forms: RHF + Zod

Schema-validated forms, error UX, async submission.

Forms are the part of React that everyone reinvents and nobody gets right the first time. Controlled inputs are easy until you have ten fields, async validation, async submission, and field-level errors. Then you reach for two libraries that have become the unofficial standard: React Hook Form for the wiring and Zod for the validation.

The pain RHF solves

Vanilla React forms put every input in state. Every keystroke re-renders the form. With ten inputs and complex validation, you notice. React Hook Form uses uncontrolled inputs under the hood: it lets the DOM hold the values and only re-renders when it absolutely has to (errors, submission state). The result is fast forms with a tiny API.

The basic shape: useForm + register

tsx
import { useForm } from "react-hook-form";

function SignupForm() {
  const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm();

  async function onSubmit(data) {
    await createAccount(data);
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("email", { required: "Required" })} />
      {errors.email && <p>{errors.email.message}</p>}

      <button disabled={isSubmitting}>
        {isSubmitting ? "Creating..." : "Create"}
      </button>
    </form>
  );
}

Spread register("fieldName") onto each input. RHF wires up the ref, the onChange, the onBlur, and the value, all without making the input controlled. handleSubmit wraps your submit handler with validation and form-data collection.

Controller for custom components

registerworks for native HTML inputs. For custom components (a date picker, a select that doesn't expose a real input), use Controller:

tsx
import { Controller } from "react-hook-form";

<Controller
  name="role"
  control={control}
  defaultValue="user"
  render={({ field }) => <FancySelect {...field} options={ROLES} />}
/>

field contains value, onChange,onBlur, name, and ref. Spread them onto your component however it expects.

Zod: the schema is your validator

Hand-written validation in form handlers gets ugly fast. Zod lets you describe your data's shape as a schema. The schema validates, the schema gives you TypeScript types, and the same schema can run on the server.

ts
import { z } from "zod";

const SignupSchema = z.object({
  email: z.string().email("Not a valid email"),
  password: z.string().min(8, "At least 8 characters"),
  age: z.coerce.number().int().min(13, "Must be 13 or older"),
});

type Signup = z.infer<typeof SignupSchema>;
// { email: string; password: string; age: number }

Combine it with RHF via the @hookform/resolvers/zod adapter:

tsx
import { zodResolver } from "@hookform/resolvers/zod";

const { register, handleSubmit, formState: { errors } } = useForm({
  resolver: zodResolver(SignupSchema),
});

Now errors.email, errors.password, etc., come straight from Zod's validation, with full messages.

Share the schema with the server
Define the schema in a shared module. Import it on the client for form validation. Import the same one on the server (in your API route or server action) and call SignupSchema.parse(input) before trusting it. One source of truth, zero drift.

Field-level errors

formState.errors mirrors your schema: errors.email?.message, errors.address?.zip?.message for nested fields. Use them inline next to each input. Many UI libraries (shadcn/ui's Form, for instance) wrap this so you write even less code.

Async submission states

formState.isSubmitting is true while your submit handler is awaiting. Use it to disable the button and prevent double submits:

tsx
<button type="submit" disabled={isSubmitting}>
  {isSubmitting ? "Saving..." : "Save"}
</button>

For server errors (the form was valid but the server rejected it), use setError("fieldName", { message: "..." }) after the request fails. The error shows up exactly like a client-side validation error.

Try it: a working signup form

Field validation runs on blur (try clicking out of a field with bad input). Submission shows pending state. Server "errors" are simulated for the password "hunter2".

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

const Schema = z.object({
  email: z.string().email("Not a valid email"),
  password: z.string().min(8, "At least 8 characters"),
  age: z.coerce.number().int().min(13, "Must be 13 or older"),
});

async function fakeSignup(data) {
  await new Promise((r) => setTimeout(r, 800));
  if (data.password === "hunter2") {
    throw new Error("Common password, pick another");
  }
  return { id: 42, ...data };
}

export default function App() {
  const {
    register,
    handleSubmit,
    setError,
    reset,
    formState: { errors, isSubmitting, submitCount },
  } = useForm({ resolver: zodResolver(Schema), mode: "onBlur" });

  async function onSubmit(data) {
    try {
      const user = await fakeSignup(data);
      alert("Welcome, user #" + user.id);
      reset();
    } catch (e) {
      setError("password", { message: e.message });
    }
  }

  const inputStyle = { width: "100%", padding: 6, marginBottom: 4 };
  const errorStyle = { color: "#dc2626", fontSize: 12, marginBottom: 8 };

  return (
    <form
      onSubmit={handleSubmit(onSubmit)}
      style={{ padding: 24, fontFamily: "system-ui", maxWidth: 360 }}
    >
      <h3>Sign up</h3>

      <label>Email</label>
      <input {...register("email")} style={inputStyle} />
      {errors.email && <p style={errorStyle}>{errors.email.message}</p>}

      <label>Password</label>
      <input type="password" {...register("password")} style={inputStyle} />
      {errors.password && <p style={errorStyle}>{errors.password.message}</p>}

      <label>Age</label>
      <input type="number" {...register("age")} style={inputStyle} />
      {errors.age && <p style={errorStyle}>{errors.age.message}</p>}

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? "Creating account..." : "Create account"}
      </button>
      <p style={{ fontSize: 12, color: "#6b7280" }}>
        Submitted {submitCount} times. Try password &quot;hunter2&quot; to see a server error.
      </p>
    </form>
  );
}

Quiz

Quiz1 / 3

Why does React Hook Form avoid putting every input in state?

Recap

  • React Hook Form keeps inputs uncontrolled so the form stays fast. Spread register(name) on each input.
  • For non-native inputs, wrap with Controller.
  • Zod describes your data with a schema, gives you TS types, and validates at runtime.
  • zodResolver(schema) connects the two and produces field-level errors.
  • Share the schema with your server (or server action) for one source of truth.
  • isSubmitting for pending UI. setError for server-side rejections.
Built with Next.js, Tailwind & Sandpack.
Learn. Build. Ship.