facebook / react · Issue No. 37585
https://github.com/tsotneguti/bug-demo.git
16.0.619.2.019.2.07.68.0@hookform/resolvers 5.2.2I found a case where changing only the order of an unreachable early return and a useEffect changes whether a child component renders when React Hook Form's formState.isValid changes.
canAddMinors is always true, so the early return is never actually taken. So this example should not be regarded as violation of Rules of React IMHO.
useEffect BEFORE the early returnuseEffect(() => {
console.log("Parent effect");
}, [form.formState.isValid]);
if (!canAddMinors) {
return <div>Error</div>;
}
When typing into the input changes:
isValid: false → true
Step2 renders again, but MinorForm is not invoked.
useEffectif (!canAddMinors) {
return <div>Error</div>;
}
useEffect(() => {
console.log("Parent effect");
}, [form.formState.isValid]);
With the same isValid change, Step2 renders again and MinorForm is invoked.
use no memo testAdding:
"use no memo";
to Step2 makes both versions behave identically (MinorForm invoked).
This makes me suspect that the React Compiler optimization of the parent component is involved.
Replacing the computed canAddMinors value with a hardcoded value:
const canAddMinors = true;
also changes the behavior.
With canAddMinors hardcoded to true, MinorForm does not render when isValid changes, regardless of whether the early return is positioned before or after the useEffect.
"use client";
import React, { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
const schema = z.object({
firstName: z.string().min(1, "Required"),
});
type FormValues = z.infer<typeof schema>;
function MinorForm({
form,
isValid,
tick,
}: {
form: ReturnType<typeof useForm<FormValues>>;
isValid: boolean;
tick: number;
}) {
console.log("MinorForm function");
useEffect(() => {
console.log("MinorForm committed");
});
return (
<div style={{ border: "1px solid green", padding: 8 }}>
<h4>MinorForm</h4>
<input {...form.register("firstName")} placeholder="First Name" />
<button disabled={!isValid}>Save</button>
</div>
);
}
export default function Step2() {
// "use no memo";
const form = useForm<FormValues>({
resolver: zodResolver(schema),
mode: "onChange",
});
const [showForm] = useState(true);
const [tick, setTick] = useState(0);
const applicantBirthDate = "2005-01-01";
const canAddMinors = (() => {
if (!applicantBirthDate) {
return false;
}
const birth = new Date(applicantBirthDate);
const now = new Date();
const age = (now.getTime() - birth.getTime()) / (1000 * 60 * 60 * 24 * 365);
return age >= 18;
})();
// Move this useEffect above/below the early return
// to reproduce the difference.
if (!canAddMinors) {
return <div>Error</div>;
}
useEffect(() => {
console.log("Parent useEffect: isValid changed");
}, [form.formState.isValid]);
console.log("Parent render", canAddMinors);
return (
<div style={{ padding: 16 }}>
{showForm && <MinorForm tick={tick} isValid={form.formState.isValid} form={form} />}
<button onClick={() => setTick((t) => t + 1)}>Force parent re-render ({tick})</button>
</div>
);
}
Changing the position of an unreachable early return should not change whether MinorForm is invoked when formState.isValid changes by typing into the input or clearing it.
The two source orderings produce different child-rendering behavior.
Adding:
"use no memo";
to Step2 removes the difference.
Every time
19.2.0
1.0.0
Relay reads this issue against the repository's contribution signals: the files it is likely to touch, how the maintainers triage work this size, and what the first contribution would exercise.
The full analysis for this issue is still being assembled. Until then, the description above and the thread on GitHub are the most reliable context.