React Hook Form and Zod: A Year in Production Without Boilerplate Code or Hidden Pitfalls
After a year of active use of the React Hook Form and Zod combo in production on a large project, the team confirms: the declarative approach to forms reduces code volume by 30–40%, but requires attention to implementation details. We share the pitfalls we discovered and how to overcome them.
From MobX to Declarative Forms: Why Switch Approaches?
In previous project iterations, forms were built using MobX in an MVC style. This method allowed controlling state but generated boilerplate code: each field required separate methods in the controller, error arrays, and manual synchronization with the UI. For example, adding a new field meant creating:
export class SomeController {
public setDuration = (v: number) => {
this.store.duration = v;
}
}
Such templated code wasn't related to business logic but took up to 60% of the component's volume. Switching to React Hook Form (RHF) with Zod solved the problem: the validation schema is described declaratively, and the library automates state synchronization and error handling.
Example of a basic form:
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const formSchema = z.object({
name: z.string().min(1, 'Enter name'),
email: z.string().email('Invalid email'),
age: z.coerce.number().min(0, 'Invalid vozrast').optional(),
});
type FormValues = z.infer<typeof formSchema>;
export function ExampleForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
name: '',
email: '',
},
});
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<div>
<input {...register('name')} />
{errors.name && <span>{errors.name.message}</span>}
</div>
<div>
<input type="email" {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
</div>
<div>
<input type="number" {...register('age')} />
{errors.age && <span>{errors.age.message}</span>}
</div>
<button type="submit">Send</button>
</form>
);
}
Result: code volume shrank, maintenance got simpler, and the speed of developing new forms increased. However, during real-world use, nuances not covered in the documentation surfaced.
Three Pitfalls in the React Hook Form and Zod Combo
Over a year of using RHF and Zod, we ran into three key issues that can break form functionality in certain scenarios.
1. Handling undefined and null
RHF doesn't support undefined as a field value. Attempting to set:
methods.setValue('age', undefined)
makes the field ignore the change. The docs mention this, but it's not obvious. The standard fix is to use null and declare the field as .nullable():
const schema = z.object({
age: z.number().nullable()
});
methods.setValue('age', null);
However, this can conflict with custom components expecting a number. For example, an age input component might break on null. The flip side: the component returns undefined when cleared, but RHF won't process that value.
Reset buttons add extra complexity. The reset() method pulls values from defaultValues, but if they're set to undefined, reset fails. Solution: use null or an empty string for string fields.
2. Unexpected Type Errors in Zod
The problem crops up with data type mismatches. For example, this schema:
const schema = z.object({
field1: z.string().min(1, 'Fill field').optional()
});
If the component returns null (not undefined), Zod throws invalid type (expected string received null). Users see a system message instead of your custom text.
Solution: explicitly set error text for all validation types:
const schema = z.object({
field1: z.string('Fill field').min(1, 'Fill field').optional()
});
This ensures any error shows your message.
3. Instability of the isDirty Flag
The formState.isDirty flag shows if the form has changes. But initializing with empty defaultValues:
const methods = useForm({
resolver: zodResolver(formSchema),
defaultValues: {},
});
makes isDirty turn true right after render, even if the user hasn't touched anything. Meanwhile, dirtyFields stays an empty object.
This stems from a mismatch between defaultValues structure and the schema. To avoid false triggers, make sure defaultValues includes every field from the schema—even with undefined or null.
How to Avoid Common Mistakes?
Drawing from experience, here are proven practices:
- For handling empty values:
- String fields: use an empty string as default.
- Numeric/object fields: apply .nullable() in Zod and null for values.
- Always verify custom components support the chosen type.
- For reliable validation:
- Specify a general error message for every validation step, as in the example above.
- Test null and undefined scenarios during component development.
- For correct isDirty behavior:
- Initialize defaultValues to match the schema, covering all fields.
- Skip the empty object {}—explicitly list fields with undefined or null.
We also recommend adding unified helpers for forms to your project, like a reset function that handles RHF quirks:
const resetForm = () => {
methods.reset({
name: null,
email: null,
age: null,
});
};
Key Takeaways
- Code reduction doesn't eliminate testing needs: The declarative approach saves time but demands rigorous edge-case checks.
- Type safety is your friend: Zod with TypeScript prevents many bugs, but schemas must be set up correctly.
- Document the quirks: Log workarounds for common RHF and Zod issues in your project's internal docs.
— Editorial Team
No comments yet.