React Hook Form (RHF) is the gold standard for managing forms in React. Its performance rely on a subscription model that avoids unnecessary re-renders. However, it's easy to accidentally introduce patterns that trigger full-form re-renders on every keystroke.
I recently migrated my projects from ESLint to Biome and I needed to bring over several custom rules used to prevent these performance pitfalls. While Biome's GritQL engine is powerful, I found that the documentation for creating custom rules in Biome is still limited.
In this post, I'll share how to use Biome to catch three common RHF anti-patterns before they reach production. This might be helpful to you if you are also navigating the same migration or looking to enforce RHF best practices automatically.
The Custom Rules
First, create a file named react-hook-form-rules.grit in your project root.
Here is the complete GritQL pattern I wrote. It looks for three specific scenarios that degrade RHF performance:
any {
// Rule 1: Prevent direct formState access on the methods object
`$m1.formState` where {
register_diagnostic(
span=$m1,
message="Don't access formState from methods object. Use useFormState hook instead for better performance."
)
},
// Rule 2: Discourage .watch() in favor of useWatch()
`$m2.watch($a1)` where {
register_diagnostic(span=$m2, message="Use of .watch() method from react-hook-form is not allowed. Consider using useWatch() instead.")
},
`watch($a2)` where {
register_diagnostic(span=$a2, message="Direct use of watch() from react-hook-form is not allowed. Consider using useWatch() instead.")
},
// Rule 3: Prevent the 'methods' object from entering dependency arrays
`methods` as $m3 where {
$m3 <: within `[$d1]` as $a3 ,
$a3 <: within any {
`useEffect($_, $a3)`,
`useMemo($_, $a3)`,
`useCallback($_, $a3)`
} ,
register_diagnostic(
span=$m3,
message="React Hook Form 'methods' object should not be in dependency array. Destructure it and include only specific methods needed."
)
}
}
Let's break down why these rules exist.
1. The formState Proxy Trap
The Anti-Pattern:
Accessing formState directly from the useForm return object.
// ❌ Bad
const { formState, register } = useForm();
// OR
const methods = useForm();
console.log(methods.formState.errors);
Why it's bad:
RHF uses a proxy to track which fields you are observing. If you destructure formState or access it at the root level of your component, RHF has to assume you are subscribed to all changes. This often leads to the entire component re-rendering whenever any validation state changes.
The Fix:
Use the useFormState hook. This isolates the subscription to that specific hook call, allowing you to re-render only the components that actually need the error messages, rather than the whole form.
// ✅ Good
const { control } = useForm();
const { errors } = useFormState({ control });
2. watch vs. useWatch
The Anti-Pattern:
Using the imperative watch function to monitor input changes.
// ❌ Bad
const { watch } = useForm();
const firstName = watch("firstName");
Why it's bad:
Similar to the issue above, watch triggers a re-render of the host component (the component calling useForm) whenever the watched field changes. If you have a large form, typing in the "First Name" field shouldn't force the "Address" section to re-render.
The Fix:
useWatch is a hook specifically designed for performance. It subscribes to the store internally and can be used in child components to isolate re-renders to just that child.
// ✅ Good
const firstName = useWatch({ control, name: "firstName" });
I have written a separate detailed article on this topic: React Hook Form: Understanding watch vs useWatch.
3. The Dependency Array Hazard
The Anti-Pattern:
Passing the entire methods object into a useEffect or useCallback.
// ❌ Bad
const methods = useForm();
useEffect(() => {
methods.reset();
}, [methods]); // <--- This is dangerous
Why it's bad:
While useForm tries to be stable, the methods object itself contains many properties. If you depend on the whole object, you risk infinite loops or effect firings whenever any part of the internal form state updates (depending on how the object reference is handled in your specific RHF version).
The Fix:
Destructure the methods and only include the specific function you need. RHF guarantees stability for functions like setValue, getValues, and reset.
// ✅ Good
const { reset } = useForm();
useEffect(() => {
reset();
}, [reset]);
Configuring Biome
Once you have created your .grit file, integrating it is incredibly simple. You don't need to inline complex logic strings into your JSON config.
Just reference the file in your biome.json plugins array:
{
"plugins": ["./react-hook-form-rules.grit"]
}
(Note: Custom GritQL rules are an advanced feature in Biome. Ensure you are on a compatible version).
This keeps your configuration clean while moving code review feedback from "after the PR is open" to "the moment you type the code."
Conclusion
Tools like Biome are exciting not just because they are fast (and Biome is incredibly fast), but because they allow us to codify team knowledge.
Instead of writing "Please use useWatch" on pull requests for the 100th time, we can bake that knowledge into the toolchain, saving everyone mental energy and keeping our forms buttery smooth.
Have you written any custom rules for your projects yet? Let me know in the comments!
Top comments (0)