Building forms in React is straightforward for simple cases, but production-grade forms need validation, accessibility, error handling, and good UX. This article covers how to build forms properly using react-hook-form and zod, the most common pairing in the React ecosystem.
How React Forms Work (The Basics)
React doesn't have a built-in form abstraction like Angular's Reactive Forms. You have two approaches:
- Controlled inputs - You store each field's value in
useStateand update it on every keystroke. Works but causes re-renders on every keypress and doesn't scale. - Uncontrolled inputs - The DOM holds the values, React reads them when needed (via refs). More performant, fewer re-renders.
react-hook-form uses the uncontrolled approach internally, which is why it's fast. There's no useState per field, no re-render on every keystroke. The library reads values directly from the DOM via refs.
For simple forms (contact, login), React 19's useActionState + server actions is sufficient. For complex forms (multi-step, dynamic fields, rich validation), react-hook-form + zod is the standard.
Architecture
lib/schemas/contact.ts <- Zod schema (shared validation rules)
app/actions/contact.ts <- Server action (server-side validation)
components/sections/Contact.tsx <- Form UI (client-side validation + UX)
The schema is the single source of truth. Both client and server import it, so validation rules are never out of sync.
Step 1: Define the Schema
import { z } from 'zod';
export const contactSchema = z.object({
name: z
.string()
.trim()
.min(2, 'Name must be at least 2 characters.')
.max(100, 'Name must be under 100 characters.'),
email: z
.string()
.trim()
.email('Please enter a valid email address.'),
message: z
.string()
.trim()
.min(10, 'Message must be at least 10 characters.')
.max(5000, 'Message must be under 5000 characters.'),
});
export type ContactFormData = z.infer<typeof contactSchema>;
export interface ContactFormResponse {
success: boolean;
error: string | null;
}
Key things happening here:
z.infer<typeof contactSchema>generates the TypeScript type automatically from the schema. You never manually write{ name: string; email: string; message: string }. If you add a field to the schema, the type updates everywhere..trim()runs before validation, so" "(whitespace only) fails the.min(2)check.- The second argument to
.min(),.email(), etc. is the error message shown to users. ContactFormResponseis the shared return type for the server action, used by both the action and the component.
Step 2: Connect react-hook-form to zod
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm<ContactFormData>({
resolver: zodResolver(contactSchema),
mode: 'onTouched',
});
useForm<ContactFormData> - The generic parameter gives you type safety. Writing register('nam') would be a TypeScript error because 'nam' isn't a key in ContactFormData.
resolver: zodResolver(contactSchema) - This is the bridge between the two libraries. When the form validates, it runs contactSchema.safeParse(formValues) under the hood and maps any zod errors back to react-hook-form's errors object.
mode: 'onTouched' - Controls when validation runs:
'onSubmit'(default) - Only on submit'onBlur'- When any field loses focus'onTouched'- When a field loses focus, but only fields the user has interacted with'onChange'- On every keystroke (expensive, annoying)'all'- Both onChange and onBlur
'onTouched' is the sweet spot. It won't show "Name is required" before the user even touches the field, but once they tab away from a field, it validates immediately.
Step 3: The register Function
<input {...register('name')} />
register('name') returns an object like:
{
name: 'name', // for FormData
ref: callbackRef, // react-hook-form attaches to the DOM node
onChange: handleChange, // updates internal form state
onBlur: handleBlur, // triggers validation in onTouched mode
}
You spread it onto the input. This is why react-hook-form is fast, it uses uncontrolled inputs. No useState per field, no re-render per keystroke.
Important: If you also need a custom onChange, you need to call both:
const { onChange, onBlur, ref, name } = register('name');
<input
name={name}
ref={ref}
onChange={(e) => {
onChange(e); // let react-hook-form track the value
myCustomHandler(e); // your custom logic
}}
onBlur={onBlur}
/>
Step 4: The errors Object
const { formState: { errors } } = useForm<ContactFormData>({...});
errors is shaped like your form data but contains error information:
// When name and email have errors:
{
name: { message: 'Name must be at least 2 characters.', type: 'too_small' },
email: { message: 'Please enter a valid email address.', type: 'invalid_string' },
// message is undefined - no error
}
So errors.name?.message gives you the string from your zod schema, and !!errors.name tells you if the field is invalid. You use this to conditionally render error messages and apply error styling.
Step 5: handleSubmit
<form onSubmit={handleSubmit(onSubmit)}>
handleSubmit is a higher-order function. It:
- Prevents default form submission (
e.preventDefault()) - Runs validation through the zod resolver
- If validation fails, populates
errors, does NOT callonSubmit - If validation passes, calls
onSubmit(validatedData)with clean, typed data
Your onSubmit function only ever receives valid data:
const onSubmit = async (data: ContactFormData) => {
// data is guaranteed valid here
const result = await sendContactEmail(data);
};
Step 6: isSubmitting
const { formState: { isSubmitting } } = useForm({...});
This is true from the moment onSubmit is called until the async function resolves. react-hook-form tracks this automatically because onSubmit returns a Promise. You use it to:
- Disable inputs and the submit button
- Show "Sending..." text
- Prevent double submissions (react-hook-form won't call
onSubmitagain whileisSubmittingis true)
Step 7: Accessibility
This is the part most tutorials skip, but it's what makes a form production-grade:
const formId = useId();
const nameId = `${formId}-name`;
const nameErrorId = `${nameId}-error`;
<label htmlFor={nameId} className="sr-only">Name</label>
<input
id={nameId}
aria-invalid={!!errors.name}
aria-describedby={errors.name ? nameErrorId : undefined}
{...register('name')}
/>
{errors.name && (
<p id={nameErrorId} role="alert">
{errors.name.message}
</p>
)}
Here's what each piece does:
<label htmlFor={nameId}>- Screen readers announce "Name" when the input is focused.sr-onlyhides it visually if your design uses placeholders instead of visible labels.aria-invalid={!!errors.name}- Tells assistive technology "this field has an error"aria-describedby={nameErrorId}- Links the input to its error message. When the user focuses the field, the screen reader reads the error.role="alert"on the error<p>- The error is announced immediately when it appears, even if the user isn't focused on the field.role="status"on success messages - Politely announces success without interrupting the user.useId()is a new React 18 hook that generates IDs stable across server and client rendering. Never useMath.random()or incrementing counters for IDs in React, they cause hydration mismatches.
The flow for a screen reader user:
- Focus the name input, reads "Name" (from the label)
- Tab away with empty value, validation triggers
- Error appears with
role="alert", screen reader announces "Name must be at least 2 characters" - Focus back on the field, reads "Name, invalid, Name must be at least 2 characters" (from
aria-invalid+aria-describedby)
Step 8: Server-Side Validation
// app/actions/contact.ts
'use server';
export async function sendContactEmail(data: ContactFormData) {
const result = contactSchema.safeParse(data);
if (!result.success) {
return { success: false, error: result.error.issues[0]?.message };
}
// ... send email with validated result.data
return { success: true, error: null };
}
Client-side validation is for UX. Server-side validation is for security. Anyone can bypass your frontend by sending a direct request. The server action re-validates with the same zod schema, so the rules are always consistent.
Step 9: Error Handling for Network Failures
const onSubmit = async (data: ContactFormData) => {
setServerState(INITIAL_SERVER_STATE);
try {
const result = await sendContactEmail(data);
setServerState(result);
if (result.success) {
reset();
}
} catch {
setServerState({
success: false,
error: 'Something went wrong. Please check your connection and try again.',
});
}
};
The try/catch around the server action call catches network failures. Without it, if the user's connection drops mid-submission, the form would just hang with "Sending..." forever.
The Complete Flow
User types -> uncontrolled input (no re-renders)
User leaves field -> zod validates that field (onTouched mode)
-> errors object updates
-> error message renders with role="alert"
-> input gets red ring via conditional className
User clicks submit -> handleSubmit runs full validation
-> if invalid: errors populate, onSubmit never called
-> if valid: onSubmit(cleanData) called
-> isSubmitting = true, inputs disabled, button shows "Sending..."
-> server action re-validates with same schema
-> response comes back, success/error message shown
-> if success: reset() clears the form
When You Don't Need This
For a login form with two fields, useActionState + server action is perfectly fine. You don't need react-hook-form for everything. The rule of thumb:
- 1-3 simple fields, basic validation - Native React 19 (
useActionState) - Complex validation, many fields, dynamic forms, multi-step - react-hook-form + zod
The approach shown here scales to any form complexity. The same patterns (schema, register, errors, handleSubmit) work whether you have 3 fields or 30.
