Building React forms with useState works for two fields. It falls apart for real forms — validation, error messages, async submission, dependent fields, dynamic lists. You write 200 lines of state management and still miss edge cases.
React Hook Form plus Zod is the stack I use for everything beyond trivial forms. It is fast, type-safe, and integrates cleanly with Next.js Server Actions. Here is the setup.
Why React Hook Form
The default React form pattern uses controlled components:
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
<input value={email} onChange={e => setEmail(e.target.value)} />Every keystroke triggers a state update and re-renders the component. For a form with 10 fields, every keystroke re-renders all 10 inputs. On large forms this is visibly slow.
React Hook Form uses uncontrolled components by default. Inputs register with the form library, which reads values directly from the DOM on submit. No re-renders on every keystroke. Performance is consistent regardless of form size.
Why Zod
Validation needs a schema. Without one, you write ad-hoc rules:
if (!email.includes('@')) errors.email = 'Invalid email'
if (password.length < 8) errors.password = 'Too short'This does not scale. Each rule needs its own line, its own error message, and a place in the submit handler. Zod replaces all of that with a schema:
import { z } from 'zod'
const schema = z.object({
email: z.string().email('Enter a valid email'),
password: z.string().min(8, 'Password must be at least 8 characters'),
})The schema is your source of truth. Use it in the form, use it in the server, use it anywhere else you need to validate the same shape.
The basic setup
Install:
npm install react-hook-form zod @hookform/resolversDefine the schema and form:
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, 'Password must be at least 8 characters'),
rememberMe: z.boolean().default(false),
})
type FormValues = z.infer<typeof schema>
export function LoginForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
email: '',
password: '',
rememberMe: false,
},
})
const onSubmit = async (data: FormValues) => {
await fetch('/api/login', {
method: 'POST',
body: JSON.stringify(data),
})
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="email">Email</label>
<input id="email" type="email" {...register('email')} />
{errors.email && <p role="alert">{errors.email.message}</p>}
</div>
<div>
<label htmlFor="password">Password</label>
<input id="password" type="password" {...register('password')} />
{errors.password && <p role="alert">{errors.password.message}</p>}
</div>
<div>
<label>
<input type="checkbox" {...register('rememberMe')} />
Remember me
</label>
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Signing in...' : 'Sign in'}
</button>
</form>
)
}The whole flow:
- User types. Input registers with the form. No re-render.
- User submits.
handleSubmitruns Zod validation. - If validation fails, errors populate
formState.errors. Form re-renders to show them. - If validation passes,
onSubmitruns with typed data.
The FormValues type is inferred from the Zod schema. No duplicate type definitions. Change the schema, the types update everywhere.
Common patterns
Controlled inputs
Some inputs must be controlled — date pickers, typeahead, rich text editors. Use the Controller component:
import { Controller } from 'react-hook-form'
<Controller
control={control}
name="birthDate"
render={({ field }) => (
<DatePicker
selected={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
/>
)}
/>Controller wraps the external library and bridges it to React Hook Form's state. Use it only when you need to control value programmatically.
Conditional fields
Show a field based on another field's value:
const { watch } = useForm(...)
const isSubscribed = watch('isSubscribed')
return (
<>
<label>
<input type="checkbox" {...register('isSubscribed')} />
Subscribe to newsletter
</label>
{isSubscribed && (
<div>
<label>Frequency</label>
<select {...register('frequency')}>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
</select>
</div>
)}
</>
)Async validation
Server-side validation (e.g., unique username check):
const schema = z.object({
username: z.string().min(3).refine(
async (value) => {
const res = await fetch(`/api/check-username?u=${value}`)
const { available } = await res.json()
return available
},
'Username is taken'
),
})Async refinements work the same as sync ones. Be careful with debouncing — RHF does not debounce by default.
Dynamic field arrays
Lists of fields (multiple addresses, multiple children, etc.):
import { useFieldArray } from 'react-hook-form'
const { fields, append, remove } = useFieldArray({
control,
name: 'addresses',
})
return (
<>
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`addresses.${index}.street`)} />
<input {...register(`addresses.${index}.city`)} />
<button type="button" onClick={() => remove(index)}>Remove</button>
</div>
))}
<button type="button" onClick={() => append({ street: '', city: '' })}>
Add address
</button>
</>
)useFieldArray handles keys, ordering, and state correctly.
Integration with Next.js Server Actions
For progressive enhancement, the form works without JS by submitting to a Server Action:
'use client'
import { useForm } from 'react-hook-form'
import { useActionState } from 'react'
import { loginAction } from '@/app/actions/login'
export function LoginForm() {
const [state, formAction, pending] = useActionState(loginAction, null)
const {
register,
formState: { errors },
} = useForm()
return (
<form action={formAction}>
<input {...register('email')} />
{errors.email && <p>{errors.email.message}</p>}
<input type="password" {...register('password')} />
{errors.password && <p>{errors.password.message}</p>}
<button disabled={pending}>{pending ? 'Signing in...' : 'Sign in'}</button>
</form>
)
}The form submits via the Server Action. Without JS, it submits as a regular POST. With JS, the action runs progressively.
For richer client-side validation, use RHF's validation on top:
const onSubmit = (data) => {
// Run server action with validated data
startTransition(() => loginAction(data))
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* ... */}
</form>
)You get the best of both worlds: client-side UX with progressive enhancement.
Common mistakes
- Controlled inputs by default — slow and unnecessary. Use
registerand reach forControlleronly when required. - Validation only on the client — server must validate independently. Same Zod schema on both sides.
- Missing error display — register errors but never render them. Users see "cannot submit" with no explanation.
- Not disabling submit during async — users double-submit. Use
isSubmittingordisabled={pending}. - Mixed Formik patterns — if you have RHF, do not import Formik concepts. Pick one library per form.
React Hook Form plus Zod is the form stack I use for everything non-trivial. It is fast, type-safe, and works with Server Actions. Stop writing forms in useState.
Want help with your React forms?
I build React and Next.js apps with proper form validation, accessibility, and server integration. Let's talk.
Frequently Asked Questions
Is React Hook Form better than Formik?
Yes, in most cases. React Hook Form uses uncontrolled components by default, which means fewer re-renders and better performance on large forms. It has a smaller bundle size and better TypeScript support. Formik is still maintained but rarely the better choice for new projects.
How does React Hook Form work with Zod?
Use zodResolver from @hookform/resolvers/zod. Pass your Zod schema to zodResolver, then pass that resolver to useForm. The form now validates against the schema automatically, and errors come back typed and structured.
Does React Hook Form work with Next.js Server Actions?
Yes. Inside your handleSubmit function, call your Server Action with the validated data. The form remains progressive (works without JS) if you set action on the form element and use the useFormState hook.
Should I use controlled or uncontrolled inputs in React?
Default to uncontrolled for forms. Use register from React Hook Form to wire up inputs without state. Reach for Controller (from RHF) or useState only when you need to control value programmatically — date pickers, typeahead, rich text editors.