Forms Without Tears
Forms are where backend engineers' patience with frontend collapses. You know how to validate on the server, but the browser wants inline feedback. You want a clean payload, but you get nested field state. Forms in React have a reputation for being ceremonious — and they are, if you do them wrong.
The good news: there is a correct approach that makes forms feel as mechanical as writing a DTO and a validator on the server side. Here is the pattern.
Controlled vs Uncontrolled: The Core Choice
An uncontrolled input stores its value in the DOM, not in React state. You read it when you need it (usually on submit) via a ref:
function UncontrolledForm() {
const inputRef = useRef<HTMLInputElement>(null);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const value = inputRef.current?.value;
// send value to server
}
return (
<form onSubmit={handleSubmit}>
<input ref={inputRef} defaultValue="" />
<button type="submit">Submit</button>
</form>
);
}A controlled input stores its value in React state. Every keystroke updates state, which re-renders the input:
function ControlledInput() {
const [value, setValue] = useState("");
return (
<input
value={value}
onChange={e => setValue(e.target.value)}
/>
);
}When to use which:
| Controlled | Uncontrolled |
|---|---|
| Inline validation on every keystroke | Grab value only on submit |
| Dynamic field enabling/disabling | File inputs (always uncontrolled) |
| Transforming input in real time | Simple one-off forms |
| Complex multi-step forms |
The Right Abstraction: React Hook Form + Zod
Rolling controlled forms by hand is the equivalent of parsing JSON with string manipulation. Use React Hook Form for form orchestration and Zod for schema validation — the same way you would use a DTO class with class-validator on the server.
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
// Define the schema — this is your DTO
const signupSchema = z.object({
email: z.string().email("Enter a valid email"),
password: z.string().min(8, "At least 8 characters"),
confirmPassword: z.string(),
}).refine(
data => data.password === data.confirmPassword,
{ message: "Passwords must match", path: ["confirmPassword"] }
);
type SignupForm = z.infer<typeof signupSchema>;
function SignupForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<SignupForm>({
resolver: zodResolver(signupSchema),
});
async function onSubmit(data: SignupForm) {
// data is fully typed and validated — send to server
await fetch("/api/signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<input type="email" {...register("email")} placeholder="Email" />
{errors.email && <span>{errors.email.message}</span>}
</div>
<div>
<input type="password" {...register("password")} placeholder="Password" />
{errors.password && <span>{errors.password.message}</span>}
</div>
<div>
<input type="password" {...register("confirmPassword")} placeholder="Confirm" />
{errors.confirmPassword && <span>{errors.confirmPassword.message}</span>}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Creating account…" : "Sign Up"}
</button>
</form>
);
}React Hook Form uses uncontrolled inputs internally (via refs) for performance, but exposes a controlled API via register. You get the performance of uncontrolled with the ergonomics of controlled. The Zod schema gives you the same validation logic you can share with your backend.
Validation Timing Strategy
Setting server-side validation errors back into the form after submission:
async function onSubmit(data: SignupForm) {
const res = await fetch("/api/signup", {
method: "POST",
body: JSON.stringify(data),
});
if (!res.ok) {
const { errors: serverErrors } = await res.json();
// Map server field errors back to the form
for (const [field, message] of Object.entries(serverErrors)) {
setError(field as keyof SignupForm, { message: message as string });
}
return;
}
router.push("/dashboard");
}Handling File Uploads
File inputs are always uncontrolled. Use register and read via watch or the native value:
const fileSchema = z.object({
avatar: z
.custom<FileList>()
.refine(files => files?.length === 1, "Please select a file")
.refine(
files => files?.[0]?.size < 2 * 1024 * 1024,
"File must be under 2MB"
),
});
// In the form
<input type="file" accept="image/*" {...register("avatar")} />
{errors.avatar && <span>{errors.avatar.message}</span>}
// On submit
async function onSubmit(data) {
const formData = new FormData();
formData.append("avatar", data.avatar[0]);
await fetch("/api/upload", { method: "POST", body: formData });
}Multi-Step Forms
Multi-step forms are just one big schema split across components with shared form state:
const schema = z.object({
step1: z.object({ name: z.string().min(1), email: z.string().email() }),
step2: z.object({ plan: z.enum(["free", "pro"]) }),
});
function MultiStepForm() {
const [step, setStep] = useState(1);
const methods = useForm({ resolver: zodResolver(schema) });
async function nextStep() {
const isValid = await methods.trigger(`step${step}` as any);
if (isValid) setStep(s => s + 1);
}
return (
<FormProvider {...methods}>
{step === 1 && <Step1 />}
{step === 2 && <Step2 />}
<button onClick={nextStep}>Next</button>
</FormProvider>
);
}FormProvider is React Context specifically for forms — child components call useFormContext() to access the parent form without prop drilling.
Key Takeaways
- Controlled inputs are right for inline validation and dynamic interactions; uncontrolled inputs (via refs) are right for file fields and simple read-on-submit forms.
- React Hook Form + Zod replicates the DTO + validator pattern you know from server-side code, with shared schema logic that can span client and server.
- Default to
onBlurvalidation mode — it balances immediate feedback without firing on every keystroke. - Server validation errors should be mapped back to form fields after a failed submission, not shown as a generic toast.
- File inputs are always uncontrolled — wrap them in
FormDataon submit rather than trying to store the FileList in React state. - Multi-step forms share a single form instance via
FormProvider; trigger per-step validation before advancing to catch errors early.