Resolving React Hook Form Setup Issues and Performance Pitfalls
Solutions for common React Hook Form configuration problems including controller integration, schema validation, and form state management in large forms.
Introduction
React Hook Form is the most popular form library for React, but its configuration options can be confusing for developers coming from controlled component patterns. The library's uncontrolled approach provides excellent performance, but integrating with external component libraries and managing complex form state requires understanding the Controller component and the register API.
This post addresses the setup issues I encounter most frequently when integrating React Hook Form with Material UI, custom components, and Zod validation schemas.
Environment
node --version
# v20.11.0
npm list react react-hook-form zod @hookform/resolvers
# react@18.2.0
# react-hook-form@7.49.0
# zod@3.22.4
# @hookform/resolvers@3.3.4The application uses React Hook Form for:
- Multi-step registration forms
- Dynamic forms with add/remove fields
- Forms that interact with external component libraries
Problem
The first issue was that controlled components like Material UI's TextField and Select did not work with register(). The form fields displayed but never updated when users typed:
// This does not work with Material UI components
const { register } = useForm();
// The field accepts the spread but does not update form stateThe second issue was that form submission triggered unnecessary re-renders. Every keystroke in any field caused all other fields to re-render, destroying performance for forms with 20+ fields:
// This pattern causes performance issues
const { watch, handleSubmit } = useForm();
// watch() causes re-renders on every change
const watchedValues = watch();
return (
);The third issue was that the useFieldArray hook did not preserve field values when reordering items. After dragging an item from position 2 to position 0, the values at positions 0 and 2 were swapped but the UI displayed the old values until the next render cycle.
Analysis
React Hook Form uses uncontrolled components by default, which means it attaches a ref to the input element and reads its value on submission. Material UI components do not forward refs to the underlying input element by default, so register() cannot attach the ref. The Controller component bridges this gap by managing the controlled component's value through React Hook Form's state.
The performance issue with watch() is that it subscribes to all field changes by default. When any field changes, the component containing watch() re-renders, which causes all child components to re-render as well. This defeats React Hook Form's uncontrolled component optimization.
The useFieldArray reordering issue is a known limitation. The hook uses the array index as the key, which causes React to reuse existing DOM nodes instead of moving them. This leads to stale values in the displayed fields.
Solution
Use Controller for all controlled components:
import { useForm, Controller } from "react-hook-form";
import TextField from "@mui/material/TextField";
import Select from "@mui/material/Select";
import MenuItem from "@mui/material/MenuItem";
function RegistrationForm() {
const { control, handleSubmit } = useForm({
defaultValues: {
email: "",
role: "user",
},
});
return (
);
}Optimize performance by subscribing only to specific fields:
import { useForm, useWatch } from "react-hook-form";
function LargeForm() {
const { register, handleSubmit, control } = useForm();
// Only re-render when 'email' changes
const EmailDisplay = () => {
const email = useWatch({
control,
name: "email",
});
return Email: {email};
};
return (
);
}Fix the useFieldArray reordering issue with unique keys:
import { useFieldArray, useForm } from "react-hook-form";
import { v4 as uuidv4 } from "uuid";
interface ExperienceItem {
id: string;
company: string;
role: string;
startDate: string;
}
function ExperienceForm() {
const { control, handleSubmit } = useForm<{
experiences: ExperienceItem[];
}>({
defaultValues: {
experiences: [{ id: uuidv4(), company: "", role: "", startDate: "" }],
},
});
const { fields, append, remove, move } = useFieldArray({
control,
name: "experiences",
keyName: "fieldId", // Use custom key instead of 'id'
});
return (
{fields.map((field, index) => (
))}
);
}Integrate with Zod validation using the resolver:
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useForm } from "react-hook-form";
const registerSchema = z
.object({
name: z.string().min(1, "Name is required").max(100),
email: z.string().email("Invalid email address"),
password: z
.string()
.min(8, "Password must be at least 8 characters")
.regex(/[A-Z]/, "Must contain at least one uppercase letter"),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
});
type RegisterInput = z.infer;
function RegisterForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm({
resolver: zodResolver(registerSchema),
mode: "onBlur", // Validate on blur for better UX
});
return (
);
} Lessons Learned
Use
Controllerfor any component that manages its own state (Material UI, Ant Design, React Select). Useregisteronly for native HTML inputs.Subscribe to specific fields with
useWatchinstead ofwatch()to prevent unnecessary re-renders in large forms.Set
mode: "onBlur"for better user experience. Real-time validation on every keystroke is distracting for most form types.Provide unique keys to
useFieldArrayusingkeyNameto avoid the reordering value swap issue.Combine React Hook Form with Zod using
@hookform/resolversto get type-safe validation that shares schemas with your backend.
This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.