Why useEffect placement affects child re-renders in React
Explain how React useEffect placement and unreachable early returns affect child re-renders with react-hook-form. Covers hook order and practical fixes.
Why does the placement of useEffect relative to an “unreachable” early return affect child component re-render behavior in React?
Context: Next.js 16, React 19.2, React Hook Form 7.6.
I encountered unexpected behavior with useEffect and an early return that should be unreachable. Here’s the code:
"use client";
import React, { useState, useEffect } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
// Dummy schema
const schema = z.object({
firstName: z.string().min(1, "Required"),
});
type FormValues = z.infer<typeof schema>;
// MinorForm Component
function MinorForm({
form,
isValid,
tick,
}: {
form: ReturnType<typeof useForm<FormValues>>;
isValid: boolean;
tick: number;
}) {
console.log("MinorForm render", tick, "isValid prop:", isValid);
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>
);
}
// Step2 Parent Component
export default function Step2() {
const form = useForm<FormValues>({
resolver: zodResolver(schema),
mode: "onChange",
});
const { reset } = form;
const [showForm, setShowForm] = useState(true);
const [tick, setTick] = useState(0);
// Dummy formValues
const [formValues, setFormValues] = useState({
step1: {
birthDate: "2005-01-01",
},
});
const applicantBirthDate = formValues.step1?.birthDate;
// Derived value like canAddMinors
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;
})();
useEffect(() => {
console.log("Parent useEffect: isValid changed:", form.formState.isValid);
}, [form.formState.isValid]);
if (!canAddMinors) return <div></div>;
console.log("Parent render", form.formState.isValid);
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>
);
}
Typing in the input does not re-render the child MinorForm.
However, swapping the positions (early return first, then useEffect):
if (!canAddMinors) return <div>Early return</div>;
useEffect(() => {
console.log("Parent useEffect: isValid changed:", form.formState.isValid);
}, [form.formState.isValid]);
Now, typing in the input does re-render the child MinorForm.
Note: canAddMinors is a constant (always true here), so the early return is unreachable and hook rules are not violated.
How can this behavior be explained? Why does useEffect placement relative to the early return impact child re-renders in React?
The placement of your react useEffect changes when and where your component (and thus any react-hook-form subscriptions) are established: reading form.formState.isValid during render registers a subscription and that registration’s timing—relative to your early-return and the commit phase—determines whether the parent/child stay wired to receive updates. In short: hook/effect placement changes the subscription/read order and the component tree state seen by React, so the child may or may not get the prop updates that trigger its re-render; the fix is to set up subscriptions where you actually want them (useFormState / useWatch or move the read into the child) and keep hooks and subscription reads in predictable places.
Contents
- Why react useEffect placement matters
- React render and react hooks lifecycle
- How react-hook-form subscriptions interact with render
- Concrete execution trace for your code
- Fixes and recommendations (practical code)
- Debugging checklist
- Sources
- Conclusion
Why react useEffect placement matters
Short answer: React runs the component function top-to-bottom and registers hooks in the order they are called. That ordering plus when you read external reactive values (like form.formState.isValid) determines which subscriptions get created during a render and whether the child component is part of the rendered tree when updates arrive. Because your useEffect placement changes the moment the effect executes (and where you read form.formState.isValid), you change how react-hook-form wires subscriptions — and that changes whether the child receives updates that cause re-renders.
You can read the core rule in the React docs: hooks must be called at the top level and in the same order on every render (useEffect – React). That rule explains why conditional or misplaced hooks are risky; the RHF behavior explains why the symptom looks like “moving the effect changes whether the child re-renders” (see the GitHub discussion on formState re-renders for details).
React render and react hooks lifecycle
A quick, practical refresher on the render/effect flow:
- React calls your function component synchronously (render phase). Hooks are invoked in order and React records them.
- The returned JSX is compared and the virtual DOM patch is prepared.
- After commit, React runs useEffect callbacks (commit phase).
- If some external source (like react-hook-form) triggers a state update, React will schedule a new render; during that render the same hook order must be preserved.
Because evaluation and hook registration happen before effects run, where you place reads and hooks in the component body matters. The official docs warn against calling hooks inside conditionals because order can change between renders; even when a conditional is currently unreachable, its presence affects how you reason about subscription timing (useEffect – React).
How react-hook-form subscriptions interact with render
React Hook Form uses an internal subscription model to avoid whole-form re-renders. When a component reads form.formState (or uses useWatch/useFormState), RHF may register that component as a subscriber for certain slices (e.g., isValid). That subscription is created at the time you access the value during render. The GH issue thread on infinite re-renders and formState explains that reading formState or using useWatch changes which components get notified when values change; the parent will only re-render when it’s actually subscribed to the slice that changed (react-hook-form issue #12599).
So: reading form.formState.isValid in the parent means the parent will be subscribed to validity updates. If that read happens at a different point in the function relative to the early return, you change whether the subscription is registered in the same render pass that produces the child or whether the child ever becomes part of the tree for those updates.
Concrete execution trace for your code
Here’s a condensed step-by-step for the two placements you tried.
A) useEffect declared BEFORE the early-return (your first snippet)
- Render begins:
useFormruns,useEffectis registered (React stores the effect and captures dependency values). if (!canAddMinors) return <div />— condition is checked; in your case it’s true so execution continues and child mounts.- Commit phase → effect runs (logs current isValid).
- User types in MinorForm input → react-hook-form updates its internal state and notifies subscribers. If the parent’s subscription timing or read didn’t line up with the child mount (or the parent hasn’t been the one that RHF expects to re-render), the child may not see the update passed via props.
B) early-return BEFORE useEffect (your second snippet)
- Render begins:
useFormruns. if (!canAddMinors) return <div />— that check happens before you register / read anything tied to the effect’s dependency. Because it’s unreachable in your case the child still mounts, but the point whereform.formState.isValidis read (when the effect is registered later) is different.- When typing, RHF notification now finds the subscription that corresponds to the mounted child/parent, so the child receives prop updates and re-renders.
Why do those micro-order changes matter? Because RHF’s subscription is sensitive to where and when you read formState during render, and React’s hook/effect registration order determines that timing. Small re-orderings can therefore change which components are subscribed at which moments.
Note: React will not let you call hooks conditionally in general — that remains a hard rule. In practice, an apparently “unreachable” early return can hide a brittle assumption; moving code around can change timing even if the condition is currently constant.
Fixes and recommendations (practical code)
Goal: make subscription and render behavior explicit and robust.
- Move the subscription to the component that needs it (best)
- Let MinorForm subscribe to validity instead of parent. That keeps the parent free of RHF re-renders and makes intent explicit.
Example using useFormState (RHF v7+):
// MinorForm — subscribe locally
import { useFormState } from "react-hook-form";
function MinorForm({ form, tick }) {
const { isValid } = useFormState({ control: form.control });
console.log("MinorForm render", tick, "isValid:", isValid);
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>
);
}
This removes the parent’s direct read of form.formState.isValid and ensures the child re-renders exactly when validity changes.
- If the parent truly needs
isValid, useuseFormStatein the parent (and call it top-level)
import { useFormState } from "react-hook-form";
const { isValid } = useFormState({ control: form.control });
Call hooks at top-level (before any return) — that preserves hook order and makes subscriptions predictable.
-
Avoid putting
form.formState.isValiddirectly into an effect dependency if you only need updates inside the effect; instead useuseFormStateoruseWatchso that React and RHF agree about the subscription. -
Don’t rely on “unreachable” early returns. If you put hooks after a return that might ever be taken, you’ll violate React’s rules. If the condition is truly static for the life of the component, you may get away with it in a narrow case — but it’s brittle and easy to break later.
-
Use React DevTools Profiler and console logs to validate which component actually re-renders. Add logs at top of both parent and child; that often quickly shows where the subscription is missing.
Quick checklist you can apply now:
- Move isValid subscription into MinorForm via useFormState/useWatch.
- If parent must see isValid, call useFormState in parent at top-level (before returns).
- Keep useEffect and other hooks at top-level and maintain stable hook order.
Debugging checklist
- Add logging in Step2 and MinorForm at top of the render body to inspect order: console.log at several spots.
- Try toggling canAddMinors between true/false to see how early-return interacts with subscriptions.
- Use React Profiler to measure renders on input change.
- Strip the project down to a minimal reproduction (just useForm, one input, parent and child) and reproduce the placement swap to confirm behavior.
- Check for Strict Mode double-render differences in development; React 18+ double-invokes mount-phase code under Strict Mode which can expose timing issues.
Sources
- https://stackoverflow.com/questions/79856884/why-do-useeffect-and-an-early-return-affect-child-re-renders-when-using-react-ho
- https://stackoverflow.com/questions/57620799/react-hook-useeffect-is-called-conditionally
- https://react.dev/reference/react/useEffect
- https://stackoverflow.com/questions/63711013/how-to-trigger-useeffects-before-render-in-react
- https://dev.to/collegewap/fix-react-hook-useeffect-is-called-conditionally-25m5
- https://github.com/react-hook-form/react-hook-form/issues/12599
Conclusion
The surprising behavior comes down to timing: react useEffect placement changes when you read/react to form.formState.isValid, and react-hook-form registers subscriptions at read time — so different ordering can change which components are subscribed and therefore which ones re-render. The robust fixes are to keep hooks and subscription reads in predictable places (top-level), or better yet, move the form-state subscription into the component that actually needs it (useFormState / useWatch). That removes brittle ordering assumptions and makes child re-renders reliable under the react hooks rules and react-hook-form semantics.