React Compiler skipped my component and hid a lint error
I turned on the React Compiler lint rules and checked one form component. The compiler skipped it twice, for try/finally and for react-hook-form's watch(). Only after both fixes did it find a real error that my normal lint had missed the whole time.
I did a small test with React Compiler. I turned on its lint rules in one of my projects and checked a single form component: the modal that invites a new user.
The lint rules ship in eslint-plugin-react-hooks. They run the compiler over each component and report what it finds.
Three things happened, one after the other.
1. try/finally: the compiler skipped the whole component
The first warning was this:
warning Todo: (BuildHIR::lowerStatement) Handle TryStatement with a finalizer ('finally') clause
react-hooks/todoThe submit handler looked roughly like this (simplified):
const onSubmit = async (data: InviteForm) => {
setIsSubmitting(true);
setSuccess(false);
try {
const result = await inviteUserAction(data.fullName, data.email, data.roles);
if (result.success) setSuccess(true);
else setError(result.error);
} finally {
setIsSubmitting(false);
}
};The compiler doesn't support a try with a finally clause yet. It's a "Todo", not a mistake in my code. But the result is the same: it can't compile the component, so it skips all of it.
To keep going, I removed the finally and reset the flag after the try/catch instead:
const onSubmit = async (data: InviteForm) => {
setIsSubmitting(true);
setSuccess(false);
try {
const result = await inviteUserAction(data.fullName, data.email, data.roles);
if (result.success) setSuccess(true);
else setError(result.error);
} catch {
setError("Something went wrong");
}
setIsSubmitting(false);
};2. watch(): the compiler skipped it again
With the finally gone, a new warning showed up:
warning Compilation Skipped: Use of incompatible library
React Hook Form's `useForm()` API returns a `watch()` function which cannot be memoized safely.
react-hooks/incompatible-libraryOn these two lines:
const selectedRoles = watch("roles");
const primaryRole = watch("primaryRole");watch() returns values that change behind the same function. React can't tell when they changed, so a memoized component could show old values. To avoid that, the compiler skips the component again.
The fix is useWatch(), which is a real hook that React can track:
const selectedRoles = useWatch({ control, name: "roles" });
const primaryRole = useWatch({ control, name: "primaryRole" });3. The real error: setState inside useEffect
After switching to useWatch(), the compiler could finally read the component. And it found an actual error: I was resetting state inside a useEffect when the modal closed.
// Reset form when modal closes
useEffect(() => {
if (!isOpen) {
reset();
setSuccess(false);
setError(null);
}
}, [isOpen, reset]);Setting state inside an effect makes React render twice: once with the old state, then again after the effect runs. The cleaner place for a reset is the event that causes it, the close handler:
const handleClose = () => {
reset();
setSuccess(false);
setError(null);
onClose();
};The interesting part
That setState rule was already on in my ESLint config, set to error. But my normal lint showed 0 errors.
The component was being skipped the whole time. First because of try/finally, then because of watch(). The rule never got to look at it, so it never reported anything.
Check your own project
Run ESLint and look at the warnings, not only the errors:
npx eslint . | grep -E "react-hooks/(todo|incompatible-library)"Every match is a component the compiler skipped. Until you fix the reason it was skipped, the compiler-based rules aren't checking it at all.