React Hook Form & Formik
Hand-rolling a form with useState is fine for a login box, but it stops scaling the moment you have a dozen fields, per-field validation, async checks, and conditional sections. You end up writing the same plumbing—change handlers, error state, touched tracking, submit guards—over and over. Form libraries package all of that bookkeeping so you can describe what the form is rather than how to wire it. The two you will meet most often are React Hook Form and Formik, and pairing either with Zod gives you type-safe validation that reads like a spec.
Why reach for a library
A controlled form re-renders the whole component on every keystroke, because each character updates state. For small forms that is invisible; for large ones it becomes janky. Beyond performance, you also have to manage error messages, which fields the user has touched, whether submission is in flight, and how to reset everything. A good library centralizes that state and exposes it through a small API.
| Concern | Hand-rolled | With a library |
|---|---|---|
| Field wiring | Manual value/onChange per input | One register or <Field> call |
| Validation | Custom if checks in submit | Schema or rules, run automatically |
| Touched / dirty state | Track yourself | Provided out of the box |
| Re-render scope | Whole form on each keystroke | Isolated to changed field (RHF) |
| Submit / reset / errors | Build it all | Returned by the hook |
React Hook Form basics
React Hook Form (RHF) is the modern default. Its key idea is uncontrolled-first: instead of binding every input to state, it registers inputs with refs and reads their values from the DOM. That means typing in a field does not re-render your component, so a 50-field form stays fast. You opt into re-renders only where you actually display something, like an error message.
The core API is three pieces from useForm: register connects an input, handleSubmit wraps your submit handler and runs validation first, and formState exposes errors and status flags.
npm install react-hook-form
import { useForm } from "react-hook-form";
function SignupForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm();
async function onSubmit(data) {
await fetch("/api/signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
console.log("Submitted:", data);
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
placeholder="Email"
{...register("email", {
required: "Email is required",
pattern: { value: /^\S+@\S+$/, message: "Enter a valid email" },
})}
/>
{errors.email && <p role="alert">{errors.email.message}</p>}
<input
type="password"
placeholder="Password"
{...register("password", {
required: "Password is required",
minLength: { value: 8, message: "At least 8 characters" },
})}
/>
{errors.password && <p role="alert">{errors.password.message}</p>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Creating..." : "Create account"}
</button>
</form>
);
}
Output:
Submitted: { email: "[email protected]", password: "hunter2!" }
The spread {...register("email", rules)} returns name, ref, onChange, and onBlur, so one call fully wires the input. Validation rules live right next to the field, and handleSubmit only calls onSubmit when every field passes.
RHF reads values from the DOM by default, so you must spread
registeronto a real input. For custom components that hide the underlying input (a styled select, a date picker), wrap them with theControllercomponent instead ofregister.
Schema validation with Zod
Inline rules are convenient, but for anything serious you want one declarative schema that is the single source of truth—and ideally one that also gives you a TypeScript type for free. Zod does exactly that, and @hookform/resolvers plugs it into RHF.
npm install zod @hookform/resolvers
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const schema = z
.object({
email: z.string().email("Enter a valid email"),
password: z.string().min(8, "At least 8 characters"),
confirm: z.string(),
})
.refine((data) => data.password === data.confirm, {
message: "Passwords do not match",
path: ["confirm"],
});
type FormValues = z.infer<typeof schema>;
function Register() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({ resolver: zodResolver(schema) });
const onSubmit = (data: FormValues) => console.log(data);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("email")} placeholder="Email" />
{errors.email && <p role="alert">{errors.email.message}</p>}
<input type="password" {...register("password")} placeholder="Password" />
{errors.password && <p role="alert">{errors.password.message}</p>}
<input type="password" {...register("confirm")} placeholder="Confirm" />
{errors.confirm && <p role="alert">{errors.confirm.message}</p>}
<button type="submit">Register</button>
</form>
);
}
z.infer derives FormValues from the schema, so the form’s data shape and its validation can never drift apart. The same schema can validate on the server too, giving you end-to-end safety from one definition.
A note on Formik
Formik was the dominant React form library before hooks matured, and you will still see it in many codebases. It is controlled-first: it holds all values in state and re-renders on each change, which is simpler to reason about but heavier than RHF for large forms. It pairs with Yup (or Zod) for validation.
import { Formik, Form, Field, ErrorMessage } from "formik";
function FormikSignup() {
return (
<Formik
initialValues={{ email: "" }}
validate={(values) => {
const errors = {};
if (!values.email) errors.email = "Required";
return errors;
}}
onSubmit={(values) => console.log(values)}
>
<Form>
<Field name="email" type="email" />
<ErrorMessage name="email" component="p" />
<button type="submit">Submit</button>
</Form>
</Formik>
);
}
For new projects, prefer React Hook Form: it has fewer re-renders, a smaller bundle, and first-class TypeScript support. Reach for Formik mainly when you are working in an existing app that already standardizes on it.
Best Practices
- Default to React Hook Form for new forms; its uncontrolled model keeps large forms fast.
- Define one Zod schema per form and derive the type with
z.inferso data and validation stay in sync. - Use
Controllerfor custom or third-party inputs that do not expose a native DOM element. - Read
formState.isSubmittingto disable the submit button and prevent duplicate requests. - Show errors only after a field is touched or on submit, not on the first keystroke, for a calmer experience.
- Reuse the same schema on the client and server so validation rules live in exactly one place.
- Only adopt Formik when an existing codebase already depends on it; otherwise the lighter library wins.