Form State with React Hook Form
Why Forms Are a Special Category of State
Forms look simple from the outside but are secretly one of the hardest state problems in frontend. Think about what a robust form actually needs:
- Track the value of every field
- Track which fields have been touched, changed, or focused
- Validate on blur, on change, or on submit (each with different UX trade-offs)
- Show contextual error messages
- Handle dependent validation (field B's rules depend on field A's value)
- Manage dynamic field arrays (add/remove items)
- Handle submission (including async, pending states, and server errors)
- Reset to initial values or a new default
Stuffing all this into Redux or Zustand is technically possible but massively overkill. Form state is ephemeral — it exists during editing and vanishes on submit. It has a clear lifecycle that doesn't benefit from global state management.
React Hook Form treats the DOM as the source of truth. Instead of React controlling every input via state (controlled components), RHF lets the browser manage input values natively (uncontrolled components) and only reads them when needed — on validation or submission. It's like the difference between a micromanager checking in every second and a project manager who trusts the team and reviews at milestones. The result: far fewer re-renders, especially in large forms.
Uncontrolled vs Controlled: The Performance Gap
Here's the fundamental insight behind React Hook Form: controlled inputs re-render on every keystroke.
// CONTROLLED — re-renders entire form component on every keystroke
function ControlledForm() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [bio, setBio] = useState('');
return (
<form>
<input value={name} onChange={(e) => setName(e.target.value)} />
<input value={email} onChange={(e) => setEmail(e.target.value)} />
<textarea value={bio} onChange={(e) => setBio(e.target.value)} />
</form>
);
}
With 20 fields, typing in any field re-renders the entire component (and all 20 inputs) on every keystroke. That's 20 field re-renders per character typed.
React Hook Form avoids this by registering inputs as uncontrolled:
// UNCONTROLLED — zero re-renders while typing
import { useForm } from 'react-hook-form';
interface FormData {
name: string;
email: string;
bio: string;
}
function UncontrolledForm() {
const { register, handleSubmit, formState: { errors } } = useForm<FormData>();
const onSubmit = (data: FormData) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('name', { required: 'Name is required' })} />
{errors.name && <span>{errors.name.message}</span>}
<input {...register('email', { pattern: { value: /^\S+@\S+$/, message: 'Invalid email' } })} />
{errors.email && <span>{errors.email.message}</span>}
<textarea {...register('bio', { maxLength: { value: 500, message: 'Max 500 chars' } })} />
{errors.bio && <span>{errors.bio.message}</span>}
</form>
);
}
register returns { name, ref, onChange, onBlur } — it connects the input directly to RHF's internal store without React state. The component only re-renders when form state metadata changes (errors, submission status), not on every keystroke.
Controller: When You Need Controlled Components
Some UI libraries (date pickers, rich text editors, custom select components) don't expose a native ref and require controlled props. For these, use Controller:
import { useForm, Controller } from 'react-hook-form';
import { DatePicker } from '@/components/ui/DatePicker';
function EventForm() {
const { control, handleSubmit } = useForm<EventFormData>();
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
name="eventDate"
control={control}
rules={{ required: 'Date is required' }}
render={({ field, fieldState }) => (
<DatePicker
value={field.value}
onChange={field.onChange}
error={fieldState.error?.message}
/>
)}
/>
</form>
);
}
Controller bridges RHF's internal store with controlled components. Use register for native HTML inputs, Controller for custom components that need value + onChange.
Zod Integration for Validation
React Hook Form integrates with Zod for schema-based validation — the same schema validates both client and server:
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const signupSchema = z.object({
username: z.string()
.min(3, 'Username must be at least 3 characters')
.max(20, 'Username must be at most 20 characters')
.regex(/^[a-z0-9_]+$/, 'Only lowercase letters, numbers, and underscores'),
email: z.string().email('Invalid email address'),
password: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Must contain an uppercase letter')
.regex(/[0-9]/, 'Must contain a number'),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: 'Passwords must match',
path: ['confirmPassword'],
});
type SignupFormData = z.infer<typeof signupSchema>;
function SignupForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isDirty, isValid },
} = useForm<SignupFormData>({
resolver: zodResolver(signupSchema),
mode: 'onBlur',
});
const onSubmit = async (data: SignupFormData) => {
await createAccount(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('username')} placeholder="Username" />
{errors.username && <span>{errors.username.message}</span>}
<input {...register('email')} type="email" placeholder="Email" />
{errors.email && <span>{errors.email.message}</span>}
<input {...register('password')} type="password" placeholder="Password" />
{errors.password && <span>{errors.password.message}</span>}
<input {...register('confirmPassword')} type="password" placeholder="Confirm password" />
{errors.confirmPassword && <span>{errors.confirmPassword.message}</span>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Creating account...' : 'Sign Up'}
</button>
</form>
);
}
The zodResolver runs the full Zod schema on validation and maps errors to field names. z.infer generates the TypeScript type from the schema — single source of truth for types and validation.
Form State Metadata
React Hook Form tracks rich metadata about your form:
const {
formState: {
isDirty, // any field changed from default?
isValid, // all validations passing?
isSubmitting, // submission in progress?
isSubmitSuccessful,
submitCount, // how many times submitted
errors, // validation errors by field name
dirtyFields, // which fields were modified
touchedFields, // which fields were focused and blurred
},
} = useForm<MyFormData>();
This metadata is proxied — RHF only subscribes to the fields you actually destructure. If you only use errors and isSubmitting, changes to isDirty don't trigger re-renders.
Field Arrays
Dynamic forms where users add/remove items:
import { useForm, useFieldArray } from 'react-hook-form';
interface InvoiceForm {
lineItems: Array<{
description: string;
quantity: number;
unitPrice: number;
}>;
}
function InvoiceEditor() {
const { register, control, handleSubmit } = useForm<InvoiceForm>({
defaultValues: {
lineItems: [{ description: '', quantity: 1, unitPrice: 0 }],
},
});
const { fields, append, remove } = useFieldArray({
control,
name: 'lineItems',
});
return (
<form onSubmit={handleSubmit(onSubmit)}>
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`lineItems.${index}.description`)} />
<input {...register(`lineItems.${index}.quantity`, { valueAsNumber: true })} type="number" />
<input {...register(`lineItems.${index}.unitPrice`, { valueAsNumber: true })} type="number" />
<button type="button" onClick={() => remove(index)}>Remove</button>
</div>
))}
<button type="button" onClick={() => append({ description: '', quantity: 1, unitPrice: 0 })}>
Add Line Item
</button>
</form>
);
}
Always use field.id as the key, not the array index. RHF generates stable IDs for each field array item — using index as key causes bugs when items are reordered or removed.
Never use the array index as the key prop for field array items. When you remove item at index 2 from a 5-item list, items at index 3 and 4 shift to 2 and 3. React thinks items 2 and 3 "changed" and item 4 was "removed" — the wrong item disappears and the wrong inputs get the wrong values. Always use the stable field.id from useFieldArray.
When to Use What
| Approach | Best For | Re-renders | Complexity |
|---|---|---|---|
| React Hook Form | Complex forms: validation, field arrays, multi-step | Minimal (uncontrolled) | Medium |
| useActionState (React 19) | Simple forms backed by Server Actions | On submission only | Low |
| Controlled useState | Single field or very simple 2-3 field forms | Every keystroke | Low |
| Formik | Legacy codebases already using it (don't migrate for fun) | Every keystroke (controlled) | Medium |
| What developers do | What they should do |
|---|---|
| Using register for third-party UI components (date pickers, custom selects) that don't forward ref register works by attaching a ref to the DOM element. If the component doesn't expose a ref to the underlying input, register can't read or set the value. Controller bridges the gap. | Use Controller for components that need controlled value + onChange props |
| Watching all fields with watch() at the form level for conditional rendering watch() without arguments subscribes to ALL field changes, re-rendering the component on every change across every field. watch('specificField') only subscribes to that field. | Use watch('specificField') to watch only the field that drives the condition |
| Storing form state in a global store (Redux/Zustand) so it persists across navigation Form state is ephemeral by nature. Putting it in a global store couples it to the app lifecycle instead of the form lifecycle. If you need cross-navigation persistence, sessionStorage is lighter. | If form persistence is needed, use RHF with a persist utility or save to sessionStorage on unmount |
- 1Use register for native HTML inputs. Use Controller for custom components without ref forwarding.
- 2Zod + zodResolver gives you type-safe validation with a single schema for both client and server.
- 3Field arrays need field.id as key — never use the array index.
- 4formState is proxied — only destructure what you need to minimize re-renders.
- 5For simple Server Action forms, useActionState (React 19) is lighter than React Hook Form.
- 6Form state is ephemeral. Don't put it in global stores. It lives during editing and dies on submit.