DEV Community

kcsujeet
kcsujeet

Posted on

Enforcing React Hook Form Best Practices with Custom Biome Rules

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."
    )
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

2. watch vs. useWatch

The Anti-Pattern:
Using the imperative watch function to monitor input changes.

// ❌ Bad
const { watch } = useForm();
const firstName = watch("firstName");
Enter fullscreen mode Exit fullscreen mode

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" });
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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"]
}
Enter fullscreen mode Exit fullscreen mode

(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)